Update from Git Manager GUI

This commit is contained in:
2026-02-21 16:00:03 +01:00
parent 834bd0e5e4
commit 7ede377c07
11 changed files with 1495 additions and 332 deletions

View File

@@ -1,5 +1,6 @@
package de.ticketsystem; package de.ticketsystem;
import de.ticketsystem.bungee.BungeeMessenger;
import de.ticketsystem.commands.TicketCommand; import de.ticketsystem.commands.TicketCommand;
import de.ticketsystem.database.DatabaseManager; import de.ticketsystem.database.DatabaseManager;
import de.ticketsystem.discord.DiscordWebhook; import de.ticketsystem.discord.DiscordWebhook;
@@ -18,11 +19,20 @@ public class TicketPlugin extends JavaPlugin {
private static TicketPlugin instance; private static TicketPlugin instance;
private boolean debug; 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 DatabaseManager databaseManager;
private TicketManager ticketManager; private TicketManager ticketManager;
private CategoryManager categoryManager; private CategoryManager categoryManager;
private TicketGUI ticketGUI; private TicketGUI ticketGUI;
private DiscordWebhook discordWebhook; private DiscordWebhook discordWebhook;
private BungeeMessenger bungeeMessenger;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -33,6 +43,33 @@ public class TicketPlugin extends JavaPlugin {
// Ticket-Klasse für YAML-Serialisierung registrieren // Ticket-Klasse für YAML-Serialisierung registrieren
Ticket.register(); 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 // Update-Checker
int resourceId = 132757; int resourceId = 132757;
new UpdateChecker(this, resourceId).getVersion(version -> { new UpdateChecker(this, resourceId).getVersion(version -> {
@@ -108,6 +145,10 @@ public class TicketPlugin extends JavaPlugin {
@Override @Override
public void onDisable() { public void onDisable() {
// Plugin-Messaging-Kanäle abmelden
getServer().getMessenger().unregisterOutgoingPluginChannel(this);
getServer().getMessenger().unregisterIncomingPluginChannel(this);
if (databaseManager != null) databaseManager.disconnect(); if (databaseManager != null) databaseManager.disconnect();
getLogger().info("TicketSystem wurde deaktiviert."); getLogger().info("TicketSystem wurde deaktiviert.");
} }
@@ -132,5 +173,18 @@ public class TicketPlugin extends JavaPlugin {
public CategoryManager getCategoryManager() { return categoryManager; } public CategoryManager getCategoryManager() { return categoryManager; }
public TicketGUI getTicketGUI() { return ticketGUI; } public TicketGUI getTicketGUI() { return ticketGUI; }
public DiscordWebhook getDiscordWebhook() { return discordWebhook; } public DiscordWebhook getDiscordWebhook() { return discordWebhook; }
public BungeeMessenger getBungeeMessenger() { return bungeeMessenger; }
public boolean isDebug() { return debug; } 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); }
} }

View File

@@ -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<? extends Player> online = Bukkit.getOnlinePlayers();
return online.isEmpty() ? null : online.iterator().next();
}
}

View File

@@ -100,13 +100,11 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
boolean prioritiesOn = plugin.getConfig().getBoolean("priorities-enabled", true); boolean prioritiesOn = plugin.getConfig().getBoolean("priorities-enabled", true);
if (args.length >= 3) { if (args.length >= 3) {
// args[1]: erst als Kategorie prüfen, dann als Priorität
if (categoriesOn) { if (categoriesOn) {
ConfigCategory parsedCat = cm.resolve(args[1]); ConfigCategory parsedCat = cm.resolve(args[1]);
if (parsedCat != null) { if (parsedCat != null) {
category = parsedCat; category = parsedCat;
messageStartIndex = 2; messageStartIndex = 2;
// args[2]: Priorität prüfen (nur wenn danach noch Text kommt)
if (prioritiesOn && args.length >= 4) { if (prioritiesOn && args.length >= 4) {
TicketPriority parsedPrio = parsePriority(args[2]); TicketPriority parsedPrio = parsePriority(args[2]);
if (parsedPrio != null) { if (parsedPrio != null) {
@@ -115,7 +113,6 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
} }
} }
} else { } else {
// Keine Kategorie erkannt → args[1] als Priorität prüfen
if (prioritiesOn) { if (prioritiesOn) {
TicketPriority parsedPrio = parsePriority(args[1]); TicketPriority parsedPrio = parsePriority(args[1]);
if (parsedPrio != null) { if (parsedPrio != null) {
@@ -125,16 +122,12 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
} }
} }
} else if (prioritiesOn) { } else if (prioritiesOn) {
// Kategorien aus → args[1] direkt als Priorität prüfen
TicketPriority parsedPrio = parsePriority(args[1]); TicketPriority parsedPrio = parsePriority(args[1]);
if (parsedPrio != null) { if (parsedPrio != null) {
priority = parsedPrio; priority = parsedPrio;
messageStartIndex = 2; 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)); 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 ticket = new Ticket(player.getUniqueId(), player.getName(), message, player.getLocation());
ticket.setCategoryKey(finalCategory.getKey()); ticket.setCategoryKey(finalCategory.getKey());
ticket.setPriority(finalPriority); ticket.setPriority(finalPriority);
// BungeeCord: Server-Name des erstellenden Servers speichern
ticket.setServerName(plugin.getServerName());
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
int id = plugin.getDatabaseManager().createTicket(ticket); 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; } if (!success) { player.sendMessage(plugin.formatMessage("messages.already-claimed")); return; }
Ticket ticket = plugin.getDatabaseManager().getTicketById(ticketId); Ticket ticket = plugin.getDatabaseManager().getTicketById(ticketId);
if (ticket == null) return; if (ticket == null) return;
player.sendMessage(plugin.formatMessage("messages.ticket-claimed") player.sendMessage(plugin.formatMessage("messages.ticket-claimed")
.replace("{id}", String.valueOf(ticketId)) .replace("{id}", String.valueOf(ticketId))
.replace("{player}", ticket.getCreatorName())); .replace("{player}", ticket.getCreatorName()));
plugin.getTicketManager().notifyCreatorClaimed(ticket); 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); boolean success = plugin.getDatabaseManager().closeTicket(ticketId, comment);
if (success) { if (success) {
Ticket ticket = plugin.getDatabaseManager().getTicketById(ticketId); 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, () -> { Bukkit.getScheduler().runTask(plugin, () -> {
player.sendMessage(plugin.formatMessage("messages.ticket-closed").replace("{id}", String.valueOf(ticketId))); player.sendMessage(plugin.formatMessage("messages.ticket-closed").replace("{id}", String.valueOf(ticketId)));
if (ticket != null) { if (ticket != null) {
@@ -252,12 +270,23 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
try { id = Integer.parseInt(args[1]); } try { id = Integer.parseInt(args[1]); }
catch (NumberFormatException e) { player.sendMessage(plugin.color("&cUngültige ID!")); return; } catch (NumberFormatException e) { player.sendMessage(plugin.color("&cUngültige ID!")); return; }
Player target = Bukkit.getPlayer(args[2]); // BungeeCord: Ziel-Spieler lokal suchen
if (target == null) { player.sendMessage(plugin.color("&cSpieler nicht gefunden!")); return; } 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 int ticketId = id;
final String fromName = player.getName(); final String fromName = player.getName();
final Player t = target; final Player t = localTarget;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
boolean success = plugin.getDatabaseManager().forwardTicket(ticketId, t.getUniqueId(), t.getName()); 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"))); Bukkit.getScheduler().runTask(plugin, () -> player.sendMessage(plugin.formatMessage("messages.ticket-not-found")));
return; return;
} }
// Spieler darf nur auf eigene Tickets kommentieren (Supporter/Admin: alle)
boolean isOwner = ticket.getCreatorUUID().equals(player.getUniqueId()); boolean isOwner = ticket.getCreatorUUID().equals(player.getUniqueId());
boolean isStaff = player.hasPermission("ticket.support") || player.hasPermission("ticket.admin"); boolean isStaff = player.hasPermission("ticket.support") || player.hasPermission("ticket.admin");
if (!isOwner && !isStaff) { if (!isOwner && !isStaff) {
@@ -309,7 +337,6 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
Bukkit.getScheduler().runTask(plugin, () -> { Bukkit.getScheduler().runTask(plugin, () -> {
if (success) { if (success) {
player.sendMessage(plugin.color("&aDein Kommentar zu Ticket &e#" + ticketId + " &awurde gespeichert.")); player.sendMessage(plugin.color("&aDein Kommentar zu Ticket &e#" + ticketId + " &awurde gespeichert."));
// Supporter/Admin und Ticket-Ersteller benachrichtigen
notifyCommentReceivers(player, ticket, message); notifyCommentReceivers(player, ticket, message);
} else { } else {
player.sendMessage(plugin.color("&cFehler beim Speichern des Kommentars.")); 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) { 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 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; 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())) { if (!ticket.getCreatorUUID().equals(author.getUniqueId())) {
Player creator = Bukkit.getPlayer(ticket.getCreatorUUID()); Player creator = Bukkit.getPlayer(ticket.getCreatorUUID());
if (creator != null && creator.isOnline()) { if (creator != null && creator.isOnline()) {
creator.sendMessage(onlineMsg); 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 { } else {
// Offline → für nächsten Login speichern // Standalone: Offline → für nächsten Login speichern
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> Bukkit.getScheduler().runTaskAsynchronously(plugin, () ->
plugin.getDatabaseManager().addPendingNotification(ticket.getCreatorUUID(), offlineMsg)); 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")) { if (!author.hasPermission("ticket.support") && !author.hasPermission("ticket.admin")) {
// Claimer des Tickets bevorzugt benachrichtigen
UUID claimerUUID = ticket.getClaimerUUID(); if (plugin.isBungeeCordEnabled()) {
if (claimerUUID != null && !claimerUUID.equals(author.getUniqueId())) { // BungeeCord-Modus: broadcastTeamNotification() übernimmt ALLES
Player claimer = Bukkit.getPlayer(claimerUUID); // lokal direkt + Forward an alle anderen Server in einem Paket.
if (claimer != null && claimer.isOnline()) { // KEINE zusätzliche lokale Schleife, da das zu Duplikaten führt.
claimer.sendMessage(onlineMsg); plugin.getBungeeMessenger().broadcastTeamNotification(onlineMsg);
} else {
String claimerOffline = "&e[Ticket #" + ticket.getId() + "] &f" + author.getName() + " &7hat auf dein bearbeitetes Ticket kommentiert (offline): &f" + message; } else {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> // Standalone-Modus: Claimer gezielt benachrichtigen
plugin.getDatabaseManager().addPendingNotification(claimerUUID, claimerOffline)); 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 // Alle anderen Online-Supporter auf diesem Server informieren
for (Player p : Bukkit.getOnlinePlayers()) { for (Player p : Bukkit.getOnlinePlayers()) {
if (p.getUniqueId().equals(author.getUniqueId())) continue; if (p.getUniqueId().equals(author.getUniqueId())) continue;
if (claimerUUID != null && p.getUniqueId().equals(claimerUUID)) continue; // schon oben if (claimerUUID != null && p.getUniqueId().equals(claimerUUID)) continue;
if (p.hasPermission("ticket.support") || p.hasPermission("ticket.admin")) { if (p.hasPermission("ticket.support") || p.hasPermission("ticket.admin")) {
p.sendMessage(onlineMsg); p.sendMessage(onlineMsg);
}
} }
} }
} }
@@ -468,7 +531,6 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
player.sendMessage(plugin.color("&7Keine gesperrten Spieler.")); player.sendMessage(plugin.color("&7Keine gesperrten Spieler."));
} else { } else {
for (String[] entry : list) { for (String[] entry : list) {
// {uuid, name, reason, bannedBy, bannedAt}
player.sendMessage(plugin.color("&e" + entry[1] + " &7 &f" + entry[2] player.sendMessage(plugin.color("&e" + entry[1] + " &7 &f" + entry[2]
+ " &7(gesperrt von &e" + entry[3] + "&7)")); + " &7(gesperrt von &e" + entry[3] + "&7)"));
} }
@@ -488,6 +550,9 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
plugin.reloadConfig(); plugin.reloadConfig();
plugin.getCategoryManager().reload(); plugin.getCategoryManager().reload();
player.sendMessage(plugin.color("&aKonfiguration wurde neu geladen. &7(inkl. Kategorien)")); 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 ─────────────────────────── // ─────────────────────────── /ticket archive ───────────────────────────
@@ -508,26 +573,55 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
private void handleStats(Player player) { private void handleStats(Player player) {
if (!player.hasPermission("ticket.admin")) { player.sendMessage(plugin.formatMessage("messages.no-permission")); return; } if (!player.hasPermission("ticket.admin")) { player.sendMessage(plugin.formatMessage("messages.no-permission")); return; }
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
var stats = plugin.getDatabaseManager().getTicketStats(); var stats = plugin.getDatabaseManager().getTicketStats();
var staffRatings = plugin.getDatabaseManager().getStaffRatings();
Bukkit.getScheduler().runTask(plugin, () -> { Bukkit.getScheduler().runTask(plugin, () -> {
player.sendMessage(plugin.color("&8&m ")); player.sendMessage(plugin.color("&8&m "));
player.sendMessage(plugin.color("&6Ticket Statistik")); player.sendMessage(plugin.color("&6Ticket Statistik"));
player.sendMessage(plugin.color("&8&m ")); player.sendMessage(plugin.color("&8&m "));
player.sendMessage(plugin.color("&eGesamt: &a" + stats.total)); player.sendMessage(plugin.color("&eGesamt: &a" + stats.total));
player.sendMessage(plugin.color("&eOffen: &a" + stats.open)); player.sendMessage(plugin.color("&eOffen: &a" + stats.open));
player.sendMessage(plugin.color("&eGeschlossen: &a" + stats.closed)); player.sendMessage(plugin.color("&eGeschlossen: &a" + stats.closed + " &7(historisch)"));
player.sendMessage(plugin.color("&eWeitergeleitet: &a" + stats.forwarded)); player.sendMessage(plugin.color("&eWeitergeleitet: &a" + stats.forwarded));
if (plugin.getConfig().getBoolean("rating-enabled", true)) { if (plugin.getConfig().getBoolean("rating-enabled", true)) {
player.sendMessage(plugin.color("&8&m ")); 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 player.sendMessage(plugin.color("&a👍 Positiv: &f" + stats.thumbsUp
+ " &c👎 Negativ: &f" + stats.thumbsDown)); + " &c👎 Negativ: &f" + stats.thumbsDown));
int total = stats.thumbsUp + stats.thumbsDown; int totalRated = stats.thumbsUp + stats.thumbsDown;
if (total > 0) { if (totalRated > 0) {
int percent = (int) Math.round(stats.thumbsUp * 100.0 / total); int percent = (int) Math.round(stats.thumbsUp * 100.0 / totalRated);
player.sendMessage(plugin.color("&7Zufriedenheit: &e" + percent + "%")); 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("&8&m "));
player.sendMessage(plugin.color("&6Top Ersteller:")); player.sendMessage(plugin.color("&6Top Ersteller:"));
stats.byPlayer.entrySet().stream() 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. /** Parst Benutzer-Eingaben zu TicketPriority. Gibt null zurück wenn keine Übereinstimmung. */
* Gibt null zurück wenn keine Übereinstimmung. */
private TicketPriority parsePriority(String input) { private TicketPriority parsePriority(String input) {
if (input == null) return null; if (input == null) return null;
return switch (input.toLowerCase()) { return switch (input.toLowerCase()) {
@@ -661,14 +754,12 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
&& plugin.getConfig().getBoolean("categories-enabled", true)) { && plugin.getConfig().getBoolean("categories-enabled", true)) {
for (ConfigCategory c : plugin.getCategoryManager().getAll()) for (ConfigCategory c : plugin.getCategoryManager().getAll())
if (c.getKey().startsWith(args[1].toLowerCase())) completions.add(c.getKey()); 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)) if (plugin.getConfig().getBoolean("priorities-enabled", true))
for (String p : List.of("low", "normal", "high", "urgent")) for (String p : List.of("low", "normal", "high", "urgent"))
if (p.startsWith(args[1].toLowerCase())) completions.add(p); if (p.startsWith(args[1].toLowerCase())) completions.add(p);
} else if (args.length == 3 && args[0].equalsIgnoreCase("create") } else if (args.length == 3 && args[0].equalsIgnoreCase("create")
&& plugin.getConfig().getBoolean("priorities-enabled", true)) { && plugin.getConfig().getBoolean("priorities-enabled", true)) {
// Priorität nach Kategorie
for (String p : List.of("low", "normal", "high", "urgent")) for (String p : List.of("low", "normal", "high", "urgent"))
if (p.startsWith(args[2].toLowerCase())) completions.add(p); 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); if (p.startsWith(args[2].toLowerCase())) completions.add(p);
} else if (args.length == 3 && args[0].equalsIgnoreCase("forward")) { } else if (args.length == 3 && args[0].equalsIgnoreCase("forward")) {
// BungeeCord: Nur lokal online Spieler als Tab-Completion
for (Player p : Bukkit.getOnlinePlayers()) for (Player p : Bukkit.getOnlinePlayers())
if (p.getName().toLowerCase().startsWith(args[2].toLowerCase())) completions.add(p.getName()); if (p.getName().toLowerCase().startsWith(args[2].toLowerCase())) completions.add(p.getName());

View File

@@ -161,6 +161,7 @@ public class DatabaseManager {
private void createTables() { private void createTables() {
// Haupt-Tickets-Tabelle // Haupt-Tickets-Tabelle
// BungeeCord: server_name speichert auf welchem Server das Ticket erstellt wurde
String ticketsSql = """ String ticketsSql = """
CREATE TABLE IF NOT EXISTS tickets ( CREATE TABLE IF NOT EXISTS tickets (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -186,7 +187,9 @@ public class DatabaseManager {
category VARCHAR(16) NOT NULL DEFAULT 'GENERAL', category VARCHAR(16) NOT NULL DEFAULT 'GENERAL',
priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL', priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL',
player_rating VARCHAR(16) NULL, 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"""; """;
@@ -225,11 +228,50 @@ public class DatabaseManager {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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()) { try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) {
stmt.execute(ticketsSql); stmt.execute(ticketsSql);
stmt.execute(commentsSql); stmt.execute(commentsSql);
stmt.execute(blacklistSql); stmt.execute(blacklistSql);
stmt.execute(notifSql); stmt.execute(notifSql);
stmt.execute(statsSql);
stmt.execute(pendingTeleportSql);
} catch (SQLException e) { } catch (SQLException e) {
plugin.getLogger().log(Level.SEVERE, "Fehler beim Erstellen der Tabellen: " + e.getMessage(), 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. * Ergänzt fehlende Spalten in bestehenden Datenbanken automatisch.
* Wichtig für Upgrades von älteren Versionen.
*/ */
private void ensureColumns() { private void ensureColumns() {
ensureColumn("close_comment", "ALTER TABLE tickets ADD COLUMN close_comment VARCHAR(500) NULL"); 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("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("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"); 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) { 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 ────────────────────────────── // ─────────────────────────── CRUD Tickets ──────────────────────────────
public int createTicket(Ticket ticket) { public int createTicket(Ticket ticket) {
if (useMySQL) { if (useMySQL) {
// BungeeCord: server_name wird ebenfalls gespeichert
String sql = """ String sql = """
INSERT INTO tickets (creator_uuid, creator_name, message, world, x, y, z, yaw, pitch, category, priority) INSERT INTO tickets (creator_uuid, creator_name, message, world, x, y, z, yaw, pitch,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) category, priority, server_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""; """;
try (Connection conn = getConnection(); try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
@@ -289,6 +362,7 @@ public class DatabaseManager {
ps.setFloat(9, ticket.getPitch()); ps.setFloat(9, ticket.getPitch());
ps.setString(10, ticket.getCategoryKey()); ps.setString(10, ticket.getCategoryKey());
ps.setString(11, ticket.getPriority().name()); ps.setString(11, ticket.getPriority().name());
ps.setString(12, ticket.getServerName()); // BungeeCord
ps.executeUpdate(); ps.executeUpdate();
ResultSet rs = ps.getGeneratedKeys(); ResultSet rs = ps.getGeneratedKeys();
if (rs.next()) return rs.getInt(1); 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) { public boolean claimTicket(int ticketId, UUID claimerUUID, String claimerName) {
if (useMySQL) { if (useMySQL) {
String sql = """ String sql = """
UPDATE tickets UPDATE tickets
SET status = 'CLAIMED', claimer_uuid = ?, claimer_name = ?, SET status = 'CLAIMED', claimer_uuid = ?, claimer_name = ?,
claimed_at = NOW(), player_deleted = FALSE 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)) { try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, claimerUUID.toString()); ps.setString(1, claimerUUID.toString());
@@ -325,7 +407,7 @@ public class DatabaseManager {
return false; return false;
} else { } else {
Ticket t = getTicketById(ticketId); 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.setStatus(TicketStatus.CLAIMED);
t.setClaimerUUID(claimerUUID); t.setClaimerUUID(claimerUUID);
t.setClaimerName(claimerName); 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) { public void markClaimerNotified(int ticketId) {
if (useMySQL) { if (useMySQL) {
String sql = "UPDATE tickets SET claimer_notified = TRUE WHERE id = ?"; 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. * Setzt close_notified = TRUE für ein Ticket (persistiert in DB/Datei).
* @param ticketId ID des Tickets * Verhindert Duplikat-Benachrichtigungen und doppelte Discord-Nachrichten
* @param rating "THUMBS_UP" oder "THUMBS_DOWN" * bei Server-Wechseln in BungeeCord-Netzwerken.
* @return true bei Erfolg
*/ */
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) { public boolean rateTicket(int ticketId, String rating) {
if (useMySQL) { if (useMySQL) {
String sql = "UPDATE tickets SET player_rating = ? WHERE id = ? AND status = 'CLOSED' AND player_rating IS NULL"; 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)) { try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, rating); ps.setString(1, rating);
ps.setInt(2, ticketId); 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) { } catch (SQLException e) {
plugin.getLogger().log(Level.SEVERE, "Fehler bei rateTicket: " + e.getMessage(), 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<String[]> getStaffRatings() {
List<String[]> 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) { public boolean addComment(TicketComment comment) {
if (useMySQL) { if (useMySQL) {
String sql = """ String sql = """
@@ -527,7 +807,6 @@ public class DatabaseManager {
} }
return false; return false;
} else { } else {
// YAML: comments.<ticketId>.<index>
int index = dataConfig.getInt("comments." + comment.getTicketId() + ".count", 0); int index = dataConfig.getInt("comments." + comment.getTicketId() + ".count", 0);
String base = "comments." + comment.getTicketId() + "." + index + "."; String base = "comments." + comment.getTicketId() + "." + index + ".";
dataConfig.set(base + "authorUUID", comment.getAuthorUUID().toString()); 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<TicketComment> getComments(int ticketId) { public List<TicketComment> getComments(int ticketId) {
List<TicketComment> list = new ArrayList<>(); List<TicketComment> list = new ArrayList<>();
if (useMySQL) { if (useMySQL) {
@@ -582,12 +858,8 @@ public class DatabaseManager {
return list; 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) { public void addPendingNotification(UUID playerUUID, String rawMessage) {
if (useMySQL) { if (useMySQL) {
String sql = "INSERT INTO ticket_pending_notifications (player_uuid, message) VALUES (?, ?)"; 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<String> getPendingNotifications(UUID playerUUID) { public List<String> getPendingNotifications(UUID playerUUID) {
List<String> messages = new ArrayList<>(); List<String> messages = new ArrayList<>();
if (useMySQL) { if (useMySQL) {
@@ -627,9 +896,6 @@ public class DatabaseManager {
return messages; return messages;
} }
/**
* Löscht alle ausstehenden Benachrichtigungen eines Spielers nach dem Anzeigen.
*/
public void clearPendingNotifications(UUID playerUUID) { public void clearPendingNotifications(UUID playerUUID) {
if (useMySQL) { if (useMySQL) {
String sql = "DELETE FROM ticket_pending_notifications WHERE player_uuid = ?"; 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) { public boolean isBlacklisted(UUID uuid) {
if (useMySQL) { 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<String[]> getBlacklist() { public List<String[]> getBlacklist() {
List<String[]> list = new ArrayList<>(); List<String[]> list = new ArrayList<>();
if (useMySQL) { if (useMySQL) {
@@ -890,28 +1155,69 @@ public class DatabaseManager {
// ─────────────────────────── Statistiken ─────────────────────────────── // ─────────────────────────── Statistiken ───────────────────────────────
public TicketStats getTicketStats() { public TicketStats getTicketStats() {
// Aktuelle Live-Daten aus der tickets-Tabelle
List<Ticket> all = getAllTickets(); List<Ticket> 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<String, Integer> byPlayer = new java.util.HashMap<>(); java.util.Map<String, Integer> byPlayer = new java.util.HashMap<>();
java.util.Map<String, Integer> byServer = new java.util.HashMap<>();
for (Ticket t : all) { for (Ticket t : all) {
switch (t.getStatus()) { switch (t.getStatus()) {
case OPEN -> open++; case OPEN -> open++;
case CLAIMED -> claimed++; case CLAIMED -> claimed++;
case FORWARDED -> forwarded++; 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); 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 static class TicketStats {
public final int total, open, closed, forwarded, thumbsUp, thumbsDown; public final int total, open, closed, forwarded, thumbsUp, thumbsDown;
public final java.util.Map<String, Integer> byPlayer; public final java.util.Map<String, Integer> byPlayer;
/** BungeeCord: Anzahl Tickets pro Server */
public final java.util.Map<String, Integer> byServer;
public TicketStats(int total, int open, int closed, int forwarded, public TicketStats(int total, int open, int closed, int forwarded,
int thumbsUp, int thumbsDown, java.util.Map<String, Integer> byPlayer) { int thumbsUp, int thumbsDown,
java.util.Map<String, Integer> byPlayer,
java.util.Map<String, Integer> byServer) {
this.total = total; this.total = total;
this.open = open; this.open = open;
this.closed = closed; this.closed = closed;
@@ -919,6 +1225,7 @@ public class DatabaseManager {
this.thumbsUp = thumbsUp; this.thumbsUp = thumbsUp;
this.thumbsDown = thumbsDown; this.thumbsDown = thumbsDown;
this.byPlayer = byPlayer; 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.setPriority(TicketPriority.fromString(rs.getString("priority"))); } catch (SQLException ignored) {}
try { t.setPlayerRating(rs.getString("player_rating")); } catch (SQLException ignored) {} try { t.setPlayerRating(rs.getString("player_rating")); } catch (SQLException ignored) {}
try { t.setClaimerNotified(rs.getBoolean("claimer_notified")); } 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; return t;
} }
@@ -1032,6 +1343,9 @@ public class DatabaseManager {
obj.put("priority", t.getPriority().name()); obj.put("priority", t.getPriority().name());
if (t.getPlayerRating() != null) obj.put("playerRating", t.getPlayerRating()); if (t.getPlayerRating() != null) obj.put("playerRating", t.getPlayerRating());
obj.put("claimerNotified", t.isClaimerNotified()); obj.put("claimerNotified", t.isClaimerNotified());
obj.put("closeNotified", t.isCloseNotified());
// BungeeCord: Server-Name im JSON-Export
obj.put("serverName", t.getServerName());
return obj; return obj;
} }
@@ -1060,6 +1374,9 @@ public class DatabaseManager {
if (obj.containsKey("priority")) t.setPriority(TicketPriority.fromString((String) obj.get("priority"))); 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("playerRating")) t.setPlayerRating((String) obj.get("playerRating"));
if (obj.containsKey("claimerNotified"))t.setClaimerNotified((Boolean) obj.get("claimerNotified")); 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; return t;
} catch (Exception e) { } catch (Exception e) {
if (plugin != null) plugin.getLogger().severe("Fehler beim Parsen eines Tickets: " + e.getMessage()); if (plugin != null) plugin.getLogger().severe("Fehler beim Parsen eines Tickets: " + e.getMessage());

View File

@@ -3,6 +3,7 @@ package de.ticketsystem.discord;
import de.ticketsystem.TicketPlugin; import de.ticketsystem.TicketPlugin;
import de.ticketsystem.model.ConfigCategory; import de.ticketsystem.model.ConfigCategory;
import de.ticketsystem.model.Ticket; import de.ticketsystem.model.Ticket;
import de.ticketsystem.model.TicketPriority;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import java.io.OutputStream; import java.io.OutputStream;
@@ -10,96 +11,94 @@ import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Instant; 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 { public class DiscordWebhook {
// ─────────────────────────────────────────────────────────────────────────
// Konstanten & Felder
// ─────────────────────────────────────────────────────────────────────────
private static final String AVATAR_URL = "https://mc-heads.net/avatar/%s/64";
private final TicketPlugin plugin; private final TicketPlugin plugin;
// ─────────────────────────────────────────────────────────────────────────
// Konstruktor
// ─────────────────────────────────────────────────────────────────────────
public DiscordWebhook(TicketPlugin plugin) { public DiscordWebhook(TicketPlugin plugin) {
this.plugin = plugin; this.plugin = plugin;
} }
// ─────────────────────────── Öffentliche Methoden ────────────────────── // ─────────────────────────────────────────────────────────────────────────
// Öffentliche Methoden Webhook-Events
// ─────────────────────────────────────────────────────────────────────────
/**
* Sendet eine Benachrichtigung wenn ein neues Ticket erstellt wurde.
*/
public void sendNewTicket(Ticket ticket) { public void sendNewTicket(Ticket ticket) {
if (!isEnabled()) return; if (!isEnabled()) return;
String webhookUrl = getWebhookUrl(); String webhookUrl = getWebhookUrl();
if (webhookUrl == null) return; if (webhookUrl == null) return;
String title = plugin.getConfig().getString ("discord.messages.new-ticket.title", "🎫 Neues Ticket erstellt"); // Konfiguration lesen
String color = plugin.getConfig().getString ("discord.messages.new-ticket.color", "3066993"); 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"); String footer = plugin.getConfig().getString ("discord.messages.new-ticket.footer", "TicketSystem");
boolean showPos = plugin.getConfig().getBoolean("discord.messages.new-ticket.show-position", true); 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 showCat = plugin.getConfig().getBoolean("discord.messages.new-ticket.show-category", true);
boolean showPri = plugin.getConfig().getBoolean("discord.messages.new-ticket.show-priority", 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); boolean ping = plugin.getConfig().getBoolean("discord.messages.new-ticket.role-ping", true);
StringBuilder fields = new StringBuilder(); // Hilfs-Werte berechnen
fields.append(field("Spieler", ticket.getCreatorName(), true)); String prioEmoji = getPriorityEmoji(ticket.getPriority());
fields.append(",").append(field("Ticket ID", "#" + ticket.getId(), true)); String avatarUrl = String.format(AVATAR_URL, ticket.getCreatorUUID().toString());
fields.append(",").append(field("Anliegen", ticket.getMessage(), false));
// Felder aufbauen
List<Field> 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)) { if (showCat && plugin.getConfig().getBoolean("categories-enabled", true)) {
ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey()); 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)) { 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) { if (showPos) {
fields.append(",").append(field("Welt", ticket.getWorldName(), true)); fields.add(new Field("🌍 Welt", ticket.getWorldName(), true));
fields.append(",").append(field("Position", fields.add(new Field("📍 Position",
String.format("%.0f, %.0f, %.0f", ticket.getX(), ticket.getY(), ticket.getZ()), true)); String.format("%.0f, %.0f, %.0f", ticket.getX(), ticket.getY(), ticket.getZ()), true));
} }
String content = ping ? buildRolePing() : ""; // JSON zusammenbauen & senden
String json = buildPayload(content, title, Integer.parseInt(color), fields.toString(), footer); 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); sendAsync(webhookUrl, json);
} }
/** // ─────────────────────────────────────────────────────────────────────────
* Sendet eine Benachrichtigung wenn ein Ticket geschlossen wurde.
*/
public void sendTicketClosed(Ticket ticket, String closerName) { public void sendTicketClosed(Ticket ticket, String closerName) {
if (!isEnabled()) return; if (!isEnabled()) return;
if (!plugin.getConfig().getBoolean("discord.messages.ticket-closed.enabled", false)) return; if (!plugin.getConfig().getBoolean("discord.messages.ticket-closed.enabled", false)) return;
@@ -107,37 +106,68 @@ public class DiscordWebhook {
String webhookUrl = getWebhookUrl(); String webhookUrl = getWebhookUrl();
if (webhookUrl == null) return; if (webhookUrl == null) return;
String title = plugin.getConfig().getString ("discord.messages.ticket-closed.title", "🔒 Ticket geschlossen"); // Konfiguration lesen
String color = plugin.getConfig().getString ("discord.messages.ticket-closed.color", "15158332"); 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"); String footer = plugin.getConfig().getString ("discord.messages.ticket-closed.footer", "TicketSystem");
boolean showCat = plugin.getConfig().getBoolean("discord.messages.ticket-closed.show-category", true); 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 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); boolean ping = plugin.getConfig().getBoolean("discord.messages.ticket-closed.role-ping", false);
StringBuilder fields = new StringBuilder(); // Hilfs-Werte berechnen
fields.append(field("Ticket ID", "#" + ticket.getId(), true)); String prioEmoji = getPriorityEmoji(ticket.getPriority());
fields.append(",").append(field("Ersteller", ticket.getCreatorName(), true)); String avatarUrl = String.format(AVATAR_URL, ticket.getCreatorUUID().toString());
fields.append(",").append(field("Geschlossen von", closerName, true));
// 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<Field> 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)) { if (showCat && plugin.getConfig().getBoolean("categories-enabled", true)) {
ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey()); 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));
}
if (ticket.getCloseComment() != null && !ticket.getCloseComment().isEmpty()) {
fields.append(",").append(field("Kommentar", ticket.getCloseComment(), false));
} }
String content = ping ? buildRolePing() : ""; if (showPri && plugin.getConfig().getBoolean("priorities-enabled", true)) {
String json = buildPayload(content, title, Integer.parseInt(color), fields.toString(), footer); 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); sendAsync(webhookUrl, json);
} }
/** // ─────────────────────────────────────────────────────────────────────────
* Sendet eine Benachrichtigung wenn ein Ticket weitergeleitet wurde.
*/
public void sendTicketForwarded(Ticket ticket, String fromName) { public void sendTicketForwarded(Ticket ticket, String fromName) {
if (!isEnabled()) return; if (!isEnabled()) return;
if (!plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.enabled", false)) return; if (!plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.enabled", false)) return;
@@ -145,98 +175,180 @@ public class DiscordWebhook {
String webhookUrl = getWebhookUrl(); String webhookUrl = getWebhookUrl();
if (webhookUrl == null) return; 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 color = plugin.getConfig().getString ("discord.messages.ticket-forwarded.color", "15105570");
String footer = plugin.getConfig().getString ("discord.messages.ticket-forwarded.footer", "TicketSystem"); String footer = plugin.getConfig().getString ("discord.messages.ticket-forwarded.footer", "TicketSystem");
boolean showCat = plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.show-category", true); 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 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); boolean ping = plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.role-ping", false);
StringBuilder fields = new StringBuilder(); // Hilfs-Werte berechnen
fields.append(field("Ticket ID", "#" + ticket.getId(), true)); String prioEmoji = getPriorityEmoji(ticket.getPriority());
fields.append(",").append(field("Ersteller", ticket.getCreatorName(), true)); String avatarUrl = String.format(AVATAR_URL, ticket.getCreatorUUID().toString());
fields.append(",").append(field("Weitergeleitet von", fromName, true));
fields.append(",").append(field("Weitergeleitet an", ticket.getForwardedToName(), true)); // Felder aufbauen
String forwardedTo = ticket.getForwardedToName() != null ? j(ticket.getForwardedToName()) : "";
List<Field> 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)) { if (showCat && plugin.getConfig().getBoolean("categories-enabled", true)) {
ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey()); 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));
} }
String content = ping ? buildRolePing() : ""; if (showPri && plugin.getConfig().getBoolean("priorities-enabled", true)) {
String json = buildPayload(content, title, Integer.parseInt(color), fields.toString(), footer); 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); 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<Field> 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() { private boolean isEnabled() {
return plugin.getConfig().getBoolean("discord.enabled", false); return plugin.getConfig().getBoolean("discord.enabled", false);
} }
/** Gibt die Webhook-URL zurück oder null wenn nicht gesetzt. */
private String getWebhookUrl() { private String getWebhookUrl() {
String url = plugin.getConfig().getString("discord.webhook-url", ""); String url = plugin.getConfig().getString("discord.webhook-url", "");
return url.isEmpty() ? null : url; return url.isEmpty() ? null : url;
} }
/**
* Baut den @Rollen-Ping-String aus der konfigurierten Rollen-ID.
* Leer wenn keine ID gesetzt.
*/
private String buildRolePing() { private String buildRolePing() {
String roleId = plugin.getConfig().getString("discord.role-ping-id", "").trim(); String roleId = plugin.getConfig().getString("discord.role-ping-id", "").trim();
if (roleId.isEmpty()) return ""; return roleId.isEmpty() ? "" : "<@&" + roleId + ">";
return "<@&" + roleId + ">";
} }
/** private String getPriorityEmoji(TicketPriority priority) {
* Baut einen einzelnen Embed-Field als JSON-String. return switch (priority) {
*/ case LOW -> "🟢";
private String field(String name, String value, boolean inline) { case NORMAL -> "🟡";
String safeValue = value != null case HIGH -> "🟠";
? value.replace("\\", "\\\\").replace("\"", "\\\"") case URGENT -> "🔴";
: ""; };
String safeName = name.replace("\\", "\\\\").replace("\"", "\\\"");
return String.format("{\"name\":\"%s\",\"value\":\"%s\",\"inline\":%b}",
safeName, safeValue, inline);
} }
/**
* 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) { private void sendAsync(String webhookUrl, String json) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try { try {
@@ -254,8 +366,12 @@ public class DiscordWebhook {
} }
int responseCode = conn.getResponseCode(); int responseCode = conn.getResponseCode();
if (plugin.isDebug()) { 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) { if (responseCode != 200 && responseCode != 204) {
@@ -263,6 +379,7 @@ public class DiscordWebhook {
} }
conn.disconnect(); conn.disconnect();
} catch (Exception e) { } catch (Exception e) {
plugin.getLogger().warning("[DiscordWebhook] Fehler beim Senden: " + e.getMessage()); plugin.getLogger().warning("[DiscordWebhook] Fehler beim Senden: " + e.getMessage());
if (plugin.isDebug()) e.printStackTrace(); if (plugin.isDebug()) e.printStackTrace();

View File

@@ -196,9 +196,32 @@ public class TicketGUI implements Listener {
// Slot 4: Ticket-Info // Slot 4: Ticket-Info
inv.setItem(4, buildDetailInfoItem(ticket)); inv.setItem(4, buildDetailInfoItem(ticket));
// Slot 10: Teleportieren // ── Teleport-Button ───────────────────────────────────────────────
inv.setItem(10, buildActionItem(Material.ENDER_PEARL, "§b§lTeleportieren", // Standalone: → normaler Teleport-Button
List.of("§7Teleportiert dich zur", "§7Position des Tickets."))); // 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 // Slot 12: Claimen / Löschen / Grau
if (ticket.getStatus() == TicketStatus.OPEN) { if (ticket.getStatus() == TicketStatus.OPEN) {
@@ -299,7 +322,6 @@ public class TicketGUI implements Listener {
// ── Spieler-GUI ──────────────────────────────────────────────────── // ── Spieler-GUI ────────────────────────────────────────────────────
if (title.equals(PLAYER_GUI_TITLE)) { if (title.equals(PLAYER_GUI_TITLE)) {
// Navigationstasten
int curPage = playerPage.getOrDefault(player.getUniqueId(), 0); int curPage = playerPage.getOrDefault(player.getUniqueId(), 0);
if (slot == 45) { openPlayerGUI(player, curPage - 1); return; } if (slot == 45) { openPlayerGUI(player, curPage - 1); return; }
if (slot == 53) { openPlayerGUI(player, curPage + 1); return; } if (slot == 53) { openPlayerGUI(player, curPage + 1); return; }
@@ -355,19 +377,16 @@ public class TicketGUI implements Listener {
// ─────────────────────────── Navigation-Handler ───────────────────────── // ─────────────────────────── Navigation-Handler ─────────────────────────
/**
* Verarbeitet Klicks auf die Navigationsleiste der Admin-Übersicht (Slots 4553).
*/
private void handleAdminNavClick(Player player, int slot, boolean isArchive) { private void handleAdminNavClick(Player player, int slot, boolean isArchive) {
int curPage = adminPage.getOrDefault(player.getUniqueId(), 0); int curPage = adminPage.getOrDefault(player.getUniqueId(), 0);
switch (slot) { switch (slot) {
case 45 -> openGUI(player, curPage - 1); // Zurück case 45 -> openGUI(player, curPage - 1);
case 53 -> openGUI(player, curPage + 1); // Vor case 53 -> openGUI(player, curPage + 1);
case 49 -> { // Archiv-Button oder Zurück im Archiv case 49 -> {
if (player.hasPermission(ARCHIVE_PERMISSION)) openClosedGUI(player); if (player.hasPermission(ARCHIVE_PERMISSION)) openClosedGUI(player);
else player.sendMessage(plugin.color("&cDu hast keine Berechtigung, das Archiv zu öffnen.")); 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)) { if (plugin.getConfig().getBoolean("categories-enabled", true)) {
cycleCategoryFilter(player); cycleCategoryFilter(player);
openGUI(player, 0); openGUI(player, 0);
@@ -381,11 +400,10 @@ public class TicketGUI implements Listener {
switch (slot) { switch (slot) {
case 45 -> openClosedGUI(player, curPage - 1); case 45 -> openClosedGUI(player, curPage - 1);
case 53 -> 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) { private void cycleCategoryFilter(Player player) {
CategoryManager cm = plugin.getCategoryManager(); CategoryManager cm = plugin.getCategoryManager();
List<ConfigCategory> all = cm.getAll(); List<ConfigCategory> all = cm.getAll();
@@ -396,7 +414,7 @@ public class TicketGUI implements Listener {
} else { } else {
int idx = all.indexOf(current); int idx = all.indexOf(current);
int next = idx + 1; 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)); else categoryFilter.put(player.getUniqueId(), all.get(next));
} }
ConfigCategory newFilter = categoryFilter.getOrDefault(player.getUniqueId(), null); 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) { private void handleDetailTeleport(Player player, Ticket ticket) {
if (ticket.getLocation() != null) { if (!plugin.isBungeeCordEnabled()) {
player.teleport(ticket.getLocation()); // ── Standalone-Modus: direkt teleportieren ──
player.sendMessage(plugin.color("&7Du wurdest zu Ticket &e#" + ticket.getId() + " &7teleportiert.")); 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 { } 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) { private void handleDetailClaim(Player player, Ticket ticket) {
if (ticket.getStatus() != TicketStatus.OPEN) { if (ticket.getStatus() != TicketStatus.OPEN) {
player.sendMessage(plugin.formatMessage("messages.already-claimed")); player.sendMessage(plugin.formatMessage("messages.already-claimed"));
@@ -440,11 +540,9 @@ public class TicketGUI implements Listener {
ticket.setClaimerUUID(player.getUniqueId()); ticket.setClaimerUUID(player.getUniqueId());
ticket.setClaimerName(player.getName()); ticket.setClaimerName(player.getName());
plugin.getTicketManager().notifyCreatorClaimed(ticket); plugin.getTicketManager().notifyCreatorClaimed(ticket);
if (ticket.getLocation() != null) player.teleport(ticket.getLocation());
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { // Teleport nach dem Claim gleiche Logik wie handleDetailTeleport
Ticket fresh = plugin.getDatabaseManager().getTicketById(ticket.getId()); handleDetailTeleport(player, ticket);
Bukkit.getScheduler().runTask(plugin, () -> { if (fresh != null) openDetailGUI(player, fresh); });
});
}); });
}); });
} }
@@ -513,7 +611,6 @@ public class TicketGUI implements Listener {
}); });
} }
private void handleShowComments(Player player, Ticket ticket) { private void handleShowComments(Player player, Ticket ticket) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
List<TicketComment> comments = plugin.getDatabaseManager().getComments(ticket.getId()); List<TicketComment> comments = plugin.getDatabaseManager().getComments(ticket.getId());
@@ -529,7 +626,6 @@ public class TicketGUI implements Listener {
} }
} }
player.sendMessage(plugin.color("&8&m ")); player.sendMessage(plugin.color("&8&m "));
// Gleich wieder Detail-GUI öffnen
openDetailGUI(player, ticket); openDetailGUI(player, ticket);
}); });
}); });
@@ -552,10 +648,21 @@ public class TicketGUI implements Listener {
} }
final String comment = input.equals("-") ? "" : input; final String comment = input.equals("-") ? "" : input;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
boolean success = plugin.getDatabaseManager().closeTicket(ticketId, comment); boolean success = plugin.getDatabaseManager().closeTicket(ticketId, comment);
if (success) { if (success) {
Ticket ticket = plugin.getDatabaseManager().getTicketById(ticketId); 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, () -> { Bukkit.getScheduler().runTask(plugin, () -> {
player.sendMessage(plugin.formatMessage("messages.ticket-closed").replace("{id}", String.valueOf(ticketId))); player.sendMessage(plugin.formatMessage("messages.ticket-closed").replace("{id}", String.valueOf(ticketId)));
if (!comment.isEmpty()) player.sendMessage(plugin.color("&7Kommentar: &f" + comment)); if (!comment.isEmpty()) player.sendMessage(plugin.color("&7Kommentar: &f" + comment));
@@ -570,33 +677,24 @@ public class TicketGUI implements Listener {
// ─────────────────────────── Item-Builder ────────────────────────────── // ─────────────────────────── Item-Builder ──────────────────────────────
/**
* Füllt die Navigationsleiste (letzte Reihe, Slots 4553).
* 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) { private void fillAdminNavigation(Inventory inv, boolean isArchiveView, Player player, int page, int totalPages) {
ItemStack glass = makeGlass(); ItemStack glass = makeGlass();
for (int i = 45; i < 54; i++) inv.setItem(i, glass); for (int i = 45; i < 54; i++) inv.setItem(i, glass);
// Zurück (Slot 45)
if (page > 0) { if (page > 0) {
inv.setItem(45, buildActionItem(Material.ARROW, "§7§l◄ Zurück", inv.setItem(45, buildActionItem(Material.ARROW, "§7§l◄ Zurück",
List.of("§7Seite " + page + " von " + totalPages))); List.of("§7Seite " + page + " von " + totalPages)));
} }
// Weiter (Slot 53)
if (page < totalPages - 1) { if (page < totalPages - 1) {
inv.setItem(53, buildActionItem(Material.ARROW, "§7§lWeiter ►", inv.setItem(53, buildActionItem(Material.ARROW, "§7§lWeiter ►",
List.of("§7Seite " + (page + 2) + " von " + totalPages))); List.of("§7Seite " + (page + 2) + " von " + totalPages)));
} }
// Seitenanzeige (Slot 49)
if (!isArchiveView) { if (!isArchiveView) {
if (player.hasPermission(ARCHIVE_PERMISSION)) { if (player.hasPermission(ARCHIVE_PERMISSION)) {
inv.setItem(49, buildActionItem(Material.CHEST, "§7§lGeschlossene Tickets", inv.setItem(49, buildActionItem(Material.CHEST, "§7§lGeschlossene Tickets",
List.of("§7Zeigt alle abgeschlossenen", "§7Tickets im Archiv an."))); List.of("§7Zeigt alle abgeschlossenen", "§7Tickets im Archiv an.")));
} }
// Kategorie-Filter (Slot 47), nur wenn aktiviert
if (plugin.getConfig().getBoolean("categories-enabled", true)) { if (plugin.getConfig().getBoolean("categories-enabled", true)) {
ConfigCategory currentFilter = categoryFilter.getOrDefault(player.getUniqueId(), null); ConfigCategory currentFilter = categoryFilter.getOrDefault(player.getUniqueId(), null);
String filterLabel = currentFilter != null ? currentFilter.getColored() : "§7Alle"; 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)); inv.setItem(47, buildActionItem(Material.HOPPER, "§e§lKategorie-Filter", filterLore));
} }
} else { } else {
// Im Archiv: Zurück-Button in Slot 49
inv.setItem(49, buildActionItem(Material.ARROW, "§7§lZurück zur Übersicht", inv.setItem(49, buildActionItem(Material.ARROW, "§7§lZurück zur Übersicht",
List.of("§7Zeigt alle offenen Tickets."))); List.of("§7Zeigt alle offenen Tickets.")));
} }
// Seitenanzeige Mitte oben (Slot 48)
inv.setItem(48, buildActionItem(Material.PAPER, "§8Seite " + (page + 1) + "/" + totalPages, inv.setItem(48, buildActionItem(Material.PAPER, "§8Seite " + (page + 1) + "/" + totalPages,
List.of("§7Gesamt: " + (playerSlotMap.containsKey(player.getUniqueId()) List.of("§7Gesamt: " + (playerSlotMap.containsKey(player.getUniqueId())
? playerSlotMap.get(player.getUniqueId()).size() + "+" : "?") + " Tickets auf dieser Seite"))); ? 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())); inv.setItem(49, buildActionItem(Material.PAPER, "§8Seite " + (page + 1) + "/" + totalPages, List.of()));
} }
// ─────────────────────────── Item-Builder ──────────────────────────────
private ItemStack buildAdminListItem(Ticket ticket) { 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; Material mat;
if (plugin.getConfig().getBoolean("categories-enabled", true)) { if (plugin.getConfig().getBoolean("categories-enabled", true)) {
mat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey()).getMaterial(); mat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey()).getMaterial();
@@ -651,7 +743,6 @@ public class TicketGUI implements Listener {
ItemMeta meta = item.getItemMeta(); ItemMeta meta = item.getItemMeta();
if (meta == null) return item; if (meta == null) return item;
// Priorität farblich im Titel anzeigen (wenn aktiviert)
String priorityPrefix = plugin.getConfig().getBoolean("priorities-enabled", true) String priorityPrefix = plugin.getConfig().getBoolean("priorities-enabled", true)
? ticket.getPriority().getColored() + " §8| " : ""; ? ticket.getPriority().getColored() + " §8| " : "";
meta.setDisplayName(priorityPrefix + "§6§lTicket #" + ticket.getId() + " §r" + ticket.getStatus().getColored()); 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("§7Ersteller: §e" + ticket.getCreatorName());
lore.add("§7Anliegen: §f" + ticket.getMessage()); lore.add("§7Anliegen: §f" + ticket.getMessage());
lore.add("§7Erstellt: §e" + DATE_FORMAT.format(ticket.getCreatedAt())); 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("§7Welt: §e" + ticket.getWorldName());
lore.add(String.format("§7Position: §e%.0f, %.0f, %.0f", ticket.getX(), ticket.getY(), ticket.getZ())); lore.add(String.format("§7Position: §e%.0f, %.0f, %.0f", ticket.getX(), ticket.getY(), ticket.getZ()));
if (plugin.getConfig().getBoolean("categories-enabled", true)) { if (plugin.getConfig().getBoolean("categories-enabled", true)) {
@@ -739,6 +833,9 @@ public class TicketGUI implements Listener {
lore.add("§8§m "); lore.add("§8§m ");
lore.add("§7Anliegen: §f" + ticket.getMessage()); lore.add("§7Anliegen: §f" + ticket.getMessage());
lore.add("§7Erstellt: §e" + DATE_FORMAT.format(ticket.getCreatedAt())); 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("§7Welt: §e" + ticket.getWorldName());
lore.add(String.format("§7Position: §e%.0f, %.0f, %.0f", ticket.getX(), ticket.getY(), ticket.getZ())); lore.add(String.format("§7Position: §e%.0f, %.0f, %.0f", ticket.getX(), ticket.getY(), ticket.getZ()));
if (plugin.getConfig().getBoolean("categories-enabled", true)) { if (plugin.getConfig().getBoolean("categories-enabled", true)) {

View File

@@ -3,10 +3,13 @@ package de.ticketsystem.listeners;
import java.util.List; import java.util.List;
import de.ticketsystem.TicketPlugin; import de.ticketsystem.TicketPlugin;
import de.ticketsystem.database.DatabaseManager;
import de.ticketsystem.model.Ticket; import de.ticketsystem.model.Ticket;
import de.ticketsystem.model.TicketStatus; import de.ticketsystem.model.TicketStatus;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener; 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 ──── // ── Ausstehende Kommentar-/Schließ-Benachrichtigungen anzeigen ────
// (Nachrichten die ankamen während der Spieler offline war) // (Nachrichten die ankamen während der Spieler offline war)
Bukkit.getScheduler().runTaskLater(plugin, () -> { Bukkit.getScheduler().runTaskLater(plugin, () -> {
@@ -66,15 +100,17 @@ public class PlayerJoinListener implements Listener {
plugin.getTicketManager().notifyClaimedWhileOffline(player); plugin.getTicketManager().notifyClaimedWhileOffline(player);
}, 60L); }, 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, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
List<Ticket> closed = plugin.getDatabaseManager() List<Ticket> closed = plugin.getDatabaseManager()
.getTicketsByStatus(TicketStatus.CLOSED); .getTicketsByStatus(TicketStatus.CLOSED);
for (Ticket t : closed) { for (Ticket t : closed) {
if (!t.getCreatorUUID().equals(player.getUniqueId())) continue; if (!t.getCreatorUUID().equals(player.getUniqueId())) continue;
if (t.getCloseComment() == null || t.getCloseComment().isEmpty()) continue; // DB-Feld prüfen funktioniert serverübergreifend
if (plugin.getTicketManager().wasClosedNotificationSent(t.getId())) continue; if (t.isCloseNotified()) continue;
Bukkit.getScheduler().runTask(plugin, () -> Bukkit.getScheduler().runTask(plugin, () ->
plugin.getTicketManager().notifyCreatorClosed(t)); plugin.getTicketManager().notifyCreatorClosed(t));

View File

@@ -8,9 +8,7 @@ import org.bukkit.Bukkit;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
public class TicketManager { public class TicketManager {
@@ -20,9 +18,6 @@ public class TicketManager {
/** Cooldown Map: UUID → Zeitstempel letztes Ticket */ /** Cooldown Map: UUID → Zeitstempel letztes Ticket */
private final Map<UUID, Long> cooldowns = new HashMap<>(); private final Map<UUID, Long> cooldowns = new HashMap<>();
/** Ticket-IDs für die der Ersteller bereits über Schließung informiert wurde */
private final Set<Integer> notifiedClosedTickets = new HashSet<>();
public TicketManager(TicketPlugin plugin) { public TicketManager(TicketPlugin plugin) {
this.plugin = plugin; this.plugin = plugin;
} }
@@ -46,34 +41,55 @@ public class TicketManager {
// ─────────────────────────── Benachrichtigungen ──────────────────────── // ─────────────────────────── Benachrichtigungen ────────────────────────
/** /**
* Benachrichtigt alle Online-Supporter/Admins über ein neues Ticket * Benachrichtigt alle Supporter/Admins über ein neues Ticket auch auf anderen Servern.
* und sendet optional eine Discord-Webhook-Nachricht. *
* 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) { public void notifyTeam(Ticket ticket) {
String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unbekannt"; String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unbekannt";
String message = ticket.getMessage() != null ? ticket.getMessage() : ""; String message = ticket.getMessage() != null ? ticket.getMessage() : "";
// Kategorie & Priorität optional anzeigen // Kategorie & Priorität optional anzeigen
String categoryInfo = ""; String categoryInfo = "";
String priorityInfo = ""; String priorityInfo = "";
if (plugin.getConfig().getBoolean("categories-enabled", true)) { 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]"; categoryInfo = " §7[§r" + cat.getColored() + "§7]";
} }
if (plugin.getConfig().getBoolean("priorities-enabled", true)) { if (plugin.getConfig().getBoolean("priorities-enabled", true)) {
priorityInfo = " §7Priorität: §r" + ticket.getPriority().getColored(); 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") String msg = plugin.formatMessage("messages.new-ticket-notify")
.replace("{player}", creatorName) .replace("{player}", creatorName)
.replace("{message}", message) .replace("{message}", message)
.replace("{id}", String.valueOf(ticket.getId())) .replace("{id}", String.valueOf(ticket.getId()))
+ categoryInfo + priorityInfo; + categoryInfo + priorityInfo + serverInfo;
for (Player p : Bukkit.getOnlinePlayers()) { String guiHint = plugin.color("&7» Klicke &e/ticket list &7um die GUI zu öffnen.");
if (p.hasPermission("ticket.support") || p.hasPermission("ticket.admin")) {
p.sendMessage(msg); if (plugin.isBungeeCordEnabled()) {
p.sendMessage(plugin.color("&7» Klicke &e/ticket list &7um die GUI zu öffnen.")); // ─ 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. * Benachrichtigt den Ersteller, wenn sein Ticket angenommen wurde.
* Setzt claimer_notified = true und persistiert es. * Setzt claimer_notified = true und persistiert es.
*
* BungeeCord: Zustellung auch wenn der Spieler auf einem anderen Server ist.
*/ */
public void notifyCreatorClaimed(Ticket ticket) { public void notifyCreatorClaimed(Ticket ticket) {
Player creator = Bukkit.getPlayer(ticket.getCreatorUUID()); String claimerName = resolveClaimerName(ticket);
if (creator != null && creator.isOnline()) {
String claimerName = ticket.getClaimerName(); String msg = plugin.formatMessage("messages.ticket-claimed-notify")
if (claimerName == null && ticket.getClaimerUUID() != null) .replace("{id}", String.valueOf(ticket.getId()))
claimerName = Bukkit.getOfflinePlayer(ticket.getClaimerUUID()).getName(); .replace("{claimer}", claimerName);
if (claimerName == null) claimerName = "Support";
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 // Persistiert setzen, damit Join-Listener weiß, dass Spieler bereits informiert ist
plugin.getDatabaseManager().markClaimerNotified(ticket.getId()); plugin.getDatabaseManager().markClaimerNotified(ticket.getId());
} }
@@ -106,15 +120,13 @@ public class TicketManager {
* die geclaimt oder weitergeleitet wurden während er offline war. * die geclaimt oder weitergeleitet wurden während er offline war.
*/ */
public void notifyClaimedWhileOffline(Player player) { public void notifyClaimedWhileOffline(Player player) {
// Suche alle Tickets dieses Spielers, die CLAIMED/FORWARDED sind,
// aber noch nicht notified wurden
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
var tickets = plugin.getDatabaseManager().getTicketsByStatus( var tickets = plugin.getDatabaseManager().getTicketsByStatus(
TicketStatus.CLAIMED, TicketStatus.FORWARDED); TicketStatus.CLAIMED, TicketStatus.FORWARDED);
for (Ticket t : tickets) { for (Ticket t : tickets) {
if (!t.getCreatorUUID().equals(player.getUniqueId())) continue; 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"; String claimerName = t.getClaimerName() != null ? t.getClaimerName() : "Support";
final String name = claimerName; final String name = claimerName;
@@ -142,81 +154,190 @@ public class TicketManager {
/** /**
* Benachrichtigt den Ersteller, wenn sein Ticket weitergeleitet wurde. * Benachrichtigt den Ersteller, wenn sein Ticket weitergeleitet wurde.
* BungeeCord: Cross-Server-Zustellung.
*/ */
public void notifyCreatorForwarded(Ticket ticket) { public void notifyCreatorForwarded(Ticket ticket) {
Player creator = Bukkit.getPlayer(ticket.getCreatorUUID()); String forwardedTo = ticket.getForwardedToName() != null ? ticket.getForwardedToName() : "einen Supporter";
if (creator != null && creator.isOnline()) { String msg = plugin.formatMessage("messages.ticket-forwarded-creator-notify")
String forwardedTo = ticket.getForwardedToName() != null ? ticket.getForwardedToName() : "einen Supporter"; .replace("{id}", String.valueOf(ticket.getId()))
String msg = plugin.formatMessage("messages.ticket-forwarded-creator-notify") .replace("{supporter}", forwardedTo);
.replace("{id}", String.valueOf(ticket.getId()))
.replace("{supporter}", forwardedTo); deliverToPlayer(ticket.getCreatorUUID(), ticket.getCreatorName(), msg);
creator.sendMessage(msg);
} // Auch bei Weiterleitung notified setzen
// Auch hier notified setzen
plugin.getDatabaseManager().markClaimerNotified(ticket.getId()); plugin.getDatabaseManager().markClaimerNotified(ticket.getId());
} }
/** /**
* Sendet dem weitergeleiteten Supporter eine Benachrichtigung. * Sendet dem weitergeleiteten Supporter eine Benachrichtigung.
* BungeeCord: Zustellung auch wenn der Supporter auf einem anderen Server ist.
*/ */
public void notifyForwardedTo(Ticket ticket, String fromName) { public void notifyForwardedTo(Ticket ticket, String fromName) {
Player target = Bukkit.getPlayer(ticket.getForwardedToUUID()); if (ticket.getForwardedToUUID() == null) return;
if (target != null && target.isOnline()) {
String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unbekannt"; String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unbekannt";
String msg = plugin.formatMessage("messages.ticket-forwarded-notify") String msg = plugin.formatMessage("messages.ticket-forwarded-notify")
.replace("{player}", creatorName) .replace("{player}", creatorName)
.replace("{id}", String.valueOf(ticket.getId())); .replace("{id}", String.valueOf(ticket.getId()));
target.sendMessage(msg);
} deliverToPlayer(ticket.getForwardedToUUID(), ticket.getForwardedToName(), msg);
plugin.getDiscordWebhook().sendTicketForwarded(ticket, fromName); plugin.getDiscordWebhook().sendTicketForwarded(ticket, fromName);
} }
/** /**
* Benachrichtigt den Ersteller, wenn sein Ticket geschlossen wurde. * 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) { notifyCreatorClosed(ticket, null); }
public void notifyCreatorClosed(Ticket ticket, String closerName) { 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()) String comment = (ticket.getCloseComment() != null && !ticket.getCloseComment().isEmpty())
? ticket.getCloseComment() : ""; ? 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()) { if (creator != null && creator.isOnline()) {
String msg = plugin.formatMessage("messages.ticket-closed-notify") // ─ Lokal online: direkt zustellen ────────────────────────────
.replace("{id}", String.valueOf(ticket.getId()))
.replace("{comment}", comment);
creator.sendMessage(msg); creator.sendMessage(msg);
if (!comment.isEmpty()) if (!comment.isEmpty())
creator.sendMessage(plugin.color("&7Kommentar des Supports: &f" + comment)); creator.sendMessage(plugin.color("&7Kommentar des Supports: &f" + comment));
if (plugin.getConfig().getBoolean("rating-enabled", true)) { if (ratingMsg != null) creator.sendMessage(ratingMsg);
creator.sendMessage(plugin.color("&8&m "));
creator.sendMessage(plugin.color("&6Wie zufrieden bist du mit dem Support?")); } else if (plugin.isBungeeCordEnabled()) {
creator.sendMessage(plugin.color("&a/ticket rate " + ticket.getId() + " good &7 👍 Gut")); // ─ BungeeCord: via Plugin-Messaging auf anderen Servern zustellen ─
creator.sendMessage(plugin.color("&c/ticket rate " + ticket.getId() + " bad &7 👎 Schlecht")); // KEIN savePendingClosedNotification hier! Das würde bei Server-Wechsel
creator.sendMessage(plugin.color("&8&m ")); // 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 { } else {
// Offline → ausstehende Benachrichtigung speichern // ─ Standalone, Spieler offline: in Pending-DB speichern ──────
String pendingMsg = "&e[Ticket #" + ticket.getId() + "] &7Dein Ticket wurde geschlossen." savePendingClosedNotification(ticket, comment);
+ (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));
} }
String closer = closerName != null ? closerName : "Unbekannt"; String closer = closerName != null ? closerName : "Unbekannt";
plugin.getDiscordWebhook().sendTicketClosed(ticket, closer); 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) { 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 ───────────────────────────── // ─────────────────────────── 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) { public boolean hasReachedTicketLimit(UUID uuid) {
int max = plugin.getConfig().getInt("max-open-tickets-per-player", 2); int max = plugin.getConfig().getInt("max-open-tickets-per-player", 2);
if (max <= 0) return false; 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("&e/ticket stats &7 Statistiken anzeigen"));
} }
player.sendMessage(plugin.color("&8&m ")); 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"));
}
} }
} }

View File

@@ -25,6 +25,13 @@ public class Ticket implements ConfigurationSerializable {
private double x, y, z; private double x, y, z;
private float yaw, pitch; 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 TicketStatus status;
private UUID claimerUUID; private UUID claimerUUID;
private String claimerName; private String claimerName;
@@ -46,6 +53,12 @@ public class Ticket implements ConfigurationSerializable {
private String playerRating = null; private String playerRating = null;
private boolean claimerNotified = false; 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() {}
public Ticket(UUID creatorUUID, String creatorName, String message, Location location) { 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("priority")) this.priority = TicketPriority.fromString((String) map.get("priority"));
if (map.containsKey("playerRating")) this.playerRating = (String) map.get("playerRating"); if (map.containsKey("playerRating")) this.playerRating = (String) map.get("playerRating");
if (map.containsKey("claimerNotified")) this.claimerNotified = (boolean) map.get("claimerNotified"); 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 @Override
@@ -112,6 +128,9 @@ public class Ticket implements ConfigurationSerializable {
map.put("priority", priority.name()); map.put("priority", priority.name());
if (playerRating != null) map.put("playerRating", playerRating); if (playerRating != null) map.put("playerRating", playerRating);
map.put("claimerNotified", claimerNotified); map.put("claimerNotified", claimerNotified);
// BungeeCord: Server-Name speichern
map.put("serverName", serverName);
map.put("closeNotified", closeNotified);
return map; 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 float toFloat(Object o) { return o instanceof Float f ? f : ((Number) o).floatValue(); }
private static long toLong(Object o) { return ((Number) o).longValue(); } private static long toLong(Object o) { return ((Number) o).longValue(); }
// ─────────────────────────── Getter & Setter ───────────────────────────
public int getId() { return id; } public int getId() { return id; }
public void setId(int id) { this.id = id; } public void setId(int id) { this.id = id; }
public UUID getCreatorUUID() { return creatorUUID; } public UUID getCreatorUUID() { return creatorUUID; }
@@ -175,4 +196,14 @@ public class Ticket implements ConfigurationSerializable {
public boolean hasRating() { return playerRating != null; } public boolean hasRating() { return playerRating != null; }
public boolean isClaimerNotified() { return claimerNotified; } public boolean isClaimerNotified() { return claimerNotified; }
public void setClaimerNotified(boolean v) { this.claimerNotified = v; } 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; }
} }

View File

@@ -17,6 +17,26 @@ version: "2.0"
# Debug-Modus (true = Logs in der Konsole) # Debug-Modus (true = Logs in der Konsole)
debug: false 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 # SPEICHERPFAD & ARCHIV
# ---------------------------------------------------- # ----------------------------------------------------
@@ -156,6 +176,7 @@ discord:
show-position: true # Welt & Koordinaten im Embed anzeigen show-position: true # Welt & Koordinaten im Embed anzeigen
show-category: true # Kategorie im Embed anzeigen show-category: true # Kategorie im Embed anzeigen
show-priority: true # Priorität 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 role-ping: false # Rollen-Ping bei neuem Ticket senden
# ── Ticket geschlossen ────────────────────────────────────────────────── # ── Ticket geschlossen ──────────────────────────────────────────────────
@@ -166,6 +187,7 @@ discord:
footer: "TicketSystem" footer: "TicketSystem"
show-category: true # Kategorie im Embed anzeigen show-category: true # Kategorie im Embed anzeigen
show-priority: true # Priorität 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 role-ping: false # Rollen-Ping beim Schließen senden
# ── Ticket weitergeleitet ─────────────────────────────────────────────── # ── Ticket weitergeleitet ───────────────────────────────────────────────
@@ -176,6 +198,7 @@ discord:
footer: "TicketSystem" footer: "TicketSystem"
show-category: true # Kategorie im Embed anzeigen show-category: true # Kategorie im Embed anzeigen
show-priority: true # Priorität 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 role-ping: false # Rollen-Ping beim Weiterleiten senden
# ---------------------------------------------------- # ----------------------------------------------------

View File

@@ -5,6 +5,12 @@ api-version: 1.20
author: M_Viper author: M_Viper
description: Ingame Support Ticket System with MySQL description: Ingame Support Ticket System with MySQL
# ── BungeeCord Plugin-Messaging-Kanäle ───────────────────────────────────────
# PFLICHTFELD für Cross-Server-Benachrichtigungen!
channels:
- BungeeCord
- ticketsystem:notify
commands: commands:
ticket: ticket:
description: TicketSystem Hauptbefehl description: TicketSystem Hauptbefehl