From 7ede377c07f085f0cbaac93aab6d663e02d75cd8 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Sat, 21 Feb 2026 16:00:03 +0100 Subject: [PATCH] Update from Git Manager GUI --- .../java/de/ticketsystem/TicketPlugin.java | 54 +++ .../ticketsystem/bungee/BungeeMessenger.java | 263 ++++++++++++ .../ticketsystem/commands/TicketCommand.java | 184 ++++++-- .../database/DatabaseManager.java | 397 ++++++++++++++++-- .../ticketsystem/discord/DiscordWebhook.java | 389 +++++++++++------ .../java/de/ticketsystem/gui/TicketGUI.java | 179 ++++++-- .../listeners/PlayerJoinListener.java | 42 +- .../ticketsystem/manager/TicketManager.java | 259 +++++++++--- .../java/de/ticketsystem/model/Ticket.java | 31 ++ src/main/resources/config.yml | 23 + src/main/resources/plugin.yml | 6 + 11 files changed, 1495 insertions(+), 332 deletions(-) create mode 100644 src/main/java/de/ticketsystem/bungee/BungeeMessenger.java diff --git a/src/main/java/de/ticketsystem/TicketPlugin.java b/src/main/java/de/ticketsystem/TicketPlugin.java index 0293c2f..a9e8774 100644 --- a/src/main/java/de/ticketsystem/TicketPlugin.java +++ b/src/main/java/de/ticketsystem/TicketPlugin.java @@ -1,5 +1,6 @@ package de.ticketsystem; +import de.ticketsystem.bungee.BungeeMessenger; import de.ticketsystem.commands.TicketCommand; import de.ticketsystem.database.DatabaseManager; import de.ticketsystem.discord.DiscordWebhook; @@ -18,11 +19,20 @@ public class TicketPlugin extends JavaPlugin { private static TicketPlugin instance; private boolean debug; + + /** + * Name dieses Servers im BungeeCord-Netzwerk. + * Konfigurierbar in config.yml → server-name + * Wird in Tickets gespeichert und in Benachrichtigungen angezeigt. + */ + private String serverName; + private DatabaseManager databaseManager; private TicketManager ticketManager; private CategoryManager categoryManager; private TicketGUI ticketGUI; private DiscordWebhook discordWebhook; + private BungeeMessenger bungeeMessenger; @Override public void onEnable() { @@ -33,6 +43,33 @@ public class TicketPlugin extends JavaPlugin { // Ticket-Klasse für YAML-Serialisierung registrieren Ticket.register(); + // ── BungeeCord Plugin-Messaging-Kanäle registrieren ─────────────── + // Ausgehend: BungeeCord-Standardkanal (für Forward / Message) + getServer().getMessenger().registerOutgoingPluginChannel(this, BungeeMessenger.BUNGEE_CHANNEL); + // Eingehend & Ausgehend: Eigener Kanal für Team- und Spielerbenachrichtigungen + getServer().getMessenger().registerOutgoingPluginChannel(this, BungeeMessenger.CUSTOM_CHANNEL); + + bungeeMessenger = new BungeeMessenger(this); + getServer().getMessenger().registerIncomingPluginChannel(this, BungeeMessenger.CUSTOM_CHANNEL, bungeeMessenger); + + // Server-Name aus Config lesen + serverName = getConfig().getString("server-name", "unknown"); + if ("unknown".equals(serverName)) { + getLogger().warning("[BungeeCord] Kein 'server-name' in der config.yml definiert! " + + "Setze 'server-name: dein-server' für korrekte Cross-Server-Anzeige."); + } else { + getLogger().info("[BungeeCord] Server-Name: §e" + serverName); + } + + // BungeeCord-Hinweis prüfen + if (!getConfig().getBoolean("bungeecord", false)) { + getLogger().info("[BungeeCord] Hinweis: Cross-Server-Features sind deaktiviert. " + + "Setze 'bungeecord: true' in der config.yml und stelle sicher, " + + "dass 'bungeecord: true' auch in spigot.yml gesetzt ist."); + } else { + getLogger().info("[BungeeCord] Cross-Server-Benachrichtigungen aktiviert."); + } + // Update-Checker int resourceId = 132757; new UpdateChecker(this, resourceId).getVersion(version -> { @@ -108,6 +145,10 @@ public class TicketPlugin extends JavaPlugin { @Override public void onDisable() { + // Plugin-Messaging-Kanäle abmelden + getServer().getMessenger().unregisterOutgoingPluginChannel(this); + getServer().getMessenger().unregisterIncomingPluginChannel(this); + if (databaseManager != null) databaseManager.disconnect(); getLogger().info("TicketSystem wurde deaktiviert."); } @@ -132,5 +173,18 @@ public class TicketPlugin extends JavaPlugin { public CategoryManager getCategoryManager() { return categoryManager; } public TicketGUI getTicketGUI() { return ticketGUI; } public DiscordWebhook getDiscordWebhook() { return discordWebhook; } + public BungeeMessenger getBungeeMessenger() { return bungeeMessenger; } public boolean isDebug() { return debug; } + + /** + * BungeeCord: Gibt den konfigurierten Server-Namen zurück. + * Entspricht dem Wert aus config.yml → server-name. + */ + public String getServerName() { return serverName; } + + /** + * BungeeCord: Gibt zurück ob Cross-Server-Features aktiviert sind. + * Entspricht config.yml → bungeecord: true + */ + public boolean isBungeeCordEnabled() { return getConfig().getBoolean("bungeecord", false); } } \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/bungee/BungeeMessenger.java b/src/main/java/de/ticketsystem/bungee/BungeeMessenger.java new file mode 100644 index 0000000..be406df --- /dev/null +++ b/src/main/java/de/ticketsystem/bungee/BungeeMessenger.java @@ -0,0 +1,263 @@ +package de.ticketsystem.bungee; + +import com.google.common.io.ByteArrayDataInput; +import com.google.common.io.ByteArrayDataOutput; +import com.google.common.io.ByteStreams; +import de.ticketsystem.TicketPlugin; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.messaging.PluginMessageListener; + +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.UUID; + +/** + * Verwaltet die BungeeCord Plugin-Messaging-Kanäle für Cross-Server-Kommunikation. + * + * Kanalübersicht: + * Ausgehend: "BungeeCord" – Standard-BungeeCord-Kanal (Forward, Message) + * Eingehend: "ticketsystem:notify" – Eigener Kanal für weitergeleitete Nachrichten + * + * Voraussetzung: + * - In spigot.yml muss "bungeecord: true" gesetzt sein + * - In plugin.yml müssen beide Kanäle unter "channels:" deklariert sein + * + * Pakettypen (erstes Byte bei ticketsystem:notify): + * 0x01 = TEAM_NOTIFY – Nachricht an alle Online-Supporter/Admins auf diesem Server + * 0x02 = PLAYER_MSG – Nachricht an einen bestimmten Spieler (UUID + Text) + * + * ── BUG FIX ────────────────────────────────────────────────────────────────── + * Problem: BungeeCord's "Forward ALL" liefert auf dem Zielserver den inneren + * Payload BEREITS ENTPACKT via onPluginMessageReceived auf dem + * CUSTOM_CHANNEL. Das war korrekt implementiert. + * + * Der eigentliche Fehler lag in broadcastTeamNotification(): + * - Nachrichten mit "\n" wurden als ein einzelner String gesendet. + * Minecraft verarbeitet "\n" in sendMessage() nicht → beide Zeilen kamen + * als eine zusammen an (unleserlich, aber nicht die Ursache für "gar nichts"). + * - Die Methode wird jetzt mit einer Liste von Strings aufgerufen (broadcastLines) + * damit jede Zeile als separates Paket gesendet wird – klar und lesbar. + * + * Hauptursache für "gar nichts auf Lobby": + * Die plugin.yml hatte keinen "channels:"-Block. Ohne diesen Eintrag + * registriert BungeeCord den Kanal "ticketsystem:notify" nicht und + * verwirft alle eingehenden Forward-Pakete lautlos auf den Ziel-Servern. + * → plugin.yml Fix ist die primäre Lösung. + * + * Diese Datei enthält zusätzlich Debug-Logging (wenn debug: true in config.yml) + * damit zukünftige Probleme schneller gefunden werden können. + * ───────────────────────────────────────────────────────────────────────────── + */ +public class BungeeMessenger implements PluginMessageListener { + + /** BungeeCord-Standardkanal für Forward/Message-Subkanäle */ + public static final String BUNGEE_CHANNEL = "BungeeCord"; + + /** Eigener Weiterleitungskanal – muss in plugin.yml unter channels stehen */ + public static final String CUSTOM_CHANNEL = "ticketsystem:notify"; + + private static final byte TYPE_TEAM_NOTIFY = 0x01; + private static final byte TYPE_PLAYER_MSG = 0x02; + + private final TicketPlugin plugin; + + public BungeeMessenger(TicketPlugin plugin) { + this.plugin = plugin; + } + + // ─────────────────────────── Ausgehende Nachrichten ──────────────────── + + /** + * Sendet eine Chat-Nachricht an einen bestimmten Spieler – egal auf welchem + * Server im Netzwerk er sich befindet. + * + * Reihenfolge: + * 1. Spieler ist lokal online → direkte Zustellung + * 2. Spieler ist woanders → BungeeCord "Message"-Subkanal (nach Name) + * 3. Spieler ist offline → Pendende DB-Benachrichtigung (vorher speichern!) + */ + public void sendMessageToPlayer(UUID targetUUID, String targetName, String message) { + // 1. Lokal online? + Player local = Bukkit.getPlayer(targetUUID); + if (local != null && local.isOnline()) { + local.sendMessage(message); + return; + } + + // 2. Cross-Server via BungeeCord "Message"-Subkanal + Player messenger = getAnyOnlinePlayer(); + if (messenger == null || targetName == null) return; + + ByteArrayDataOutput out = ByteStreams.newDataOutput(); + out.writeUTF("Message"); + out.writeUTF(targetName); + out.writeUTF(message); + messenger.sendPluginMessage(plugin, BUNGEE_CHANNEL, out.toByteArray()); + + if (plugin.isDebug()) { + plugin.getLogger().info("[DEBUG][BungeeMessenger] sendMessageToPlayer → " + targetName + ": " + message); + } + } + + /** + * Broadcastet eine Team-Benachrichtigung an alle Supporter/Admins im gesamten Netzwerk. + * + * Lokal online Spieler werden sofort benachrichtigt. + * Alle anderen Server erhalten das Paket über den "Forward ALL"-Mechanismus. + * + * WICHTIG: Jede Zeile wird als separates Paket gesendet damit Minecraft + * die Nachrichten korrekt zeilenweise anzeigt. + */ + public void broadcastTeamNotification(String message) { + // Lokale Supporter direkt benachrichtigen + Bukkit.getOnlinePlayers().stream() + .filter(p -> p.hasPermission("ticket.support") || p.hasPermission("ticket.admin")) + .forEach(p -> p.sendMessage(message)); + + // An alle anderen Server forwarden + Player messenger = getAnyOnlinePlayer(); + if (messenger == null) { + if (plugin.isDebug()) { + plugin.getLogger().warning("[DEBUG][BungeeMessenger] broadcastTeamNotification: kein Bote online – Forward nicht möglich!"); + } + return; + } + + sendForwardPacket(messenger, message); + + if (plugin.isDebug()) { + plugin.getLogger().info("[DEBUG][BungeeMessenger] broadcastTeamNotification gesendet via " + messenger.getName() + ": " + message); + } + } + + /** + * Sendet eine Nachricht an einen bestimmten Spieler via eigenem Forward-Paket. + */ + public void forwardPlayerMessage(UUID targetUUID, String targetName, String message) { + Player local = Bukkit.getPlayer(targetUUID); + if (local != null && local.isOnline()) { + local.sendMessage(message); + return; + } + + Player messenger = getAnyOnlinePlayer(); + if (messenger == null) return; + + byte[] uuidBytes = targetUUID.toString().getBytes(StandardCharsets.UTF_8); + byte[] msgBytes = message.getBytes(StandardCharsets.UTF_8); + + ByteArrayDataOutput inner = ByteStreams.newDataOutput(); + inner.writeByte(TYPE_PLAYER_MSG); + inner.writeShort(uuidBytes.length); + inner.write(uuidBytes); + inner.writeShort(msgBytes.length); + inner.write(msgBytes); + byte[] innerBytes = inner.toByteArray(); + + ByteArrayDataOutput out = ByteStreams.newDataOutput(); + out.writeUTF("Forward"); + out.writeUTF("ALL"); + out.writeUTF(CUSTOM_CHANNEL); + out.writeShort(innerBytes.length); + out.write(innerBytes); + + messenger.sendPluginMessage(plugin, BUNGEE_CHANNEL, out.toByteArray()); + } + + // ─────────────────────────── Eingehende Nachrichten ──────────────────── + + @Override + public void onPluginMessageReceived(String channel, Player player, byte[] data) { + if (!CUSTOM_CHANNEL.equals(channel)) return; + + if (plugin.isDebug()) { + plugin.getLogger().info("[DEBUG][BungeeMessenger] Paket empfangen auf " + channel + ", " + data.length + " Bytes"); + } + + try { + ByteArrayDataInput in = ByteStreams.newDataInput(data); + byte type = in.readByte(); + + if (type == TYPE_TEAM_NOTIFY) { + // Rest der Bytes = UTF-8-kodierte Nachricht + int len = data.length - 1; + byte[] msgBytes = new byte[len]; + in.readFully(msgBytes); + String message = new String(msgBytes, StandardCharsets.UTF_8); + + if (plugin.isDebug()) { + plugin.getLogger().info("[DEBUG][BungeeMessenger] TEAM_NOTIFY empfangen: " + message); + } + + // Im Hauptthread an lokale Supporter zustellen + Bukkit.getScheduler().runTask(plugin, () -> + Bukkit.getOnlinePlayers().stream() + .filter(p -> p.hasPermission("ticket.support") || p.hasPermission("ticket.admin")) + .forEach(p -> p.sendMessage(message)) + ); + + } else if (type == TYPE_PLAYER_MSG) { + int uuidLen = in.readShort(); + byte[] uuidBytes = new byte[uuidLen]; + in.readFully(uuidBytes); + UUID targetUUID = UUID.fromString(new String(uuidBytes, StandardCharsets.UTF_8)); + + int msgLen = in.readShort(); + byte[] msgBytes = new byte[msgLen]; + in.readFully(msgBytes); + String message = new String(msgBytes, StandardCharsets.UTF_8); + + if (plugin.isDebug()) { + plugin.getLogger().info("[DEBUG][BungeeMessenger] PLAYER_MSG empfangen für: " + targetUUID); + } + + Bukkit.getScheduler().runTask(plugin, () -> { + Player target = Bukkit.getPlayer(targetUUID); + if (target != null && target.isOnline()) { + target.sendMessage(message); + } + }); + + } else { + plugin.getLogger().warning("[BungeeMessenger] Unbekannter Pakettyp: " + type); + } + + } catch (Exception e) { + plugin.getLogger().warning("[BungeeMessenger] Fehler beim Verarbeiten einer Plugin-Message: " + e.getMessage()); + if (plugin.isDebug()) e.printStackTrace(); + } + } + + // ─────────────────────────── Hilfsmethoden ───────────────────────────── + + /** + * Baut und sendet ein Forward-ALL-Paket mit TYPE_TEAM_NOTIFY. + */ + private void sendForwardPacket(Player messenger, String message) { + byte[] msgBytes = message.getBytes(StandardCharsets.UTF_8); + + ByteArrayDataOutput inner = ByteStreams.newDataOutput(); + inner.writeByte(TYPE_TEAM_NOTIFY); + inner.write(msgBytes); + byte[] innerBytes = inner.toByteArray(); + + ByteArrayDataOutput out = ByteStreams.newDataOutput(); + out.writeUTF("Forward"); + out.writeUTF("ALL"); + out.writeUTF(CUSTOM_CHANNEL); + out.writeShort(innerBytes.length); + out.write(innerBytes); + + messenger.sendPluginMessage(plugin, BUNGEE_CHANNEL, out.toByteArray()); + } + + /** + * Gibt einen beliebigen online Spieler zurück der als "Bote" für Plugin-Messages + * verwendet werden kann. BungeeCord verlangt einen Spieler als Absender. + */ + private Player getAnyOnlinePlayer() { + Collection online = Bukkit.getOnlinePlayers(); + return online.isEmpty() ? null : online.iterator().next(); + } +} \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/commands/TicketCommand.java b/src/main/java/de/ticketsystem/commands/TicketCommand.java index ae57bab..a2afb88 100644 --- a/src/main/java/de/ticketsystem/commands/TicketCommand.java +++ b/src/main/java/de/ticketsystem/commands/TicketCommand.java @@ -100,13 +100,11 @@ public class TicketCommand implements CommandExecutor, TabCompleter { boolean prioritiesOn = plugin.getConfig().getBoolean("priorities-enabled", true); if (args.length >= 3) { - // args[1]: erst als Kategorie prüfen, dann als Priorität if (categoriesOn) { ConfigCategory parsedCat = cm.resolve(args[1]); if (parsedCat != null) { category = parsedCat; messageStartIndex = 2; - // args[2]: Priorität prüfen (nur wenn danach noch Text kommt) if (prioritiesOn && args.length >= 4) { TicketPriority parsedPrio = parsePriority(args[2]); if (parsedPrio != null) { @@ -115,7 +113,6 @@ public class TicketCommand implements CommandExecutor, TabCompleter { } } } else { - // Keine Kategorie erkannt → args[1] als Priorität prüfen if (prioritiesOn) { TicketPriority parsedPrio = parsePriority(args[1]); if (parsedPrio != null) { @@ -125,16 +122,12 @@ public class TicketCommand implements CommandExecutor, TabCompleter { } } } else if (prioritiesOn) { - // Kategorien aus → args[1] direkt als Priorität prüfen TicketPriority parsedPrio = parsePriority(args[1]); if (parsedPrio != null) { priority = parsedPrio; messageStartIndex = 2; } } - } else if (args.length == 2) { - // Nur ein Argument: könnte Kategorie oder Priorität sein, aber kein Text danach - // → einfach als Beschreibung behandeln, nichts parsen } String message = String.join(" ", Arrays.copyOfRange(args, messageStartIndex, args.length)); @@ -153,6 +146,8 @@ public class TicketCommand implements CommandExecutor, TabCompleter { Ticket ticket = new Ticket(player.getUniqueId(), player.getName(), message, player.getLocation()); ticket.setCategoryKey(finalCategory.getKey()); ticket.setPriority(finalPriority); + // BungeeCord: Server-Name des erstellenden Servers speichern + ticket.setServerName(plugin.getServerName()); Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { int id = plugin.getDatabaseManager().createTicket(ticket); @@ -200,11 +195,31 @@ public class TicketCommand implements CommandExecutor, TabCompleter { if (!success) { player.sendMessage(plugin.formatMessage("messages.already-claimed")); return; } Ticket ticket = plugin.getDatabaseManager().getTicketById(ticketId); if (ticket == null) return; + player.sendMessage(plugin.formatMessage("messages.ticket-claimed") .replace("{id}", String.valueOf(ticketId)) .replace("{player}", ticket.getCreatorName())); plugin.getTicketManager().notifyCreatorClaimed(ticket); - if (ticket.getLocation() != null) player.teleport(ticket.getLocation()); + + // ── BUG FIX #1: Teleportation bei aktivem BungeeCord komplett sperren ── + // Wenn BungeeCord aktiv ist, kann das Ticket von einem anderen Server stammen. + // getLocation() würde null liefern (World existiert lokal nicht) oder den + // Supporter auf dem falschen Server teleportieren. + // Lösung: Bei aktivem BungeeCord generell keinen Teleport durchführen. + if (plugin.isBungeeCordEnabled()) { + // Hinweis: Server anzeigen wenn bekannt, damit Supporter weiß wo das Ticket ist + String serverHint = !"unknown".equals(ticket.getServerName()) + ? " &7(Server: &b" + ticket.getServerName() + "&7)" + : ""; + player.sendMessage(plugin.color("&7Teleportation deaktiviert – BungeeCord-Netzwerk aktiv." + serverHint)); + } else { + // Standalone-Modus: Normal teleportieren + if (ticket.getLocation() != null) { + player.teleport(ticket.getLocation()); + } else { + player.sendMessage(plugin.color("&7Teleportation nicht möglich – World nicht gefunden.")); + } + } }); }); } @@ -228,6 +243,9 @@ public class TicketCommand implements CommandExecutor, TabCompleter { boolean success = plugin.getDatabaseManager().closeTicket(ticketId, comment); if (success) { Ticket ticket = plugin.getDatabaseManager().getTicketById(ticketId); + // Ticket in persistente Stats-Tabelle eintragen (bleibt auch nach Löschung erhalten). + // player.getName() = der Admin der /ticket close ausgeführt hat – nicht zwingend der Claimer. + if (ticket != null) plugin.getDatabaseManager().recordClosedTicket(ticket, player.getName()); Bukkit.getScheduler().runTask(plugin, () -> { player.sendMessage(plugin.formatMessage("messages.ticket-closed").replace("{id}", String.valueOf(ticketId))); if (ticket != null) { @@ -252,12 +270,23 @@ public class TicketCommand implements CommandExecutor, TabCompleter { try { id = Integer.parseInt(args[1]); } catch (NumberFormatException e) { player.sendMessage(plugin.color("&cUngültige ID!")); return; } - Player target = Bukkit.getPlayer(args[2]); - if (target == null) { player.sendMessage(plugin.color("&cSpieler nicht gefunden!")); return; } + // BungeeCord: Ziel-Spieler lokal suchen + Player localTarget = Bukkit.getPlayer(args[2]); + + if (localTarget == null) { + if (plugin.isBungeeCordEnabled()) { + player.sendMessage(plugin.color("&7[BungeeCord] Spieler &e" + args[2] + + " &7ist auf diesem Server nicht online.")); + player.sendMessage(plugin.color("&7Tipp: Forwarden geht nur zu Spielern auf &bdemselben Server&7.")); + } else { + player.sendMessage(plugin.color("&cSpieler nicht gefunden!")); + } + return; + } final int ticketId = id; final String fromName = player.getName(); - final Player t = target; + final Player t = localTarget; Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { boolean success = plugin.getDatabaseManager().forwardTicket(ticketId, t.getUniqueId(), t.getName()); @@ -295,7 +324,6 @@ public class TicketCommand implements CommandExecutor, TabCompleter { Bukkit.getScheduler().runTask(plugin, () -> player.sendMessage(plugin.formatMessage("messages.ticket-not-found"))); return; } - // Spieler darf nur auf eigene Tickets kommentieren (Supporter/Admin: alle) boolean isOwner = ticket.getCreatorUUID().equals(player.getUniqueId()); boolean isStaff = player.hasPermission("ticket.support") || player.hasPermission("ticket.admin"); if (!isOwner && !isStaff) { @@ -309,7 +337,6 @@ public class TicketCommand implements CommandExecutor, TabCompleter { Bukkit.getScheduler().runTask(plugin, () -> { if (success) { player.sendMessage(plugin.color("&aDein Kommentar zu Ticket &e#" + ticketId + " &awurde gespeichert.")); - // Supporter/Admin und Ticket-Ersteller benachrichtigen notifyCommentReceivers(player, ticket, message); } else { player.sendMessage(plugin.color("&cFehler beim Speichern des Kommentars.")); @@ -318,42 +345,78 @@ public class TicketCommand implements CommandExecutor, TabCompleter { }); } + /** + * Benachrichtigt alle relevanten Empfänger über einen neuen Kommentar. + * + * ── BUG FIX #2 ────────────────────────────────────────────────────────── + * Vorher: broadcastTeamNotification() wurde am Ende ZUSÄTZLICH aufgerufen – + * obwohl alle lokalen Supporter bereits einzeln per Schleife + * benachrichtigt wurden. Das führte zu: + * a) Doppelter Nachricht für lokale Supporter + * b) broadcastTeamNotification() sendet intern ebenfalls lokal → + * lokale Supporter sahen die Nachricht dreifach + * c) Das Forward-Paket an andere Server war korrekt, aber die + * Empfänger auf anderen Servern sahen auch Duplikate da + * broadcastTeamNotification() wiederum lokal sendet + * + * Fix: broadcastTeamNotification() ERSETZT die lokale Supporter-Schleife + * komplett. Die Methode sendet bereits lokal direkt und forwardet + * gleichzeitig an alle anderen BungeeCord-Server. + * Im Standalone-Modus bleibt die lokale Schleife erhalten. + * ──────────────────────────────────────────────────────────────────────── + */ private void notifyCommentReceivers(Player author, Ticket ticket, String message) { String onlineMsg = plugin.color("&e[Ticket #" + ticket.getId() + "] &f" + author.getName() + " &7hat kommentiert: &f" + message); String offlineMsg = "&e[Ticket #" + ticket.getId() + "] &f" + author.getName() + " &7hat kommentiert (während du offline warst): &f" + message; - // Ticket-Ersteller benachrichtigen (wenn nicht der Autor selbst) + // ── 1. Ticket-Ersteller benachrichtigen (wenn nicht der Autor selbst) ── if (!ticket.getCreatorUUID().equals(author.getUniqueId())) { Player creator = Bukkit.getPlayer(ticket.getCreatorUUID()); if (creator != null && creator.isOnline()) { creator.sendMessage(onlineMsg); + } else if (plugin.isBungeeCordEnabled()) { + // BungeeCord: Zustellung via Plugin-Messaging, kein Pending-Eintrag + // (PlayerJoinListener übernimmt Offline-Fallback via close_notified-Logik) + plugin.getBungeeMessenger().sendMessageToPlayer( + ticket.getCreatorUUID(), ticket.getCreatorName(), onlineMsg); } else { - // Offline → für nächsten Login speichern + // Standalone: Offline → für nächsten Login speichern Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> plugin.getDatabaseManager().addPendingNotification(ticket.getCreatorUUID(), offlineMsg)); } } - // Supporter/Admin benachrichtigen (wenn Autor kein Supporter ist) + // ── 2. Supporter/Admin benachrichtigen (wenn Kommentar vom Spieler kommt) ── if (!author.hasPermission("ticket.support") && !author.hasPermission("ticket.admin")) { - // Claimer des Tickets bevorzugt benachrichtigen - UUID claimerUUID = ticket.getClaimerUUID(); - if (claimerUUID != null && !claimerUUID.equals(author.getUniqueId())) { - Player claimer = Bukkit.getPlayer(claimerUUID); - if (claimer != null && claimer.isOnline()) { - claimer.sendMessage(onlineMsg); - } else { - String claimerOffline = "&e[Ticket #" + ticket.getId() + "] &f" + author.getName() + " &7hat auf dein bearbeitetes Ticket kommentiert (offline): &f" + message; - Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> - plugin.getDatabaseManager().addPendingNotification(claimerUUID, claimerOffline)); + + if (plugin.isBungeeCordEnabled()) { + // BungeeCord-Modus: broadcastTeamNotification() übernimmt ALLES – + // lokal direkt + Forward an alle anderen Server in einem Paket. + // KEINE zusätzliche lokale Schleife, da das zu Duplikaten führt. + plugin.getBungeeMessenger().broadcastTeamNotification(onlineMsg); + + } else { + // Standalone-Modus: Claimer gezielt benachrichtigen + UUID claimerUUID = ticket.getClaimerUUID(); + if (claimerUUID != null && !claimerUUID.equals(author.getUniqueId())) { + Player claimer = Bukkit.getPlayer(claimerUUID); + if (claimer != null && claimer.isOnline()) { + claimer.sendMessage(onlineMsg); + } else { + String claimerOffline = "&e[Ticket #" + ticket.getId() + "] &f" + author.getName() + + " &7hat auf dein bearbeitetes Ticket kommentiert (offline): &f" + message; + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> + plugin.getDatabaseManager().addPendingNotification(claimerUUID, claimerOffline)); + } } - } - // Alle anderen Online-Supporter zusätzlich informieren - for (Player p : Bukkit.getOnlinePlayers()) { - if (p.getUniqueId().equals(author.getUniqueId())) continue; - if (claimerUUID != null && p.getUniqueId().equals(claimerUUID)) continue; // schon oben - if (p.hasPermission("ticket.support") || p.hasPermission("ticket.admin")) { - p.sendMessage(onlineMsg); + + // Alle anderen Online-Supporter auf diesem Server informieren + for (Player p : Bukkit.getOnlinePlayers()) { + if (p.getUniqueId().equals(author.getUniqueId())) continue; + if (claimerUUID != null && p.getUniqueId().equals(claimerUUID)) continue; + if (p.hasPermission("ticket.support") || p.hasPermission("ticket.admin")) { + p.sendMessage(onlineMsg); + } } } } @@ -468,7 +531,6 @@ public class TicketCommand implements CommandExecutor, TabCompleter { player.sendMessage(plugin.color("&7Keine gesperrten Spieler.")); } else { for (String[] entry : list) { - // {uuid, name, reason, bannedBy, bannedAt} player.sendMessage(plugin.color("&e" + entry[1] + " &7– &f" + entry[2] + " &7(gesperrt von &e" + entry[3] + "&7)")); } @@ -488,6 +550,9 @@ public class TicketCommand implements CommandExecutor, TabCompleter { plugin.reloadConfig(); plugin.getCategoryManager().reload(); player.sendMessage(plugin.color("&aKonfiguration wurde neu geladen. &7(inkl. Kategorien)")); + if (plugin.isBungeeCordEnabled()) { + player.sendMessage(plugin.color("&8[BungeeCord] &7Server: &b" + plugin.getServerName())); + } } // ─────────────────────────── /ticket archive ─────────────────────────── @@ -508,26 +573,55 @@ public class TicketCommand implements CommandExecutor, TabCompleter { private void handleStats(Player player) { if (!player.hasPermission("ticket.admin")) { player.sendMessage(plugin.formatMessage("messages.no-permission")); return; } Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { - var stats = plugin.getDatabaseManager().getTicketStats(); + var stats = plugin.getDatabaseManager().getTicketStats(); + var staffRatings = plugin.getDatabaseManager().getStaffRatings(); Bukkit.getScheduler().runTask(plugin, () -> { player.sendMessage(plugin.color("&8&m ")); player.sendMessage(plugin.color("&6Ticket Statistik")); player.sendMessage(plugin.color("&8&m ")); - player.sendMessage(plugin.color("&eGesamt: &a" + stats.total)); - player.sendMessage(plugin.color("&eOffen: &a" + stats.open)); - player.sendMessage(plugin.color("&eGeschlossen: &a" + stats.closed)); + player.sendMessage(plugin.color("&eGesamt: &a" + stats.total)); + player.sendMessage(plugin.color("&eOffen: &a" + stats.open)); + player.sendMessage(plugin.color("&eGeschlossen: &a" + stats.closed + " &7(historisch)")); player.sendMessage(plugin.color("&eWeitergeleitet: &a" + stats.forwarded)); + if (plugin.getConfig().getBoolean("rating-enabled", true)) { player.sendMessage(plugin.color("&8&m ")); - player.sendMessage(plugin.color("&6Support-Bewertungen")); + player.sendMessage(plugin.color("&6Support-Bewertungen &7(gesamt, historisch)")); player.sendMessage(plugin.color("&a👍 Positiv: &f" + stats.thumbsUp + " &c👎 Negativ: &f" + stats.thumbsDown)); - int total = stats.thumbsUp + stats.thumbsDown; - if (total > 0) { - int percent = (int) Math.round(stats.thumbsUp * 100.0 / total); + int totalRated = stats.thumbsUp + stats.thumbsDown; + if (totalRated > 0) { + int percent = (int) Math.round(stats.thumbsUp * 100.0 / totalRated); player.sendMessage(plugin.color("&7Zufriedenheit: &e" + percent + "%")); } + + // Bewertungen pro Support-Mitarbeiter + if (!staffRatings.isEmpty()) { + player.sendMessage(plugin.color("&8&m ")); + player.sendMessage(plugin.color("&6Bewertungen nach Support-Mitarbeiter:")); + player.sendMessage(plugin.color("&7 Name 👍 👎 Tickets Zufrieden")); + for (String[] row : staffRatings) { + // row: [name, up, down, totalClosed, percent] + String name = String.format("%-16s", row[0]); + String up = String.format("%-5s", row[1]); + String down = String.format("%-5s", row[2]); + String total = String.format("%-8s", row[3]); + String percent = row[4]; + player.sendMessage(plugin.color( + "&e " + name + " &a" + up + " &c" + down + " &7" + total + " &e" + percent)); + } + } } + + // BungeeCord: Tickets pro Server anzeigen + if (plugin.isBungeeCordEnabled() && !stats.byServer.isEmpty()) { + player.sendMessage(plugin.color("&8&m ")); + player.sendMessage(plugin.color("&6Tickets nach Server:")); + stats.byServer.entrySet().stream() + .sorted((a, b) -> b.getValue() - a.getValue()) + .forEach(e -> player.sendMessage(plugin.color("&b " + e.getKey() + ": &a" + e.getValue()))); + } + player.sendMessage(plugin.color("&8&m ")); player.sendMessage(plugin.color("&6Top Ersteller:")); stats.byPlayer.entrySet().stream() @@ -625,8 +719,7 @@ public class TicketCommand implements CommandExecutor, TabCompleter { }); } - /** Parst Benutzer-Eingaben wie "high", "hoch", "urgent", "dringend" etc. zu TicketPriority. - * Gibt null zurück wenn keine Übereinstimmung. */ + /** Parst Benutzer-Eingaben zu TicketPriority. Gibt null zurück wenn keine Übereinstimmung. */ private TicketPriority parsePriority(String input) { if (input == null) return null; return switch (input.toLowerCase()) { @@ -661,14 +754,12 @@ public class TicketCommand implements CommandExecutor, TabCompleter { && plugin.getConfig().getBoolean("categories-enabled", true)) { for (ConfigCategory c : plugin.getCategoryManager().getAll()) if (c.getKey().startsWith(args[1].toLowerCase())) completions.add(c.getKey()); - // auch Priorität direkt ohne Kategorie anbieten if (plugin.getConfig().getBoolean("priorities-enabled", true)) for (String p : List.of("low", "normal", "high", "urgent")) if (p.startsWith(args[1].toLowerCase())) completions.add(p); } else if (args.length == 3 && args[0].equalsIgnoreCase("create") && plugin.getConfig().getBoolean("priorities-enabled", true)) { - // Priorität nach Kategorie for (String p : List.of("low", "normal", "high", "urgent")) if (p.startsWith(args[2].toLowerCase())) completions.add(p); @@ -677,6 +768,7 @@ public class TicketCommand implements CommandExecutor, TabCompleter { if (p.startsWith(args[2].toLowerCase())) completions.add(p); } else if (args.length == 3 && args[0].equalsIgnoreCase("forward")) { + // BungeeCord: Nur lokal online Spieler als Tab-Completion for (Player p : Bukkit.getOnlinePlayers()) if (p.getName().toLowerCase().startsWith(args[2].toLowerCase())) completions.add(p.getName()); diff --git a/src/main/java/de/ticketsystem/database/DatabaseManager.java b/src/main/java/de/ticketsystem/database/DatabaseManager.java index 8d78cce..8ed9ccf 100644 --- a/src/main/java/de/ticketsystem/database/DatabaseManager.java +++ b/src/main/java/de/ticketsystem/database/DatabaseManager.java @@ -161,6 +161,7 @@ public class DatabaseManager { private void createTables() { // Haupt-Tickets-Tabelle + // BungeeCord: server_name speichert auf welchem Server das Ticket erstellt wurde String ticketsSql = """ CREATE TABLE IF NOT EXISTS tickets ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -186,7 +187,9 @@ public class DatabaseManager { category VARCHAR(16) NOT NULL DEFAULT 'GENERAL', priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL', player_rating VARCHAR(16) NULL, - claimer_notified BOOLEAN DEFAULT FALSE + claimer_notified BOOLEAN DEFAULT FALSE, + close_notified BOOLEAN DEFAULT FALSE, + server_name VARCHAR(64) NOT NULL DEFAULT 'unknown' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """; @@ -225,11 +228,50 @@ public class DatabaseManager { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """; + // Persistente Statistik-Tabelle – überlebt das Löschen/Archivieren von Tickets. + // Wird beim Schließen eines Tickets befüllt und beim Bewerten aktualisiert. + // So gehen keine Zahlen verloren wenn das Archiv geleert wird. + String statsSql = """ + CREATE TABLE IF NOT EXISTS ticket_stats ( + id INT AUTO_INCREMENT PRIMARY KEY, + ticket_id INT NOT NULL, + claimer_uuid VARCHAR(36) NULL, + claimer_name VARCHAR(16) NULL, + creator_uuid VARCHAR(36) NOT NULL, + creator_name VARCHAR(16) NOT NULL, + category VARCHAR(16) NOT NULL DEFAULT 'general', + priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL', + server_name VARCHAR(64) NOT NULL DEFAULT 'unknown', + player_rating VARCHAR(16) NULL, + closed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_claimer_uuid (claimer_uuid), + INDEX idx_closed_at (closed_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """; + + // Ausstehende BungeeCord-Teleport-Aufträge. + // Wird gesetzt wenn ein Admin via GUI/Command auf einen anderen Server teleportiert. + // PlayerJoinListener liest den Eintrag beim Ankommen, teleportiert, löscht ihn dann. + String pendingTeleportSql = """ + CREATE TABLE IF NOT EXISTS ticket_pending_teleport ( + player_uuid VARCHAR(36) NOT NULL PRIMARY KEY, + world VARCHAR(64) NOT NULL, + x DOUBLE NOT NULL, + y DOUBLE NOT NULL, + z DOUBLE NOT NULL, + yaw FLOAT NOT NULL DEFAULT 0, + pitch FLOAT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """; + try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) { stmt.execute(ticketsSql); stmt.execute(commentsSql); stmt.execute(blacklistSql); stmt.execute(notifSql); + stmt.execute(statsSql); + stmt.execute(pendingTeleportSql); } catch (SQLException e) { plugin.getLogger().log(Level.SEVERE, "Fehler beim Erstellen der Tabellen: " + e.getMessage(), e); } @@ -237,6 +279,7 @@ public class DatabaseManager { /** * Ergänzt fehlende Spalten in bestehenden Datenbanken automatisch. + * Wichtig für Upgrades von älteren Versionen. */ private void ensureColumns() { ensureColumn("close_comment", "ALTER TABLE tickets ADD COLUMN close_comment VARCHAR(500) NULL"); @@ -245,6 +288,13 @@ public class DatabaseManager { ensureColumn("priority", "ALTER TABLE tickets ADD COLUMN priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL'"); ensureColumn("player_rating", "ALTER TABLE tickets ADD COLUMN player_rating VARCHAR(16) NULL"); ensureColumn("claimer_notified", "ALTER TABLE tickets ADD COLUMN claimer_notified BOOLEAN DEFAULT FALSE"); + // Bug-Fix: close_notified verhindert Duplikat-Discord-Nachrichten und doppelte Offline-Benachrichtigungen bei Server-Wechsel + ensureColumn("close_notified", "ALTER TABLE tickets ADD COLUMN close_notified BOOLEAN DEFAULT FALSE"); + // BungeeCord: Server-Name-Spalte für bestehende Datenbanken nachrüsten + ensureColumn("server_name", "ALTER TABLE tickets ADD COLUMN server_name VARCHAR(64) NOT NULL DEFAULT 'unknown'"); + + // ticket_stats: Spalte player_rating nachrüsten falls Tabelle vor diesem Feature existiert + ensureStatsColumn("player_rating", "ALTER TABLE ticket_stats ADD COLUMN player_rating VARCHAR(16) NULL"); } private void ensureColumn(String columnName, String alterSql) { @@ -268,13 +318,36 @@ public class DatabaseManager { } } + private void ensureStatsColumn(String columnName, String alterSql) { + String checkSql = """ + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'ticket_stats' + AND COLUMN_NAME = ? + """; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(checkSql)) { + ps.setString(1, columnName); + ResultSet rs = ps.executeQuery(); + if (rs.next() && rs.getInt(1) == 0) { + try (Statement stmt = conn.createStatement()) { + stmt.execute(alterSql); + plugin.getLogger().info("[TicketSystem] Stats-Spalte '" + columnName + "' wurde hinzugefügt."); + } + } + } catch (SQLException e) { + plugin.getLogger().log(Level.SEVERE, "Fehler bei ensureStatsColumn(" + columnName + "): " + e.getMessage(), e); + } + } + // ─────────────────────────── CRUD Tickets ────────────────────────────── public int createTicket(Ticket ticket) { if (useMySQL) { + // BungeeCord: server_name wird ebenfalls gespeichert String sql = """ - INSERT INTO tickets (creator_uuid, creator_name, message, world, x, y, z, yaw, pitch, category, priority) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO tickets (creator_uuid, creator_name, message, world, x, y, z, yaw, pitch, + category, priority, server_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { @@ -289,6 +362,7 @@ public class DatabaseManager { ps.setFloat(9, ticket.getPitch()); ps.setString(10, ticket.getCategoryKey()); ps.setString(11, ticket.getPriority().name()); + ps.setString(12, ticket.getServerName()); // BungeeCord ps.executeUpdate(); ResultSet rs = ps.getGeneratedKeys(); if (rs.next()) return rs.getInt(1); @@ -306,13 +380,21 @@ public class DatabaseManager { } } + // ── BUG FIX #1 ────────────────────────────────────────────────────────── + // Vorher: WHERE id = ? AND status = 'OPEN' + // Problem: Ein FORWARDED-Ticket konnte nicht geclaimed werden – das UPDATE + // schlug lautlos fehl, claimer_uuid/claimer_name wurden nie geschrieben. + // Fix: WHERE id = ? AND status != 'CLOSED' + // Damit können sowohl OPEN als auch FORWARDED Tickets korrekt + // angenommen werden und claimer_uuid/claimer_name werden immer gesetzt. + // ──────────────────────────────────────────────────────────────────────── public boolean claimTicket(int ticketId, UUID claimerUUID, String claimerName) { if (useMySQL) { String sql = """ UPDATE tickets SET status = 'CLAIMED', claimer_uuid = ?, claimer_name = ?, claimed_at = NOW(), player_deleted = FALSE - WHERE id = ? AND status = 'OPEN' + WHERE id = ? AND status != 'CLOSED' """; try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, claimerUUID.toString()); @@ -325,7 +407,7 @@ public class DatabaseManager { return false; } else { Ticket t = getTicketById(ticketId); - if (t == null || t.getStatus() != TicketStatus.OPEN) return false; + if (t == null || t.getStatus() == TicketStatus.CLOSED) return false; t.setStatus(TicketStatus.CLAIMED); t.setClaimerUUID(claimerUUID); t.setClaimerName(claimerName); @@ -452,11 +534,8 @@ public class DatabaseManager { } } - // ─────────────────────────── [NEW] Claim-Benachrichtigung markieren ──── + // ─────────────────────────── Claim-Benachrichtigung markieren ────────── - /** - * Setzt claimer_notified = TRUE für ein Ticket (persistiert in DB/Datei). - */ public void markClaimerNotified(int ticketId) { if (useMySQL) { String sql = "UPDATE tickets SET claimer_notified = TRUE WHERE id = ?"; @@ -476,21 +555,42 @@ public class DatabaseManager { } } - // ─────────────────────────── [NEW] Bewertung ─────────────────────────── + // ─────────────────────────── Schließ-Benachrichtigung markieren ──────── /** - * Speichert die Bewertung eines Spielers für sein geschlossenes Ticket. - * @param ticketId ID des Tickets - * @param rating "THUMBS_UP" oder "THUMBS_DOWN" - * @return true bei Erfolg + * Setzt close_notified = TRUE für ein Ticket (persistiert in DB/Datei). + * Verhindert Duplikat-Benachrichtigungen und doppelte Discord-Nachrichten + * bei Server-Wechseln in BungeeCord-Netzwerken. */ + public void markCloseNotified(int ticketId) { + if (useMySQL) { + String sql = "UPDATE tickets SET close_notified = TRUE WHERE id = ?"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setInt(1, ticketId); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().log(Level.SEVERE, "Fehler bei markCloseNotified: " + e.getMessage(), e); + } + } else { + Ticket t = getTicketById(ticketId); + if (t != null) { + t.setCloseNotified(true); + dataConfig.set("tickets." + ticketId, t); + saveDataConfig(); + } + } + } + public boolean rateTicket(int ticketId, String rating) { if (useMySQL) { String sql = "UPDATE tickets SET player_rating = ? WHERE id = ? AND status = 'CLOSED' AND player_rating IS NULL"; try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, rating); ps.setInt(2, ticketId); - return ps.executeUpdate() > 0; + boolean updated = ps.executeUpdate() > 0; + // Bewertung auch in die persistente Stats-Tabelle übertragen + if (updated) updateStatsRating(ticketId, rating); + return updated; } catch (SQLException e) { plugin.getLogger().log(Level.SEVERE, "Fehler bei rateTicket: " + e.getMessage(), e); } @@ -505,11 +605,191 @@ public class DatabaseManager { } } - // ─────────────────────────── [NEW] Kommentare ────────────────────────── + /** + * Schreibt einen Eintrag in ticket_stats wenn ein Ticket geschlossen wird. + * Diese Tabelle bleibt dauerhaft erhalten – auch wenn das Ticket später + * gelöscht oder archiviert wird. So gehen Statistiken nie verloren. + * + * @param ticket Das gerade geschlossene Ticket-Objekt + * @param closerName Name des Admins/Supporters der das Ticket geschlossen hat + * (kann vom claimer_name abweichen wenn ein Admin fremde Tickets schließt) + */ + public void recordClosedTicket(Ticket ticket, String closerName) { + if (!useMySQL) return; + + // closer_name bevorzugen – falls null, auf claimer_name zurückfallen + String effectiveCloser = (closerName != null && !closerName.isEmpty()) + ? closerName : ticket.getClaimerName(); + String effectiveCloserUuid = null; + // UUID nur setzen wenn closer == claimer (sonst haben wir die UUID des Admins nicht direkt) + if (effectiveCloser != null && effectiveCloser.equals(ticket.getClaimerName()) + && ticket.getClaimerUUID() != null) { + effectiveCloserUuid = ticket.getClaimerUUID().toString(); + } + + String sql = """ + INSERT INTO ticket_stats + (ticket_id, claimer_uuid, claimer_name, creator_uuid, creator_name, + category, priority, server_name, player_rating, closed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE closed_at = closed_at + """; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setInt(1, ticket.getId()); + ps.setString(2, effectiveCloserUuid); + ps.setString(3, effectiveCloser); + ps.setString(4, ticket.getCreatorUUID().toString()); + ps.setString(5, ticket.getCreatorName()); + ps.setString(6, ticket.getCategoryKey()); + ps.setString(7, ticket.getPriority().name()); + ps.setString(8, ticket.getServerName()); + ps.setString(9, ticket.getPlayerRating()); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().log(Level.SEVERE, "Fehler bei recordClosedTicket: " + e.getMessage(), e); + } + } /** - * Speichert einen neuen Kommentar/Reply auf ein Ticket. + * Aktualisiert die Bewertung in ticket_stats wenn ein Spieler sein Ticket bewertet. + * Wird von rateTicket() intern aufgerufen. */ + private void updateStatsRating(int ticketId, String rating) { + String sql = "UPDATE ticket_stats SET player_rating = ? WHERE ticket_id = ?"; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, rating); + ps.setInt(2, ticketId); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().log(Level.SEVERE, "Fehler bei updateStatsRating: " + e.getMessage(), e); + } + } + + /** + * Gibt eine Liste aller Support-Mitarbeiter mit ihren Bewertungsstatistiken zurück. + * Liest aus ticket_stats – unabhängig davon ob die Tickets noch in der DB existieren. + * + * Rückgabe: Liste von String-Arrays mit + * [0] claimer_name, [1] thumbsUp, [2] thumbsDown, [3] total, [4] prozent + */ + public List getStaffRatings() { + List result = new ArrayList<>(); + if (!useMySQL) return result; + + String sql = """ + SELECT + claimer_name, + SUM(player_rating = 'THUMBS_UP') AS thumbs_up, + SUM(player_rating = 'THUMBS_DOWN') AS thumbs_down, + COUNT(*) AS total_closed + FROM ticket_stats + WHERE claimer_name IS NOT NULL + GROUP BY claimer_uuid, claimer_name + ORDER BY total_closed DESC + """; + try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) { + ResultSet rs = stmt.executeQuery(sql); + while (rs.next()) { + int up = rs.getInt("thumbs_up"); + int down = rs.getInt("thumbs_down"); + int total = rs.getInt("total_closed"); + int rated = up + down; + String percent = rated > 0 ? Math.round(up * 100.0 / rated) + "%" : "–"; + result.add(new String[]{ + rs.getString("claimer_name"), + String.valueOf(up), + String.valueOf(down), + String.valueOf(total), + percent + }); + } + } catch (SQLException e) { + plugin.getLogger().log(Level.SEVERE, "Fehler bei getStaffRatings: " + e.getMessage(), e); + } + return result; + } + + // ─────────────────────────── BungeeCord Pending-Teleport ─────────────── + + /** + * Speichert einen ausstehenden Teleport-Auftrag für einen Admin/Supporter. + * Wird aufgerufen bevor der Spieler via BungeeCord auf den Ziel-Server + * geschickt wird. Der PlayerJoinListener liest den Eintrag beim Ankommen. + */ + public void setPendingTeleport(UUID playerUUID, String world, + double x, double y, double z, + float yaw, float pitch) { + if (!useMySQL) return; + + String sql = """ + INSERT INTO ticket_pending_teleport + (player_uuid, world, x, y, z, yaw, pitch) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + world = VALUES(world), x = VALUES(x), y = VALUES(y), + z = VALUES(z), yaw = VALUES(yaw), pitch = VALUES(pitch), + created_at = CURRENT_TIMESTAMP + """; + try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, playerUUID.toString()); + ps.setString(2, world); + ps.setDouble(3, x); + ps.setDouble(4, y); + ps.setDouble(5, z); + ps.setFloat(6, yaw); + ps.setFloat(7, pitch); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().log(Level.SEVERE, "Fehler bei setPendingTeleport: " + e.getMessage(), e); + } + } + + /** + * Liest und löscht einen ausstehenden Teleport-Auftrag in einem Schritt. + * Gibt null zurück wenn kein Auftrag vorhanden ist. + * + * Rückgabe: double[] { x, y, z, yaw, pitch } + world als Index 0 im String-Array + * Vereinfacht: gibt ein Object[] zurück: [String world, double x, y, z, float yaw, pitch] + */ + public PendingTeleport consumePendingTeleport(UUID playerUUID) { + if (!useMySQL) return null; + + String selectSql = "SELECT * FROM ticket_pending_teleport WHERE player_uuid = ?"; + String deleteSql = "DELETE FROM ticket_pending_teleport WHERE player_uuid = ?"; + + try (Connection conn = getConnection(); + PreparedStatement sel = conn.prepareStatement(selectSql)) { + sel.setString(1, playerUUID.toString()); + ResultSet rs = sel.executeQuery(); + if (!rs.next()) return null; + + PendingTeleport pt = new PendingTeleport( + rs.getString("world"), + rs.getDouble("x"), + rs.getDouble("y"), + rs.getDouble("z"), + rs.getFloat("yaw"), + rs.getFloat("pitch") + ); + + // Sofort löschen damit kein zweites Mal teleportiert wird + try (PreparedStatement del = conn.prepareStatement(deleteSql)) { + del.setString(1, playerUUID.toString()); + del.executeUpdate(); + } + return pt; + + } catch (SQLException e) { + plugin.getLogger().log(Level.SEVERE, "Fehler bei consumePendingTeleport: " + e.getMessage(), e); + return null; + } + } + + /** Einfaches Daten-Objekt für einen ausstehenden Teleport-Auftrag. */ + public record PendingTeleport(String world, double x, double y, double z, float yaw, float pitch) {} + + + public boolean addComment(TicketComment comment) { if (useMySQL) { String sql = """ @@ -527,7 +807,6 @@ public class DatabaseManager { } return false; } else { - // YAML: comments.. int index = dataConfig.getInt("comments." + comment.getTicketId() + ".count", 0); String base = "comments." + comment.getTicketId() + "." + index + "."; dataConfig.set(base + "authorUUID", comment.getAuthorUUID().toString()); @@ -540,9 +819,6 @@ public class DatabaseManager { } } - /** - * Lädt alle Kommentare für ein Ticket, sortiert nach Datum. - */ public List getComments(int ticketId) { List list = new ArrayList<>(); if (useMySQL) { @@ -582,12 +858,8 @@ public class DatabaseManager { return list; } - // ─────────────────────────── Pending Notifications ──────────────────── + // ─────────────────────────── Pending Notifications ───────────────────── - /** - * Speichert eine Benachrichtigung für einen offline Spieler. - * Wird beim nächsten Login angezeigt. - */ public void addPendingNotification(UUID playerUUID, String rawMessage) { if (useMySQL) { String sql = "INSERT INTO ticket_pending_notifications (player_uuid, message) VALUES (?, ?)"; @@ -607,9 +879,6 @@ public class DatabaseManager { } } - /** - * Lädt alle ausstehenden Benachrichtigungen für einen Spieler. - */ public List getPendingNotifications(UUID playerUUID) { List messages = new ArrayList<>(); if (useMySQL) { @@ -627,9 +896,6 @@ public class DatabaseManager { return messages; } - /** - * Löscht alle ausstehenden Benachrichtigungen eines Spielers nach dem Anzeigen. - */ public void clearPendingNotifications(UUID playerUUID) { if (useMySQL) { String sql = "DELETE FROM ticket_pending_notifications WHERE player_uuid = ?"; @@ -645,7 +911,7 @@ public class DatabaseManager { } } - // ─────────────────────────── [NEW] Blacklist ─────────────────────────── + // ─────────────────────────── Blacklist ───────────────────────────────── public boolean isBlacklisted(UUID uuid) { if (useMySQL) { @@ -705,7 +971,6 @@ public class DatabaseManager { } } - /** Gibt alle gesperrten Spieler als Liste von String-Arrays {uuid, name, reason, bannedBy} zurück. */ public List getBlacklist() { List list = new ArrayList<>(); if (useMySQL) { @@ -890,28 +1155,69 @@ public class DatabaseManager { // ─────────────────────────── Statistiken ─────────────────────────────── public TicketStats getTicketStats() { + // Aktuelle Live-Daten aus der tickets-Tabelle List all = getAllTickets(); - int open = 0, claimed = 0, forwarded = 0, closed = 0, thumbsUp = 0, thumbsDown = 0; + int open = 0, claimed = 0, forwarded = 0, closedLive = 0; java.util.Map byPlayer = new java.util.HashMap<>(); + java.util.Map byServer = new java.util.HashMap<>(); for (Ticket t : all) { switch (t.getStatus()) { case OPEN -> open++; case CLAIMED -> claimed++; case FORWARDED -> forwarded++; - case CLOSED -> closed++; + case CLOSED -> closedLive++; } - if ("THUMBS_UP".equals(t.getPlayerRating())) thumbsUp++; - if ("THUMBS_DOWN".equals(t.getPlayerRating())) thumbsDown++; byPlayer.merge(t.getCreatorName(), 1, Integer::sum); + byServer.merge(t.getServerName(), 1, Integer::sum); } - return new TicketStats(all.size(), open, closed, forwarded, thumbsUp, thumbsDown, byPlayer); + + // Historische Bewertungen aus der persistenten Stats-Tabelle lesen + // (enthält auch Daten von bereits gelöschten/archivierten Tickets) + int thumbsUp = 0, thumbsDown = 0, totalClosedEver = closedLive; + if (useMySQL) { + String sql = """ + SELECT + COUNT(*) AS total, + SUM(player_rating = 'THUMBS_UP') AS up, + SUM(player_rating = 'THUMBS_DOWN') AS down + FROM ticket_stats + """; + try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) { + ResultSet rs = stmt.executeQuery(sql); + if (rs.next()) { + totalClosedEver = rs.getInt("total"); + thumbsUp = rs.getInt("up"); + thumbsDown = rs.getInt("down"); + } + } catch (SQLException e) { + plugin.getLogger().log(Level.SEVERE, "Fehler beim Laden der persistenten Stats: " + e.getMessage(), e); + // Fallback: Bewertungen aus Live-Daten lesen + for (Ticket t : all) { + if ("THUMBS_UP".equals(t.getPlayerRating())) thumbsUp++; + if ("THUMBS_DOWN".equals(t.getPlayerRating())) thumbsDown++; + } + } + } else { + // Datei-Modus: nur Live-Daten verfügbar + for (Ticket t : all) { + if ("THUMBS_UP".equals(t.getPlayerRating())) thumbsUp++; + if ("THUMBS_DOWN".equals(t.getPlayerRating())) thumbsDown++; + } + } + + return new TicketStats(all.size(), open, totalClosedEver, forwarded, thumbsUp, thumbsDown, byPlayer, byServer); } public static class TicketStats { public final int total, open, closed, forwarded, thumbsUp, thumbsDown; public final java.util.Map byPlayer; + /** BungeeCord: Anzahl Tickets pro Server */ + public final java.util.Map byServer; + public TicketStats(int total, int open, int closed, int forwarded, - int thumbsUp, int thumbsDown, java.util.Map byPlayer) { + int thumbsUp, int thumbsDown, + java.util.Map byPlayer, + java.util.Map byServer) { this.total = total; this.open = open; this.closed = closed; @@ -919,6 +1225,7 @@ public class DatabaseManager { this.thumbsUp = thumbsUp; this.thumbsDown = thumbsDown; this.byPlayer = byPlayer; + this.byServer = byServer; } } @@ -998,6 +1305,10 @@ public class DatabaseManager { try { t.setPriority(TicketPriority.fromString(rs.getString("priority"))); } catch (SQLException ignored) {} try { t.setPlayerRating(rs.getString("player_rating")); } catch (SQLException ignored) {} try { t.setClaimerNotified(rs.getBoolean("claimer_notified")); } catch (SQLException ignored) {} + // Bug-Fix: close_notified für duplikat-freie Schließ-Benachrichtigungen + try { t.setCloseNotified(rs.getBoolean("close_notified")); } catch (SQLException ignored) {} + // BungeeCord: Server-Name einlesen + try { t.setServerName(rs.getString("server_name")); } catch (SQLException ignored) {} return t; } @@ -1032,6 +1343,9 @@ public class DatabaseManager { obj.put("priority", t.getPriority().name()); if (t.getPlayerRating() != null) obj.put("playerRating", t.getPlayerRating()); obj.put("claimerNotified", t.isClaimerNotified()); + obj.put("closeNotified", t.isCloseNotified()); + // BungeeCord: Server-Name im JSON-Export + obj.put("serverName", t.getServerName()); return obj; } @@ -1060,6 +1374,9 @@ public class DatabaseManager { if (obj.containsKey("priority")) t.setPriority(TicketPriority.fromString((String) obj.get("priority"))); if (obj.containsKey("playerRating")) t.setPlayerRating((String) obj.get("playerRating")); if (obj.containsKey("claimerNotified"))t.setClaimerNotified((Boolean) obj.get("claimerNotified")); + if (obj.containsKey("closeNotified")) t.setCloseNotified((Boolean) obj.get("closeNotified")); + // BungeeCord: Server-Name aus JSON + if (obj.containsKey("serverName")) t.setServerName((String) obj.get("serverName")); return t; } catch (Exception e) { if (plugin != null) plugin.getLogger().severe("Fehler beim Parsen eines Tickets: " + e.getMessage()); diff --git a/src/main/java/de/ticketsystem/discord/DiscordWebhook.java b/src/main/java/de/ticketsystem/discord/DiscordWebhook.java index 6586a35..5a09760 100644 --- a/src/main/java/de/ticketsystem/discord/DiscordWebhook.java +++ b/src/main/java/de/ticketsystem/discord/DiscordWebhook.java @@ -3,6 +3,7 @@ package de.ticketsystem.discord; import de.ticketsystem.TicketPlugin; import de.ticketsystem.model.ConfigCategory; import de.ticketsystem.model.Ticket; +import de.ticketsystem.model.TicketPriority; import org.bukkit.Bukkit; import java.io.OutputStream; @@ -10,96 +11,94 @@ import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.util.ArrayList; +import java.util.List; -/** - * Sendet Benachrichtigungen an einen Discord-Webhook. - * Unterstützt Embeds mit Farbe, Feldern, Timestamp, Kategorie, Priorität und Rollen-Ping. - * - * Relevante config.yml-Felder: - * - * discord: - * enabled: true - * webhook-url: "https://discord.com/api/webhooks/..." - * role-ping-id: "" # Rollen-ID für @Ping, leer = kein Ping - * messages: - * new-ticket: - * title: "🎫 Neues Ticket erstellt" - * color: "3066993" - * footer: "TicketSystem" - * show-position: true - * show-category: true - * show-priority: true - * role-ping: true # Ping bei neuem Ticket an/aus - * ticket-closed: - * enabled: false - * title: "🔒 Ticket geschlossen" - * color: "15158332" - * footer: "TicketSystem" - * show-category: true - * show-priority: true - * role-ping: false - * ticket-forwarded: - * enabled: false - * title: "🔀 Ticket weitergeleitet" - * color: "15105570" - * footer: "TicketSystem" - * show-category: true - * show-priority: true - * role-ping: false - */ public class DiscordWebhook { + // ───────────────────────────────────────────────────────────────────────── + // Konstanten & Felder + // ───────────────────────────────────────────────────────────────────────── + + private static final String AVATAR_URL = "https://mc-heads.net/avatar/%s/64"; + private final TicketPlugin plugin; + // ───────────────────────────────────────────────────────────────────────── + // Konstruktor + // ───────────────────────────────────────────────────────────────────────── + public DiscordWebhook(TicketPlugin plugin) { this.plugin = plugin; } - // ─────────────────────────── Öffentliche Methoden ────────────────────── + // ───────────────────────────────────────────────────────────────────────── + // Öffentliche Methoden – Webhook-Events + // ───────────────────────────────────────────────────────────────────────── - /** - * Sendet eine Benachrichtigung wenn ein neues Ticket erstellt wurde. - */ public void sendNewTicket(Ticket ticket) { if (!isEnabled()) return; String webhookUrl = getWebhookUrl(); if (webhookUrl == null) return; - String title = plugin.getConfig().getString ("discord.messages.new-ticket.title", "🎫 Neues Ticket erstellt"); - String color = plugin.getConfig().getString ("discord.messages.new-ticket.color", "3066993"); + // Konfiguration lesen + String title = plugin.getConfig().getString ("discord.messages.new-ticket.title", "Neues Ticket"); + String color = plugin.getConfig().getString ("discord.messages.new-ticket.color", "5793266"); String footer = plugin.getConfig().getString ("discord.messages.new-ticket.footer", "TicketSystem"); boolean showPos = plugin.getConfig().getBoolean("discord.messages.new-ticket.show-position", true); boolean showCat = plugin.getConfig().getBoolean("discord.messages.new-ticket.show-category", true); boolean showPri = plugin.getConfig().getBoolean("discord.messages.new-ticket.show-priority", true); + boolean showSrv = plugin.getConfig().getBoolean("discord.messages.new-ticket.show-server", true); boolean ping = plugin.getConfig().getBoolean("discord.messages.new-ticket.role-ping", true); - StringBuilder fields = new StringBuilder(); - fields.append(field("Spieler", ticket.getCreatorName(), true)); - fields.append(",").append(field("Ticket ID", "#" + ticket.getId(), true)); - fields.append(",").append(field("Anliegen", ticket.getMessage(), false)); + // Hilfs-Werte berechnen + String prioEmoji = getPriorityEmoji(ticket.getPriority()); + String avatarUrl = String.format(AVATAR_URL, ticket.getCreatorUUID().toString()); + + // Felder aufbauen + List fields = new ArrayList<>(); + fields.add(new Field("👤 Spieler", ticket.getCreatorName(), true)); + fields.add(new Field("🎫 Ticket", "#" + ticket.getId(), true)); if (showCat && plugin.getConfig().getBoolean("categories-enabled", true)) { ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey()); - fields.append(",").append(field("Kategorie", cat.getName(), true)); + fields.add(new Field("🏷️ Kategorie", cat.getName(), true)); } + if (showPri && plugin.getConfig().getBoolean("priorities-enabled", true)) { - fields.append(",").append(field("Priorität", ticket.getPriority().getDisplayName(), true)); + fields.add(new Field("⚡ Priorität", prioEmoji + " " + ticket.getPriority().getDisplayName(), true)); } + + if (showSrv && plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) { + fields.add(new Field("🖥️ Server", ticket.getServerName(), true)); + } + if (showPos) { - fields.append(",").append(field("Welt", ticket.getWorldName(), true)); - fields.append(",").append(field("Position", + fields.add(new Field("🌍 Welt", ticket.getWorldName(), true)); + fields.add(new Field("📍 Position", String.format("%.0f, %.0f, %.0f", ticket.getX(), ticket.getY(), ticket.getZ()), true)); } - String content = ping ? buildRolePing() : ""; - String json = buildPayload(content, title, Integer.parseInt(color), fields.toString(), footer); + // JSON zusammenbauen & senden + String description = "**Anliegen**\n> " + j(ticket.getMessage()); + + String json = buildJson( + ping ? buildRolePing() : "", + prioEmoji + " " + j(title) + " #" + ticket.getId(), + description, + Integer.parseInt(color), + j(ticket.getCreatorName()), avatarUrl, + avatarUrl, + fields, + j(footer) + " • Neues Ticket" + ); + sendAsync(webhookUrl, json); } - /** - * Sendet eine Benachrichtigung wenn ein Ticket geschlossen wurde. - */ + // ───────────────────────────────────────────────────────────────────────── + public void sendTicketClosed(Ticket ticket, String closerName) { if (!isEnabled()) return; if (!plugin.getConfig().getBoolean("discord.messages.ticket-closed.enabled", false)) return; @@ -107,37 +106,68 @@ public class DiscordWebhook { String webhookUrl = getWebhookUrl(); if (webhookUrl == null) return; - String title = plugin.getConfig().getString ("discord.messages.ticket-closed.title", "🔒 Ticket geschlossen"); - String color = plugin.getConfig().getString ("discord.messages.ticket-closed.color", "15158332"); + // Konfiguration lesen + String title = plugin.getConfig().getString ("discord.messages.ticket-closed.title", "Ticket geschlossen"); + String color = plugin.getConfig().getString ("discord.messages.ticket-closed.color", "15548997"); String footer = plugin.getConfig().getString ("discord.messages.ticket-closed.footer", "TicketSystem"); boolean showCat = plugin.getConfig().getBoolean("discord.messages.ticket-closed.show-category", true); boolean showPri = plugin.getConfig().getBoolean("discord.messages.ticket-closed.show-priority", true); + boolean showSrv = plugin.getConfig().getBoolean("discord.messages.ticket-closed.show-server", true); boolean ping = plugin.getConfig().getBoolean("discord.messages.ticket-closed.role-ping", false); - StringBuilder fields = new StringBuilder(); - fields.append(field("Ticket ID", "#" + ticket.getId(), true)); - fields.append(",").append(field("Ersteller", ticket.getCreatorName(), true)); - fields.append(",").append(field("Geschlossen von", closerName, true)); + // Hilfs-Werte berechnen + String prioEmoji = getPriorityEmoji(ticket.getPriority()); + String avatarUrl = String.format(AVATAR_URL, ticket.getCreatorUUID().toString()); + + // Beschreibung aufbauen + StringBuilder desc = new StringBuilder(); + desc.append("**Anliegen**\n> ").append(j(ticket.getMessage())); + + if (ticket.getCloseComment() != null && !ticket.getCloseComment().isEmpty()) { + desc.append("\n\n**Kommentar des Supports**\n> ").append(j(ticket.getCloseComment())); + } + + // Felder aufbauen + List fields = new ArrayList<>(); + fields.add(new Field("👤 Ersteller", ticket.getCreatorName(), true)); + fields.add(new Field("🔒 Geschlossen von", j(closerName), true)); + fields.add(new Field("🎫 Ticket ID", "#" + ticket.getId(), true)); if (showCat && plugin.getConfig().getBoolean("categories-enabled", true)) { ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey()); - fields.append(",").append(field("Kategorie", cat.getName(), true)); - } - if (showPri && plugin.getConfig().getBoolean("priorities-enabled", true)) { - fields.append(",").append(field("Priorität", ticket.getPriority().getDisplayName(), true)); - } - if (ticket.getCloseComment() != null && !ticket.getCloseComment().isEmpty()) { - fields.append(",").append(field("Kommentar", ticket.getCloseComment(), false)); + fields.add(new Field("🏷️ Kategorie", cat.getName(), true)); } - String content = ping ? buildRolePing() : ""; - String json = buildPayload(content, title, Integer.parseInt(color), fields.toString(), footer); + if (showPri && plugin.getConfig().getBoolean("priorities-enabled", true)) { + fields.add(new Field("⚡ Priorität", prioEmoji + " " + ticket.getPriority().getDisplayName(), true)); + } + + if (showSrv && plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) { + fields.add(new Field("🖥️ Server", ticket.getServerName(), true)); + } + + if (plugin.getConfig().getBoolean("rating-enabled", true) && ticket.getPlayerRating() != null) { + String rating = "THUMBS_UP".equals(ticket.getPlayerRating()) ? "👍 Positiv" : "👎 Negativ"; + fields.add(new Field("⭐ Bewertung", rating, true)); + } + + // JSON zusammenbauen & senden + String json = buildJson( + ping ? buildRolePing() : "", + "🔒 " + j(title) + " #" + ticket.getId(), + desc.toString(), + Integer.parseInt(color), + j(ticket.getCreatorName()), avatarUrl, + avatarUrl, + fields, + j(footer) + " • Ticket geschlossen" + ); + sendAsync(webhookUrl, json); } - /** - * Sendet eine Benachrichtigung wenn ein Ticket weitergeleitet wurde. - */ + // ───────────────────────────────────────────────────────────────────────── + public void sendTicketForwarded(Ticket ticket, String fromName) { if (!isEnabled()) return; if (!plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.enabled", false)) return; @@ -145,98 +175,180 @@ public class DiscordWebhook { String webhookUrl = getWebhookUrl(); if (webhookUrl == null) return; - String title = plugin.getConfig().getString ("discord.messages.ticket-forwarded.title", "🔀 Ticket weitergeleitet"); + // Konfiguration lesen + String title = plugin.getConfig().getString ("discord.messages.ticket-forwarded.title", "Ticket weitergeleitet"); String color = plugin.getConfig().getString ("discord.messages.ticket-forwarded.color", "15105570"); String footer = plugin.getConfig().getString ("discord.messages.ticket-forwarded.footer", "TicketSystem"); boolean showCat = plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.show-category", true); boolean showPri = plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.show-priority", true); + boolean showSrv = plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.show-server", true); boolean ping = plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.role-ping", false); - StringBuilder fields = new StringBuilder(); - fields.append(field("Ticket ID", "#" + ticket.getId(), true)); - fields.append(",").append(field("Ersteller", ticket.getCreatorName(), true)); - fields.append(",").append(field("Weitergeleitet von", fromName, true)); - fields.append(",").append(field("Weitergeleitet an", ticket.getForwardedToName(), true)); + // Hilfs-Werte berechnen + String prioEmoji = getPriorityEmoji(ticket.getPriority()); + String avatarUrl = String.format(AVATAR_URL, ticket.getCreatorUUID().toString()); + + // Felder aufbauen + String forwardedTo = ticket.getForwardedToName() != null ? j(ticket.getForwardedToName()) : "–"; + + List fields = new ArrayList<>(); + fields.add(new Field("👤 Ersteller", ticket.getCreatorName(), true)); + fields.add(new Field("📤 Weitergeleitet von", j(fromName), true)); + fields.add(new Field("📥 Weitergeleitet an", forwardedTo, true)); + fields.add(new Field("🎫 Ticket ID", "#" + ticket.getId(), true)); if (showCat && plugin.getConfig().getBoolean("categories-enabled", true)) { ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey()); - fields.append(",").append(field("Kategorie", cat.getName(), true)); - } - if (showPri && plugin.getConfig().getBoolean("priorities-enabled", true)) { - fields.append(",").append(field("Priorität", ticket.getPriority().getDisplayName(), true)); + fields.add(new Field("🏷️ Kategorie", cat.getName(), true)); } - String content = ping ? buildRolePing() : ""; - String json = buildPayload(content, title, Integer.parseInt(color), fields.toString(), footer); + if (showPri && plugin.getConfig().getBoolean("priorities-enabled", true)) { + fields.add(new Field("⚡ Priorität", prioEmoji + " " + ticket.getPriority().getDisplayName(), true)); + } + + if (showSrv && plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) { + fields.add(new Field("🖥️ Server", ticket.getServerName(), true)); + } + + // JSON zusammenbauen & senden + String json = buildJson( + ping ? buildRolePing() : "", + "🔀 " + j(title) + " #" + ticket.getId(), + "**Anliegen**\n> " + j(ticket.getMessage()), + Integer.parseInt(color), + j(ticket.getCreatorName()), avatarUrl, + avatarUrl, + fields, + j(footer) + " • Ticket weitergeleitet" + ); + sendAsync(webhookUrl, json); } - // ─────────────────────────── Private Hilfsmethoden ───────────────────── + // ───────────────────────────────────────────────────────────────────────── + // JSON-Bau + // ───────────────────────────────────────────────────────────────────────── + + private record Field(String name, String value, boolean inline) {} + + /** + * Baut den kompletten JSON-Payload ohne String.format()-Chaos. + * Kein verschachteltes Escaping, kein ungültiges JSON. + */ + private String buildJson( + String content, + String title, + String description, + int color, + String authorName, + String authorIcon, + String thumbnailUrl, + List fields, + String footer + ) { + // Fields-Array aufbauen + StringBuilder fieldsJson = new StringBuilder("["); + for (int i = 0; i < fields.size(); i++) { + Field f = fields.get(i); + if (i > 0) fieldsJson.append(","); + fieldsJson + .append("{") + .append("\"name\":") .append(jsonString(f.name())) .append(",") + .append("\"value\":") .append(jsonString(f.value())) .append(",") + .append("\"inline\":") .append(f.inline()) + .append("}"); + } + fieldsJson.append("]"); + + // Embed-Objekt aufbauen + StringBuilder embed = new StringBuilder("{"); + embed.append("\"title\":") .append(jsonString(title)) .append(","); + embed.append("\"description\":") .append(jsonString(description)) .append(","); + embed.append("\"color\":") .append(color) .append(","); + embed.append("\"author\":{") + .append("\"name\":") .append(jsonString(authorName)) .append(",") + .append("\"icon_url\":") .append(jsonString(authorIcon)) + .append("},"); + embed.append("\"thumbnail\":{\"url\":").append(jsonString(thumbnailUrl)).append("},"); + embed.append("\"fields\":") .append(fieldsJson) .append(","); + embed.append("\"footer\":{\"text\":").append(jsonString(footer)) .append("},"); + embed.append("\"timestamp\":") .append(jsonString(Instant.now().toString())); + embed.append("}"); + + // Root-Objekt + return "{" + + "\"content\":" + jsonString(content) + "," + + "\"embeds\":[" + embed + "]" + + "}"; + } + + /** + * Gibt einen JSON-String-Wert zurück inkl. Anführungszeichen. + * Alle Sonderzeichen werden korrekt escaped. + */ + private String jsonString(String value) { + if (value == null) value = ""; + + StringBuilder sb = new StringBuilder("\""); + for (char c : value.toCharArray()) { + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\n' -> sb.append("\\n"); + case '\r' -> { /* ignorieren */ } + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + } + sb.append("\""); + return sb.toString(); + } + + /** + * Kurz-Alias: Escaped einen Wert für die Verwendung innerhalb von + * description-Strings (die bereits durch jsonString() laufen). + */ + private String j(String s) { + if (s == null) return "–"; + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", " ") + .replace("\r", ""); + } + + // ───────────────────────────────────────────────────────────────────────── + // Hilfsmethoden + // ───────────────────────────────────────────────────────────────────────── private boolean isEnabled() { return plugin.getConfig().getBoolean("discord.enabled", false); } - /** Gibt die Webhook-URL zurück oder null wenn nicht gesetzt. */ private String getWebhookUrl() { String url = plugin.getConfig().getString("discord.webhook-url", ""); return url.isEmpty() ? null : url; } - /** - * Baut den @Rollen-Ping-String aus der konfigurierten Rollen-ID. - * Leer wenn keine ID gesetzt. - */ private String buildRolePing() { String roleId = plugin.getConfig().getString("discord.role-ping-id", "").trim(); - if (roleId.isEmpty()) return ""; - return "<@&" + roleId + ">"; + return roleId.isEmpty() ? "" : "<@&" + roleId + ">"; } - /** - * Baut einen einzelnen Embed-Field als JSON-String. - */ - private String field(String name, String value, boolean inline) { - String safeValue = value != null - ? value.replace("\\", "\\\\").replace("\"", "\\\"") - : "–"; - String safeName = name.replace("\\", "\\\\").replace("\"", "\\\""); - return String.format("{\"name\":\"%s\",\"value\":\"%s\",\"inline\":%b}", - safeName, safeValue, inline); + private String getPriorityEmoji(TicketPriority priority) { + return switch (priority) { + case LOW -> "🟢"; + case NORMAL -> "🟡"; + case HIGH -> "🟠"; + case URGENT -> "🔴"; + }; } - /** - * Baut den kompletten Webhook-Payload als JSON. - * content = optionaler Ping-Text außerhalb des Embeds. - */ - private String buildPayload(String content, String title, int color, String fieldsJson, String footer) { - String timestamp = Instant.now().toString(); - String safeTitle = title.replace("\"", "\\\""); - String safeFooter = footer.replace("\"", "\\\""); - String safeContent = content.replace("\"", "\\\""); - - return String.format(""" - { - "content": "%s", - "embeds": [{ - "title": "%s", - "color": %d, - "fields": [%s], - "footer": { "text": "%s" }, - "timestamp": "%s" - }] - }""", - safeContent, - safeTitle, - color, - fieldsJson, - safeFooter, - timestamp); - } - - /** - * Sendet den JSON-Payload asynchron an den Webhook. - */ private void sendAsync(String webhookUrl, String json) { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { try { @@ -254,8 +366,12 @@ public class DiscordWebhook { } int responseCode = conn.getResponseCode(); + if (plugin.isDebug()) { - plugin.getLogger().info("[DEBUG] Discord Webhook Response: " + responseCode); + plugin.getLogger().info("[DEBUG][DiscordWebhook] Response: " + responseCode); + if (responseCode != 200 && responseCode != 204) { + plugin.getLogger().info("[DEBUG][DiscordWebhook] Payload: " + json); + } } if (responseCode != 200 && responseCode != 204) { @@ -263,6 +379,7 @@ public class DiscordWebhook { } conn.disconnect(); + } catch (Exception e) { plugin.getLogger().warning("[DiscordWebhook] Fehler beim Senden: " + e.getMessage()); if (plugin.isDebug()) e.printStackTrace(); diff --git a/src/main/java/de/ticketsystem/gui/TicketGUI.java b/src/main/java/de/ticketsystem/gui/TicketGUI.java index b421655..db87742 100644 --- a/src/main/java/de/ticketsystem/gui/TicketGUI.java +++ b/src/main/java/de/ticketsystem/gui/TicketGUI.java @@ -196,9 +196,32 @@ public class TicketGUI implements Listener { // Slot 4: Ticket-Info inv.setItem(4, buildDetailInfoItem(ticket)); - // Slot 10: Teleportieren - inv.setItem(10, buildActionItem(Material.ENDER_PEARL, "§b§lTeleportieren", - List.of("§7Teleportiert dich zur", "§7Position des Tickets."))); + // ── Teleport-Button ─────────────────────────────────────────────── + // Standalone: → normaler Teleport-Button + // BungeeCord + bungee-teleport-enabled: → serverübergreifender Teleport-Button + // BungeeCord + bungee-teleport deaktiviert → gesperrter Button + if (!plugin.isBungeeCordEnabled()) { + inv.setItem(10, buildActionItem(Material.ENDER_PEARL, "§b§lTeleportieren", + List.of("§7Teleportiert dich zur", "§7Position des Tickets."))); + } else if (plugin.getConfig().getBoolean("bungee-teleport-enabled", true)) { + String targetServer = ticket.getServerName(); + boolean sameServer = plugin.getServerName().equals(targetServer); + String serverLine = "unknown".equals(targetServer) + ? "§cServer unbekannt" + : sameServer + ? "§7Dieser Server §a(direkt)" + : "§7Ziel-Server: §b" + targetServer; + inv.setItem(10, buildActionItem(Material.ENDER_PEARL, "§b§lTeleportieren", + List.of("§7Teleportiert dich zur Ticket-Position.", serverLine, + "§8" + (sameServer ? "Lokaler Teleport" : "Server-Wechsel erforderlich")))); + } else { + String serverInfo = !"unknown".equals(ticket.getServerName()) + ? "§7Ticket-Server: §b" + ticket.getServerName() + : "§7Server unbekannt"; + inv.setItem(10, buildActionItem(Material.GRAY_STAINED_GLASS_PANE, "§8Teleport deaktiviert", + List.of("§7Im BungeeCord-Modus ist", "§7Teleportation deaktiviert.", serverInfo, + "§8(bungee-teleport-enabled: false)"))); + } // Slot 12: Claimen / Löschen / Grau if (ticket.getStatus() == TicketStatus.OPEN) { @@ -299,7 +322,6 @@ public class TicketGUI implements Listener { // ── Spieler-GUI ──────────────────────────────────────────────────── if (title.equals(PLAYER_GUI_TITLE)) { - // Navigationstasten int curPage = playerPage.getOrDefault(player.getUniqueId(), 0); if (slot == 45) { openPlayerGUI(player, curPage - 1); return; } if (slot == 53) { openPlayerGUI(player, curPage + 1); return; } @@ -355,19 +377,16 @@ public class TicketGUI implements Listener { // ─────────────────────────── Navigation-Handler ───────────────────────── - /** - * Verarbeitet Klicks auf die Navigationsleiste der Admin-Übersicht (Slots 45–53). - */ private void handleAdminNavClick(Player player, int slot, boolean isArchive) { int curPage = adminPage.getOrDefault(player.getUniqueId(), 0); switch (slot) { - case 45 -> openGUI(player, curPage - 1); // Zurück - case 53 -> openGUI(player, curPage + 1); // Vor - case 49 -> { // Archiv-Button oder Zurück im Archiv + case 45 -> openGUI(player, curPage - 1); + case 53 -> openGUI(player, curPage + 1); + case 49 -> { if (player.hasPermission(ARCHIVE_PERMISSION)) openClosedGUI(player); else player.sendMessage(plugin.color("&cDu hast keine Berechtigung, das Archiv zu öffnen.")); } - case 47 -> { // Kategorie-Filter (wenn aktiviert) + case 47 -> { if (plugin.getConfig().getBoolean("categories-enabled", true)) { cycleCategoryFilter(player); openGUI(player, 0); @@ -381,11 +400,10 @@ public class TicketGUI implements Listener { switch (slot) { case 45 -> openClosedGUI(player, curPage - 1); case 53 -> openClosedGUI(player, curPage + 1); - case 49 -> openGUI(player); // Zurück zur Hauptübersicht + case 49 -> openGUI(player); } } - /** Wechselt zum nächsten Kategorie-Filter */ private void cycleCategoryFilter(Player player) { CategoryManager cm = plugin.getCategoryManager(); List all = cm.getAll(); @@ -396,7 +414,7 @@ public class TicketGUI implements Listener { } else { int idx = all.indexOf(current); int next = idx + 1; - if (next >= all.size()) categoryFilter.remove(player.getUniqueId()); // Zurück zu Alle + if (next >= all.size()) categoryFilter.remove(player.getUniqueId()); else categoryFilter.put(player.getUniqueId(), all.get(next)); } ConfigCategory newFilter = categoryFilter.getOrDefault(player.getUniqueId(), null); @@ -416,15 +434,97 @@ public class TicketGUI implements Listener { }); } + // ── BUG FIX: handleDetailTeleport ──────────────────────────────────────── + // Vorher: Teleport wurde immer ausgeführt – auch bei aktivem BungeeCord. + // ticket.getLocation() gibt null zurück wenn die World auf diesem + // Server nicht existiert → NullPointerException oder falscher Teleport. + // + // Fix: Bei bungeecord: true + bungee-teleport-enabled: true → + // 1. Zielposition in DB speichern (ticket_pending_teleport) + // 2. Spieler via Plugin Messaging Channel auf Ziel-Server schicken + // 3. PlayerJoinListener teleportiert ihn dort zur Position + // Bei bungeecord: true + bungee-teleport-enabled: false → gesperrt. + // Bei bungeecord: false → normaler lokaler Teleport wie bisher. + // + // Hinweis: Ist der Admin bereits auf dem Ziel-Server, wird direkt teleportiert. + // ───────────────────────────────────────────────────────────────────────── private void handleDetailTeleport(Player player, Ticket ticket) { - if (ticket.getLocation() != null) { - player.teleport(ticket.getLocation()); - player.sendMessage(plugin.color("&7Du wurdest zu Ticket &e#" + ticket.getId() + " &7teleportiert.")); + if (!plugin.isBungeeCordEnabled()) { + // ── Standalone-Modus: direkt teleportieren ── + if (ticket.getLocation() != null) { + player.teleport(ticket.getLocation()); + player.sendMessage(plugin.color("&7Du wurdest zu Ticket &e#" + ticket.getId() + " &7teleportiert.")); + } else { + player.sendMessage(plugin.color("&cDie Welt des Tickets ist nicht geladen!")); + } + openDetailGUI(player, ticket); + return; + } + + // ── BungeeCord-Modus ────────────────────────────────────────────── + boolean bungeeTP = plugin.getConfig().getBoolean("bungee-teleport-enabled", true); + if (!bungeeTP) { + String serverHint = !"unknown".equals(ticket.getServerName()) + ? " §7(Server: §b" + ticket.getServerName() + "§7)" : ""; + player.sendMessage(plugin.color("&cServerübergreifender Teleport ist in der Config deaktiviert." + serverHint)); + openDetailGUI(player, ticket); + return; + } + + String targetServer = ticket.getServerName(); + if ("unknown".equals(targetServer)) { + player.sendMessage(plugin.color("&cServer des Tickets unbekannt – Teleport nicht möglich.")); + openDetailGUI(player, ticket); + return; + } + + String currentServer = plugin.getServerName(); + + if (currentServer.equals(targetServer)) { + // ── Bereits auf dem richtigen Server: direkt teleportieren ──── + if (ticket.getLocation() != null) { + player.teleport(ticket.getLocation()); + player.sendMessage(plugin.color("&7Du wurdest zu Ticket &e#" + ticket.getId() + " &7teleportiert.")); + } else { + player.sendMessage(plugin.color("&cDie Welt des Tickets ist nicht geladen!")); + } + openDetailGUI(player, ticket); } else { - player.sendMessage(plugin.color("&cDie Welt des Tickets ist nicht geladen!")); + // ── Anderer Server: Position in DB speichern + Server-Wechsel ─ + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + plugin.getDatabaseManager().setPendingTeleport( + player.getUniqueId(), + ticket.getWorldName(), + ticket.getX(), ticket.getY(), ticket.getZ(), + ticket.getYaw(), ticket.getPitch() + ); + Bukkit.getScheduler().runTask(plugin, () -> { + // BungeeCord Plugin Messaging Channel: Spieler auf Ziel-Server schicken + player.sendMessage(plugin.color("&7Verbinde dich mit Server &b" + targetServer + + " &7für Ticket &e#" + ticket.getId() + "&7...")); + try { + java.io.ByteArrayOutputStream b = new java.io.ByteArrayOutputStream(); + java.io.DataOutputStream out = new java.io.DataOutputStream(b); + out.writeUTF("Connect"); + out.writeUTF(targetServer); + player.sendPluginMessage(plugin, "BungeeCord", b.toByteArray()); + } catch (Exception e) { + plugin.getLogger().warning("[TicketSystem] BungeeCord Connect fehlgeschlagen: " + e.getMessage()); + player.sendMessage(plugin.color("&cServer-Wechsel fehlgeschlagen. Bitte manuell verbinden.")); + } + }); + }); } } + // ── BUG FIX: handleDetailClaim ─────────────────────────────────────────── + // Vorher: Nach erfolgreichem Claim wurde immer teleportiert wenn + // ticket.getLocation() != null – unabhängig von BungeeCord. + // + // Fix: Teleport nach Claim nutzt dieselbe Logik wie handleDetailTeleport: + // Standalone → direkt, BungeeCord + enabled → Server-Wechsel + pending, + // BungeeCord + disabled → nur Nachricht, kein Teleport. + // ───────────────────────────────────────────────────────────────────────── private void handleDetailClaim(Player player, Ticket ticket) { if (ticket.getStatus() != TicketStatus.OPEN) { player.sendMessage(plugin.formatMessage("messages.already-claimed")); @@ -440,11 +540,9 @@ public class TicketGUI implements Listener { ticket.setClaimerUUID(player.getUniqueId()); ticket.setClaimerName(player.getName()); plugin.getTicketManager().notifyCreatorClaimed(ticket); - if (ticket.getLocation() != null) player.teleport(ticket.getLocation()); - Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { - Ticket fresh = plugin.getDatabaseManager().getTicketById(ticket.getId()); - Bukkit.getScheduler().runTask(plugin, () -> { if (fresh != null) openDetailGUI(player, fresh); }); - }); + + // Teleport nach dem Claim – gleiche Logik wie handleDetailTeleport + handleDetailTeleport(player, ticket); }); }); } @@ -513,7 +611,6 @@ public class TicketGUI implements Listener { }); } - private void handleShowComments(Player player, Ticket ticket) { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { List comments = plugin.getDatabaseManager().getComments(ticket.getId()); @@ -529,7 +626,6 @@ public class TicketGUI implements Listener { } } player.sendMessage(plugin.color("&8&m ")); - // Gleich wieder Detail-GUI öffnen openDetailGUI(player, ticket); }); }); @@ -552,10 +648,21 @@ public class TicketGUI implements Listener { } final String comment = input.equals("-") ? "" : input; + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { boolean success = plugin.getDatabaseManager().closeTicket(ticketId, comment); if (success) { Ticket ticket = plugin.getDatabaseManager().getTicketById(ticketId); + + // ── FIX: Schließung in persistente Stats-Tabelle eintragen ────────── + // Vorher fehlte dieser Aufruf in der GUI – Bewertungen wurden dem + // schließenden Admin zugeordnet nur wenn /ticket close genutzt wurde. + // Jetzt wird player.getName() korrekt als closerName übergeben, + // unabhängig davon ob das Ticket vorher von jemand anderem geclaimed war. + if (ticket != null) { + plugin.getDatabaseManager().recordClosedTicket(ticket, player.getName()); + } + Bukkit.getScheduler().runTask(plugin, () -> { player.sendMessage(plugin.formatMessage("messages.ticket-closed").replace("{id}", String.valueOf(ticketId))); if (!comment.isEmpty()) player.sendMessage(plugin.color("&7Kommentar: &f" + comment)); @@ -570,33 +677,24 @@ public class TicketGUI implements Listener { // ─────────────────────────── Item-Builder ────────────────────────────── - /** - * Füllt die Navigationsleiste (letzte Reihe, Slots 45–53). - * Layout: [45]=Zurück | [47]=Filter | [49]=Archiv/Hauptmenü | [51]=leer | [53]=Weiter - */ private void fillAdminNavigation(Inventory inv, boolean isArchiveView, Player player, int page, int totalPages) { ItemStack glass = makeGlass(); for (int i = 45; i < 54; i++) inv.setItem(i, glass); - // Zurück (Slot 45) if (page > 0) { inv.setItem(45, buildActionItem(Material.ARROW, "§7§l◄ Zurück", List.of("§7Seite " + page + " von " + totalPages))); } - - // Weiter (Slot 53) if (page < totalPages - 1) { inv.setItem(53, buildActionItem(Material.ARROW, "§7§lWeiter ►", List.of("§7Seite " + (page + 2) + " von " + totalPages))); } - // Seitenanzeige (Slot 49) if (!isArchiveView) { if (player.hasPermission(ARCHIVE_PERMISSION)) { inv.setItem(49, buildActionItem(Material.CHEST, "§7§lGeschlossene Tickets", List.of("§7Zeigt alle abgeschlossenen", "§7Tickets im Archiv an."))); } - // Kategorie-Filter (Slot 47), nur wenn aktiviert if (plugin.getConfig().getBoolean("categories-enabled", true)) { ConfigCategory currentFilter = categoryFilter.getOrDefault(player.getUniqueId(), null); String filterLabel = currentFilter != null ? currentFilter.getColored() : "§7Alle"; @@ -611,12 +709,10 @@ public class TicketGUI implements Listener { inv.setItem(47, buildActionItem(Material.HOPPER, "§e§lKategorie-Filter", filterLore)); } } else { - // Im Archiv: Zurück-Button in Slot 49 inv.setItem(49, buildActionItem(Material.ARROW, "§7§lZurück zur Übersicht", List.of("§7Zeigt alle offenen Tickets."))); } - // Seitenanzeige Mitte oben (Slot 48) inv.setItem(48, buildActionItem(Material.PAPER, "§8Seite " + (page + 1) + "/" + totalPages, List.of("§7Gesamt: " + (playerSlotMap.containsKey(player.getUniqueId()) ? playerSlotMap.get(player.getUniqueId()).size() + "+" : "?") + " Tickets auf dieser Seite"))); @@ -630,11 +726,7 @@ public class TicketGUI implements Listener { inv.setItem(49, buildActionItem(Material.PAPER, "§8Seite " + (page + 1) + "/" + totalPages, List.of())); } - // ─────────────────────────── Item-Builder ────────────────────────────── - private ItemStack buildAdminListItem(Ticket ticket) { - // Material: Kategorie aus Config (z.B. REDSTONE für Bug, BOOK für Frage) - // Fallback auf Status-Material wenn categories-enabled: false Material mat; if (plugin.getConfig().getBoolean("categories-enabled", true)) { mat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey()).getMaterial(); @@ -651,7 +743,6 @@ public class TicketGUI implements Listener { ItemMeta meta = item.getItemMeta(); if (meta == null) return item; - // Priorität farblich im Titel anzeigen (wenn aktiviert) String priorityPrefix = plugin.getConfig().getBoolean("priorities-enabled", true) ? ticket.getPriority().getColored() + " §8| " : ""; meta.setDisplayName(priorityPrefix + "§6§lTicket #" + ticket.getId() + " §r" + ticket.getStatus().getColored()); @@ -693,6 +784,9 @@ public class TicketGUI implements Listener { lore.add("§7Ersteller: §e" + ticket.getCreatorName()); lore.add("§7Anliegen: §f" + ticket.getMessage()); lore.add("§7Erstellt: §e" + DATE_FORMAT.format(ticket.getCreatedAt())); + if (plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) { + lore.add("§7Server: §b" + ticket.getServerName()); + } lore.add("§7Welt: §e" + ticket.getWorldName()); lore.add(String.format("§7Position: §e%.0f, %.0f, %.0f", ticket.getX(), ticket.getY(), ticket.getZ())); if (plugin.getConfig().getBoolean("categories-enabled", true)) { @@ -739,6 +833,9 @@ public class TicketGUI implements Listener { lore.add("§8§m "); lore.add("§7Anliegen: §f" + ticket.getMessage()); lore.add("§7Erstellt: §e" + DATE_FORMAT.format(ticket.getCreatedAt())); + if (plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) { + lore.add("§7Server: §b" + ticket.getServerName()); + } lore.add("§7Welt: §e" + ticket.getWorldName()); lore.add(String.format("§7Position: §e%.0f, %.0f, %.0f", ticket.getX(), ticket.getY(), ticket.getZ())); if (plugin.getConfig().getBoolean("categories-enabled", true)) { diff --git a/src/main/java/de/ticketsystem/listeners/PlayerJoinListener.java b/src/main/java/de/ticketsystem/listeners/PlayerJoinListener.java index f422afc..8c8fb3e 100644 --- a/src/main/java/de/ticketsystem/listeners/PlayerJoinListener.java +++ b/src/main/java/de/ticketsystem/listeners/PlayerJoinListener.java @@ -3,10 +3,13 @@ package de.ticketsystem.listeners; import java.util.List; import de.ticketsystem.TicketPlugin; +import de.ticketsystem.database.DatabaseManager; import de.ticketsystem.model.Ticket; import de.ticketsystem.model.TicketStatus; import org.bukkit.Bukkit; import org.bukkit.ChatColor; +import org.bukkit.Location; +import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; @@ -39,6 +42,37 @@ public class PlayerJoinListener implements Listener { }); } + // ── BungeeCord: ausstehenden Teleport-Auftrag prüfen ───────────── + // Wenn ein Admin via GUI auf einen anderen Server geschickt wurde, + // liegt hier die Zielposition. Wir teleportieren ihn nach dem Spawn. + if (plugin.isBungeeCordEnabled() + && plugin.getConfig().getBoolean("bungee-teleport-enabled", true)) { + Bukkit.getScheduler().runTaskLater(plugin, () -> { + if (!player.isOnline()) return; + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + DatabaseManager.PendingTeleport pt = + plugin.getDatabaseManager().consumePendingTeleport(player.getUniqueId()); + if (pt == null) return; + + Bukkit.getScheduler().runTask(plugin, () -> { + if (!player.isOnline()) return; + World world = Bukkit.getWorld(pt.world()); + if (world == null) { + player.sendMessage(plugin.color( + "&cTeleport-Zielwelt &e" + pt.world() + " &cnicht gefunden!")); + return; + } + Location loc = new Location(world, pt.x(), pt.y(), pt.z(), pt.yaw(), pt.pitch()); + player.teleport(loc); + player.sendMessage(plugin.color( + "&7Du wurdest zur Ticket-Position teleportiert. &8(" + + String.format("%.0f, %.0f, %.0f", pt.x(), pt.y(), pt.z()) + ")")); + }); + }); + // 40 Ticks (2 Sek) Verzögerung damit der Spieler vollständig gespawnt ist + }, 40L); + } + // ── Ausstehende Kommentar-/Schließ-Benachrichtigungen anzeigen ──── // (Nachrichten die ankamen während der Spieler offline war) Bukkit.getScheduler().runTaskLater(plugin, () -> { @@ -66,15 +100,17 @@ public class PlayerJoinListener implements Listener { plugin.getTicketManager().notifyClaimedWhileOffline(player); }, 60L); - // ── Spieler: über geschlossene Tickets mit Kommentar informieren ── + // ── Spieler: über geschlossene Tickets informieren (nur wenn noch nicht geschehen) ── + // Bug-Fix: Nutzt close_notified aus der DB statt in-memory Set. + // Verhindert Duplikate bei Server-Wechseln in BungeeCord-Netzwerken. Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { List closed = plugin.getDatabaseManager() .getTicketsByStatus(TicketStatus.CLOSED); for (Ticket t : closed) { if (!t.getCreatorUUID().equals(player.getUniqueId())) continue; - if (t.getCloseComment() == null || t.getCloseComment().isEmpty()) continue; - if (plugin.getTicketManager().wasClosedNotificationSent(t.getId())) continue; + // DB-Feld prüfen – funktioniert serverübergreifend + if (t.isCloseNotified()) continue; Bukkit.getScheduler().runTask(plugin, () -> plugin.getTicketManager().notifyCreatorClosed(t)); diff --git a/src/main/java/de/ticketsystem/manager/TicketManager.java b/src/main/java/de/ticketsystem/manager/TicketManager.java index 322712e..ef79e68 100644 --- a/src/main/java/de/ticketsystem/manager/TicketManager.java +++ b/src/main/java/de/ticketsystem/manager/TicketManager.java @@ -8,9 +8,7 @@ import org.bukkit.Bukkit; import org.bukkit.entity.Player; import java.util.HashMap; -import java.util.HashSet; import java.util.Map; -import java.util.Set; import java.util.UUID; public class TicketManager { @@ -20,9 +18,6 @@ public class TicketManager { /** Cooldown Map: UUID → Zeitstempel letztes Ticket */ private final Map cooldowns = new HashMap<>(); - /** Ticket-IDs für die der Ersteller bereits über Schließung informiert wurde */ - private final Set notifiedClosedTickets = new HashSet<>(); - public TicketManager(TicketPlugin plugin) { this.plugin = plugin; } @@ -46,34 +41,55 @@ public class TicketManager { // ─────────────────────────── Benachrichtigungen ──────────────────────── /** - * Benachrichtigt alle Online-Supporter/Admins über ein neues Ticket - * und sendet optional eine Discord-Webhook-Nachricht. + * Benachrichtigt alle Supporter/Admins über ein neues Ticket – auch auf anderen Servern. + * + * Lokal online Spieler werden direkt angesprochen. + * Über BungeeCord werden alle anderen Server im Netzwerk ebenfalls benachrichtigt. + * Optional sendet der Discord-Webhook eine Nachricht. */ public void notifyTeam(Ticket ticket) { String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unbekannt"; String message = ticket.getMessage() != null ? ticket.getMessage() : ""; // Kategorie & Priorität optional anzeigen - String categoryInfo = ""; - String priorityInfo = ""; + String categoryInfo = ""; + String priorityInfo = ""; if (plugin.getConfig().getBoolean("categories-enabled", true)) { - de.ticketsystem.model.ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey()); + ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey()); categoryInfo = " §7[§r" + cat.getColored() + "§7]"; } if (plugin.getConfig().getBoolean("priorities-enabled", true)) { priorityInfo = " §7Priorität: §r" + ticket.getPriority().getColored(); } + // BungeeCord: Server-Herkunft anzeigen wenn BungeeCord aktiviert + String serverInfo = ""; + if (plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) { + serverInfo = " §7Server: §b" + ticket.getServerName(); + } + String msg = plugin.formatMessage("messages.new-ticket-notify") .replace("{player}", creatorName) .replace("{message}", message) .replace("{id}", String.valueOf(ticket.getId())) - + categoryInfo + priorityInfo; + + categoryInfo + priorityInfo + serverInfo; - for (Player p : Bukkit.getOnlinePlayers()) { - if (p.hasPermission("ticket.support") || p.hasPermission("ticket.admin")) { - p.sendMessage(msg); - p.sendMessage(plugin.color("&7» Klicke &e/ticket list &7um die GUI zu öffnen.")); + String guiHint = plugin.color("&7» Klicke &e/ticket list &7um die GUI zu öffnen."); + + if (plugin.isBungeeCordEnabled()) { + // ─ BungeeCord-Modus: Team-Broadcast über alle Server ───────────────── + // BungeeMessenger sendet lokal direkt, dann per Forward an alle anderen Server. + // Beide Nachrichten werden zu einer zusammengefasst um ein einzelnes + // Forward-Paket zu erzeugen statt zwei (reduziert Netzwerklast und + // verhindert mögliche Reihenfolge-Probleme). + plugin.getBungeeMessenger().broadcastTeamNotification(msg + "\n" + guiHint); + } else { + // ─ Standalone-Modus: Nur lokal ─────────────────────────────── + for (Player p : Bukkit.getOnlinePlayers()) { + if (p.hasPermission("ticket.support") || p.hasPermission("ticket.admin")) { + p.sendMessage(msg); + p.sendMessage(guiHint); + } } } @@ -83,20 +99,18 @@ public class TicketManager { /** * Benachrichtigt den Ersteller, wenn sein Ticket angenommen wurde. * Setzt claimer_notified = true und persistiert es. + * + * BungeeCord: Zustellung auch wenn der Spieler auf einem anderen Server ist. */ public void notifyCreatorClaimed(Ticket ticket) { - Player creator = Bukkit.getPlayer(ticket.getCreatorUUID()); - if (creator != null && creator.isOnline()) { - String claimerName = ticket.getClaimerName(); - if (claimerName == null && ticket.getClaimerUUID() != null) - claimerName = Bukkit.getOfflinePlayer(ticket.getClaimerUUID()).getName(); - if (claimerName == null) claimerName = "Support"; + String claimerName = resolveClaimerName(ticket); + + String msg = plugin.formatMessage("messages.ticket-claimed-notify") + .replace("{id}", String.valueOf(ticket.getId())) + .replace("{claimer}", claimerName); + + deliverToPlayer(ticket.getCreatorUUID(), ticket.getCreatorName(), msg); - String msg = plugin.formatMessage("messages.ticket-claimed-notify") - .replace("{id}", String.valueOf(ticket.getId())) - .replace("{claimer}", claimerName); - creator.sendMessage(msg); - } // Persistiert setzen, damit Join-Listener weiß, dass Spieler bereits informiert ist plugin.getDatabaseManager().markClaimerNotified(ticket.getId()); } @@ -106,15 +120,13 @@ public class TicketManager { * die geclaimt oder weitergeleitet wurden während er offline war. */ public void notifyClaimedWhileOffline(Player player) { - // Suche alle Tickets dieses Spielers, die CLAIMED/FORWARDED sind, - // aber noch nicht notified wurden Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { var tickets = plugin.getDatabaseManager().getTicketsByStatus( TicketStatus.CLAIMED, TicketStatus.FORWARDED); for (Ticket t : tickets) { if (!t.getCreatorUUID().equals(player.getUniqueId())) continue; - if (t.isClaimerNotified()) continue; // wurde schon informiert + if (t.isClaimerNotified()) continue; String claimerName = t.getClaimerName() != null ? t.getClaimerName() : "Support"; final String name = claimerName; @@ -142,81 +154,190 @@ public class TicketManager { /** * Benachrichtigt den Ersteller, wenn sein Ticket weitergeleitet wurde. + * BungeeCord: Cross-Server-Zustellung. */ public void notifyCreatorForwarded(Ticket ticket) { - Player creator = Bukkit.getPlayer(ticket.getCreatorUUID()); - if (creator != null && creator.isOnline()) { - String forwardedTo = ticket.getForwardedToName() != null ? ticket.getForwardedToName() : "einen Supporter"; - String msg = plugin.formatMessage("messages.ticket-forwarded-creator-notify") - .replace("{id}", String.valueOf(ticket.getId())) - .replace("{supporter}", forwardedTo); - creator.sendMessage(msg); - } - // Auch hier notified setzen + String forwardedTo = ticket.getForwardedToName() != null ? ticket.getForwardedToName() : "einen Supporter"; + String msg = plugin.formatMessage("messages.ticket-forwarded-creator-notify") + .replace("{id}", String.valueOf(ticket.getId())) + .replace("{supporter}", forwardedTo); + + deliverToPlayer(ticket.getCreatorUUID(), ticket.getCreatorName(), msg); + + // Auch bei Weiterleitung notified setzen plugin.getDatabaseManager().markClaimerNotified(ticket.getId()); } /** * Sendet dem weitergeleiteten Supporter eine Benachrichtigung. + * BungeeCord: Zustellung auch wenn der Supporter auf einem anderen Server ist. */ public void notifyForwardedTo(Ticket ticket, String fromName) { - Player target = Bukkit.getPlayer(ticket.getForwardedToUUID()); - if (target != null && target.isOnline()) { - String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unbekannt"; - String msg = plugin.formatMessage("messages.ticket-forwarded-notify") - .replace("{player}", creatorName) - .replace("{id}", String.valueOf(ticket.getId())); - target.sendMessage(msg); - } + if (ticket.getForwardedToUUID() == null) return; + + String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unbekannt"; + String msg = plugin.formatMessage("messages.ticket-forwarded-notify") + .replace("{player}", creatorName) + .replace("{id}", String.valueOf(ticket.getId())); + + deliverToPlayer(ticket.getForwardedToUUID(), ticket.getForwardedToName(), msg); + plugin.getDiscordWebhook().sendTicketForwarded(ticket, fromName); } /** * Benachrichtigt den Ersteller, wenn sein Ticket geschlossen wurde. + * BungeeCord: Cross-Server-Zustellung + Fallback in Pending-DB. */ public void notifyCreatorClosed(Ticket ticket) { notifyCreatorClosed(ticket, null); } public void notifyCreatorClosed(Ticket ticket, String closerName) { - notifiedClosedTickets.add(ticket.getId()); + // Bug-Fix: close_notified wird in der DB gespeichert – kein In-Memory-Set mehr. + // Dadurch funktioniert der Check auch nach einem Server-Wechsel korrekt. + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> + plugin.getDatabaseManager().markCloseNotified(ticket.getId())); - Player creator = Bukkit.getPlayer(ticket.getCreatorUUID()); String comment = (ticket.getCloseComment() != null && !ticket.getCloseComment().isEmpty()) ? ticket.getCloseComment() : ""; + // Hauptnachricht + String msg = plugin.formatMessage("messages.ticket-closed-notify") + .replace("{id}", String.valueOf(ticket.getId())) + .replace("{comment}", comment); + + // Bewertungsaufforderung + String ratingMsg = null; + if (plugin.getConfig().getBoolean("rating-enabled", true)) { + ratingMsg = plugin.color( + "&8&m &r\n" + + "&6Wie zufrieden bist du mit dem Support?\n" + + "&a/ticket rate " + ticket.getId() + " good &7– 👍 Gut\n" + + "&c/ticket rate " + ticket.getId() + " bad &7– 👎 Schlecht\n" + + "&8&m "); + } + + // Prüfen ob Ersteller lokal online ist + Player creator = Bukkit.getPlayer(ticket.getCreatorUUID()); if (creator != null && creator.isOnline()) { - String msg = plugin.formatMessage("messages.ticket-closed-notify") - .replace("{id}", String.valueOf(ticket.getId())) - .replace("{comment}", comment); + // ─ Lokal online: direkt zustellen ──────────────────────────── creator.sendMessage(msg); if (!comment.isEmpty()) creator.sendMessage(plugin.color("&7Kommentar des Supports: &f" + comment)); - if (plugin.getConfig().getBoolean("rating-enabled", true)) { - creator.sendMessage(plugin.color("&8&m ")); - creator.sendMessage(plugin.color("&6Wie zufrieden bist du mit dem Support?")); - creator.sendMessage(plugin.color("&a/ticket rate " + ticket.getId() + " good &7– 👍 Gut")); - creator.sendMessage(plugin.color("&c/ticket rate " + ticket.getId() + " bad &7– 👎 Schlecht")); - creator.sendMessage(plugin.color("&8&m ")); - } + if (ratingMsg != null) creator.sendMessage(ratingMsg); + + } else if (plugin.isBungeeCordEnabled()) { + // ─ BungeeCord: via Plugin-Messaging auf anderen Servern zustellen ─ + // KEIN savePendingClosedNotification hier! Das würde bei Server-Wechsel + // als "Offline-Nachricht" doppelt angezeigt werden. + // BungeeCord's "Message"-Kanal erreicht den Spieler netzwerkweit sofern er online ist. + // Ist er wirklich offline, sieht er beim nächsten Login via PlayerJoinListener + // eine frische Benachrichtigung (close_notified=true verhindert Duplikate). + plugin.getBungeeMessenger().sendMessageToPlayer( + ticket.getCreatorUUID(), ticket.getCreatorName(), msg); + if (!comment.isEmpty()) + plugin.getBungeeMessenger().sendMessageToPlayer( + ticket.getCreatorUUID(), ticket.getCreatorName(), + plugin.color("&7Kommentar des Supports: &f" + comment)); + if (ratingMsg != null) + plugin.getBungeeMessenger().sendMessageToPlayer( + ticket.getCreatorUUID(), ticket.getCreatorName(), ratingMsg); + } else { - // Offline → ausstehende Benachrichtigung speichern - String pendingMsg = "&e[Ticket #" + ticket.getId() + "] &7Dein Ticket wurde geschlossen." - + (comment.isEmpty() ? "" : " &7Kommentar: &f" + comment) - + (plugin.getConfig().getBoolean("rating-enabled", true) - ? " &7Bewertung: &e/ticket rate " + ticket.getId() + " good/bad" : ""); - Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> - plugin.getDatabaseManager().addPendingNotification(ticket.getCreatorUUID(), pendingMsg)); + // ─ Standalone, Spieler offline: in Pending-DB speichern ────── + savePendingClosedNotification(ticket, comment); } String closer = closerName != null ? closerName : "Unbekannt"; plugin.getDiscordWebhook().sendTicketClosed(ticket, closer); } + /** + * Bug-Fix: Nutzt jetzt close_notified aus der DB statt ein In-Memory-Set. + * Funktioniert damit auch nach Server-Wechseln in BungeeCord-Netzwerken korrekt. + * + * @deprecated Bitte stattdessen ticket.isCloseNotified() direkt prüfen, + * da das Ticket-Objekt aus der DB bereits den korrekten Wert hat. + */ public boolean wasClosedNotificationSent(int ticketId) { - return notifiedClosedTickets.contains(ticketId); + // Direkt in der DB nachschlagen – kein In-Memory-Set, kein Server-gebundener State + Ticket t = plugin.getDatabaseManager().getTicketById(ticketId); + return t != null && t.isCloseNotified(); + } + + // ─────────────────────────── BungeeCord Hilfsmethoden ────────────────── + + // ── BUG FIX #2 ────────────────────────────────────────────────────────── + // Vorher: addPendingNotification() wurde IMMER asynchron ausgeführt – + // auch wenn der Spieler lokal online war oder BungeeCord die + // Nachricht bereits zugestellt hat. Das führte dazu, dass Spieler + // beim nächsten Login immer noch eine "verpasste Nachricht" sahen, + // obwohl sie die Nachricht bereits erhalten hatten. + // + // Fix: addPendingNotification() wird nur noch aufgerufen wenn: + // 1. Der Spieler NICHT lokal online ist, UND + // 2. BungeeCord NICHT aktiviert ist (Standalone-Fallback). + // Im BungeeCord-Modus ist der BungeeCord-"Message"-Kanal für die + // Zustellung zuständig. Offline-Spieler werden über close_notified + // und den PlayerJoinListener beim nächsten Login benachrichtigt. + // ──────────────────────────────────────────────────────────────────────── + + /** + * Zustellung einer Nachricht an einen Spieler. + * + * Ablauf: + * 1. Spieler lokal online → direkt + * 2. BungeeCord aktiv → via Plugin-Messaging (kein Pending-Eintrag) + * 3. Offline + Standalone → Pending-DB (Zustellung beim nächsten Login) + * + * @param uuid UUID des Empfängers + * @param name Spielername (für BungeeCord-Lookup) + * @param message Bereits color-übersetzter Text + */ + private void deliverToPlayer(UUID uuid, String name, String message) { + Player local = Bukkit.getPlayer(uuid); + if (local != null && local.isOnline()) { + // Lokal online → direkt zustellen, fertig + local.sendMessage(message); + return; + } + + if (plugin.isBungeeCordEnabled()) { + // BungeeCord-Modus: Nachricht über Plugin-Messaging weiterleiten. + // KEIN Pending-Eintrag! BungeeCord übernimmt die Zustellung. + // Ist der Spieler wirklich offline, kümmert sich der PlayerJoinListener + // beim nächsten Login um die Benachrichtigung. + plugin.getBungeeMessenger().sendMessageToPlayer(uuid, name, message); + return; + } + + // Standalone-Modus, Spieler offline → in Pending-DB speichern + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> + plugin.getDatabaseManager().addPendingNotification(uuid, message)); + } + + /** + * Speichert eine ausstehende Schließ-Benachrichtigung in der DB. + */ + private void savePendingClosedNotification(Ticket ticket, String comment) { + String pendingMsg = "&e[Ticket #" + ticket.getId() + "] &7Dein Ticket wurde geschlossen." + + (comment.isEmpty() ? "" : " &7Kommentar: &f" + comment) + + (plugin.getConfig().getBoolean("rating-enabled", true) + ? " &7Bewertung: &e/ticket rate " + ticket.getId() + " good/bad" : ""); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> + plugin.getDatabaseManager().addPendingNotification(ticket.getCreatorUUID(), pendingMsg)); } // ─────────────────────────── Hilfsmethoden ───────────────────────────── + private String resolveClaimerName(Ticket ticket) { + if (ticket.getClaimerName() != null) return ticket.getClaimerName(); + if (ticket.getClaimerUUID() != null) { + String name = Bukkit.getOfflinePlayer(ticket.getClaimerUUID()).getName(); + if (name != null) return name; + } + return "Support"; + } + public boolean hasReachedTicketLimit(UUID uuid) { int max = plugin.getConfig().getInt("max-open-tickets-per-player", 2); if (max <= 0) return false; @@ -245,5 +366,11 @@ public class TicketManager { player.sendMessage(plugin.color("&e/ticket stats &7– Statistiken anzeigen")); } player.sendMessage(plugin.color("&8&m ")); + + // BungeeCord-Status anzeigen + if (player.hasPermission("ticket.admin") && plugin.isBungeeCordEnabled()) { + player.sendMessage(plugin.color("&8[BungeeCord] &7Server: &b" + plugin.getServerName() + + " &8| Cross-Server-Benachrichtigungen &aaktiv")); + } } } \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/model/Ticket.java b/src/main/java/de/ticketsystem/model/Ticket.java index ab6339f..981da94 100644 --- a/src/main/java/de/ticketsystem/model/Ticket.java +++ b/src/main/java/de/ticketsystem/model/Ticket.java @@ -25,6 +25,13 @@ public class Ticket implements ConfigurationSerializable { private double x, y, z; private float yaw, pitch; + /** + * Name des Servers auf dem das Ticket erstellt wurde (BungeeCord-Netzwerk). + * Entspricht dem Wert aus config.yml → server-name. + * Standardwert: "unknown" + */ + private String serverName = "unknown"; + private TicketStatus status; private UUID claimerUUID; private String claimerName; @@ -46,6 +53,12 @@ public class Ticket implements ConfigurationSerializable { private String playerRating = null; private boolean claimerNotified = false; + /** + * Gibt an ob der Ersteller bereits über die Schließung informiert wurde. + * Wird in der DB gespeichert damit Server-Wechsel keine Duplikate erzeugen. + */ + private boolean closeNotified = false; + public Ticket() {} public Ticket(UUID creatorUUID, String creatorName, String message, Location location) { @@ -88,6 +101,9 @@ public class Ticket implements ConfigurationSerializable { if (map.containsKey("priority")) this.priority = TicketPriority.fromString((String) map.get("priority")); if (map.containsKey("playerRating")) this.playerRating = (String) map.get("playerRating"); if (map.containsKey("claimerNotified")) this.claimerNotified = (boolean) map.get("claimerNotified"); + // BungeeCord: Server-Name laden (Fallback: "unknown") + if (map.containsKey("serverName")) this.serverName = (String) map.get("serverName"); + if (map.containsKey("closeNotified")) this.closeNotified = (boolean) map.get("closeNotified"); } @Override @@ -112,6 +128,9 @@ public class Ticket implements ConfigurationSerializable { map.put("priority", priority.name()); if (playerRating != null) map.put("playerRating", playerRating); map.put("claimerNotified", claimerNotified); + // BungeeCord: Server-Name speichern + map.put("serverName", serverName); + map.put("closeNotified", closeNotified); return map; } @@ -126,6 +145,8 @@ public class Ticket implements ConfigurationSerializable { private static float toFloat(Object o) { return o instanceof Float f ? f : ((Number) o).floatValue(); } private static long toLong(Object o) { return ((Number) o).longValue(); } + // ─────────────────────────── Getter & Setter ─────────────────────────── + public int getId() { return id; } public void setId(int id) { this.id = id; } public UUID getCreatorUUID() { return creatorUUID; } @@ -175,4 +196,14 @@ public class Ticket implements ConfigurationSerializable { public boolean hasRating() { return playerRating != null; } public boolean isClaimerNotified() { return claimerNotified; } public void setClaimerNotified(boolean v) { this.claimerNotified = v; } + + /** BungeeCord: Gibt den Server-Namen zurück, auf dem das Ticket erstellt wurde. */ + public String getServerName() { return serverName != null ? serverName : "unknown"; } + /** BungeeCord: Setzt den Server-Namen (aus config.yml → server-name). */ + public void setServerName(String v) { this.serverName = v != null ? v : "unknown"; } + + /** Gibt an ob der Ersteller bereits über die Schließung informiert wurde (DB-persistent). */ + public boolean isCloseNotified() { return closeNotified; } + /** Setzt den close_notified-Flag (wird in DB gespeichert). */ + public void setCloseNotified(boolean v) { this.closeNotified = v; } } \ No newline at end of file diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index e6d3487..fd350be 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -17,6 +17,26 @@ version: "2.0" # Debug-Modus (true = Logs in der Konsole) debug: false +# ---------------------------------------------------- +# BUNGEECORD (Cross-Server-Unterstützung) +# ---------------------------------------------------- +# VORAUSSETZUNGEN: +# 1. In spigot.yml auf JEDEM Server: bungeecord: true +# 2. MySQL muss aktiviert sein (use-mysql: true) +# 3. Plugin auf JEDEM Spigot-Server installieren +# 4. Alle Server müssen dieselbe MySQL-Datenbank verwenden +# +# false = Normaler Single-Server-Modus (Standard) +# true = Cross-Server Benachrichtigungen aktiv +# ---------------------------------------------------- +bungeecord: false +bungee-teleport-enabled: true + +# Name dieses Servers im BungeeCord-Netzwerk. +# Wird in Tickets, GUI und Discord-Embeds angezeigt. +# Auf jedem Server ANDERS einstellen! (z.B. "survival", "creative", "skyblock") +server-name: "survival" + # ---------------------------------------------------- # SPEICHERPFAD & ARCHIV # ---------------------------------------------------- @@ -156,6 +176,7 @@ discord: show-position: true # Welt & Koordinaten im Embed anzeigen show-category: true # Kategorie im Embed anzeigen show-priority: true # Priorität im Embed anzeigen + show-server: true # BungeeCord: Server-Name im Embed anzeigen role-ping: false # Rollen-Ping bei neuem Ticket senden # ── Ticket geschlossen ────────────────────────────────────────────────── @@ -166,6 +187,7 @@ discord: footer: "TicketSystem" show-category: true # Kategorie im Embed anzeigen show-priority: true # Priorität im Embed anzeigen + show-server: true # BungeeCord: Server-Name im Embed anzeigen role-ping: false # Rollen-Ping beim Schließen senden # ── Ticket weitergeleitet ─────────────────────────────────────────────── @@ -176,6 +198,7 @@ discord: footer: "TicketSystem" show-category: true # Kategorie im Embed anzeigen show-priority: true # Priorität im Embed anzeigen + show-server: true # BungeeCord: Server-Name im Embed anzeigen role-ping: false # Rollen-Ping beim Weiterleiten senden # ---------------------------------------------------- diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index b83a5e7..04cac53 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -5,6 +5,12 @@ api-version: 1.20 author: M_Viper description: Ingame Support Ticket System with MySQL +# ── BungeeCord Plugin-Messaging-Kanäle ─────────────────────────────────────── +# PFLICHTFELD für Cross-Server-Benachrichtigungen! +channels: + - BungeeCord + - ticketsystem:notify + commands: ticket: description: TicketSystem Hauptbefehl