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;
import de.ticketsystem.bungee.BungeeMessenger;
import de.ticketsystem.commands.TicketCommand;
import de.ticketsystem.database.DatabaseManager;
import de.ticketsystem.discord.DiscordWebhook;
@@ -18,11 +19,20 @@ public class TicketPlugin extends JavaPlugin {
private static TicketPlugin instance;
private boolean debug;
/**
* Name dieses Servers im BungeeCord-Netzwerk.
* Konfigurierbar in config.yml → server-name
* Wird in Tickets gespeichert und in Benachrichtigungen angezeigt.
*/
private String serverName;
private DatabaseManager databaseManager;
private TicketManager ticketManager;
private CategoryManager categoryManager;
private TicketGUI ticketGUI;
private DiscordWebhook discordWebhook;
private BungeeMessenger bungeeMessenger;
@Override
public void onEnable() {
@@ -33,6 +43,33 @@ public class TicketPlugin extends JavaPlugin {
// Ticket-Klasse für YAML-Serialisierung registrieren
Ticket.register();
// ── BungeeCord Plugin-Messaging-Kanäle registrieren ───────────────
// Ausgehend: BungeeCord-Standardkanal (für Forward / Message)
getServer().getMessenger().registerOutgoingPluginChannel(this, BungeeMessenger.BUNGEE_CHANNEL);
// Eingehend & Ausgehend: Eigener Kanal für Team- und Spielerbenachrichtigungen
getServer().getMessenger().registerOutgoingPluginChannel(this, BungeeMessenger.CUSTOM_CHANNEL);
bungeeMessenger = new BungeeMessenger(this);
getServer().getMessenger().registerIncomingPluginChannel(this, BungeeMessenger.CUSTOM_CHANNEL, bungeeMessenger);
// Server-Name aus Config lesen
serverName = getConfig().getString("server-name", "unknown");
if ("unknown".equals(serverName)) {
getLogger().warning("[BungeeCord] Kein 'server-name' in der config.yml definiert! " +
"Setze 'server-name: dein-server' für korrekte Cross-Server-Anzeige.");
} else {
getLogger().info("[BungeeCord] Server-Name: §e" + serverName);
}
// BungeeCord-Hinweis prüfen
if (!getConfig().getBoolean("bungeecord", false)) {
getLogger().info("[BungeeCord] Hinweis: Cross-Server-Features sind deaktiviert. " +
"Setze 'bungeecord: true' in der config.yml und stelle sicher, " +
"dass 'bungeecord: true' auch in spigot.yml gesetzt ist.");
} else {
getLogger().info("[BungeeCord] Cross-Server-Benachrichtigungen aktiviert.");
}
// Update-Checker
int resourceId = 132757;
new UpdateChecker(this, resourceId).getVersion(version -> {
@@ -108,6 +145,10 @@ public class TicketPlugin extends JavaPlugin {
@Override
public void onDisable() {
// Plugin-Messaging-Kanäle abmelden
getServer().getMessenger().unregisterOutgoingPluginChannel(this);
getServer().getMessenger().unregisterIncomingPluginChannel(this);
if (databaseManager != null) databaseManager.disconnect();
getLogger().info("TicketSystem wurde deaktiviert.");
}
@@ -132,5 +173,18 @@ public class TicketPlugin extends JavaPlugin {
public CategoryManager getCategoryManager() { return categoryManager; }
public TicketGUI getTicketGUI() { return ticketGUI; }
public DiscordWebhook getDiscordWebhook() { return discordWebhook; }
public BungeeMessenger getBungeeMessenger() { return bungeeMessenger; }
public boolean isDebug() { return debug; }
/**
* BungeeCord: Gibt den konfigurierten Server-Namen zurück.
* Entspricht dem Wert aus config.yml → server-name.
*/
public String getServerName() { return serverName; }
/**
* BungeeCord: Gibt zurück ob Cross-Server-Features aktiviert sind.
* Entspricht config.yml → bungeecord: true
*/
public boolean isBungeeCordEnabled() { return getConfig().getBoolean("bungeecord", false); }
}

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);
if (args.length >= 3) {
// args[1]: erst als Kategorie prüfen, dann als Priorität
if (categoriesOn) {
ConfigCategory parsedCat = cm.resolve(args[1]);
if (parsedCat != null) {
category = parsedCat;
messageStartIndex = 2;
// args[2]: Priorität prüfen (nur wenn danach noch Text kommt)
if (prioritiesOn && args.length >= 4) {
TicketPriority parsedPrio = parsePriority(args[2]);
if (parsedPrio != null) {
@@ -115,7 +113,6 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
}
}
} else {
// Keine Kategorie erkannt → args[1] als Priorität prüfen
if (prioritiesOn) {
TicketPriority parsedPrio = parsePriority(args[1]);
if (parsedPrio != null) {
@@ -125,16 +122,12 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
}
}
} else if (prioritiesOn) {
// Kategorien aus → args[1] direkt als Priorität prüfen
TicketPriority parsedPrio = parsePriority(args[1]);
if (parsedPrio != null) {
priority = parsedPrio;
messageStartIndex = 2;
}
}
} else if (args.length == 2) {
// Nur ein Argument: könnte Kategorie oder Priorität sein, aber kein Text danach
// → einfach als Beschreibung behandeln, nichts parsen
}
String message = String.join(" ", Arrays.copyOfRange(args, messageStartIndex, args.length));
@@ -153,6 +146,8 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
Ticket ticket = new Ticket(player.getUniqueId(), player.getName(), message, player.getLocation());
ticket.setCategoryKey(finalCategory.getKey());
ticket.setPriority(finalPriority);
// BungeeCord: Server-Name des erstellenden Servers speichern
ticket.setServerName(plugin.getServerName());
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
int id = plugin.getDatabaseManager().createTicket(ticket);
@@ -200,11 +195,31 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
if (!success) { player.sendMessage(plugin.formatMessage("messages.already-claimed")); return; }
Ticket ticket = plugin.getDatabaseManager().getTicketById(ticketId);
if (ticket == null) return;
player.sendMessage(plugin.formatMessage("messages.ticket-claimed")
.replace("{id}", String.valueOf(ticketId))
.replace("{player}", ticket.getCreatorName()));
plugin.getTicketManager().notifyCreatorClaimed(ticket);
if (ticket.getLocation() != null) player.teleport(ticket.getLocation());
// ── BUG FIX #1: Teleportation bei aktivem BungeeCord komplett sperren ──
// Wenn BungeeCord aktiv ist, kann das Ticket von einem anderen Server stammen.
// getLocation() würde null liefern (World existiert lokal nicht) oder den
// Supporter auf dem falschen Server teleportieren.
// Lösung: Bei aktivem BungeeCord generell keinen Teleport durchführen.
if (plugin.isBungeeCordEnabled()) {
// Hinweis: Server anzeigen wenn bekannt, damit Supporter weiß wo das Ticket ist
String serverHint = !"unknown".equals(ticket.getServerName())
? " &7(Server: &b" + ticket.getServerName() + "&7)"
: "";
player.sendMessage(plugin.color("&7Teleportation deaktiviert BungeeCord-Netzwerk aktiv." + serverHint));
} else {
// Standalone-Modus: Normal teleportieren
if (ticket.getLocation() != null) {
player.teleport(ticket.getLocation());
} else {
player.sendMessage(plugin.color("&7Teleportation nicht möglich World nicht gefunden."));
}
}
});
});
}
@@ -228,6 +243,9 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
boolean success = plugin.getDatabaseManager().closeTicket(ticketId, comment);
if (success) {
Ticket ticket = plugin.getDatabaseManager().getTicketById(ticketId);
// Ticket in persistente Stats-Tabelle eintragen (bleibt auch nach Löschung erhalten).
// player.getName() = der Admin der /ticket close ausgeführt hat nicht zwingend der Claimer.
if (ticket != null) plugin.getDatabaseManager().recordClosedTicket(ticket, player.getName());
Bukkit.getScheduler().runTask(plugin, () -> {
player.sendMessage(plugin.formatMessage("messages.ticket-closed").replace("{id}", String.valueOf(ticketId)));
if (ticket != null) {
@@ -252,12 +270,23 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
try { id = Integer.parseInt(args[1]); }
catch (NumberFormatException e) { player.sendMessage(plugin.color("&cUngültige ID!")); return; }
Player target = Bukkit.getPlayer(args[2]);
if (target == null) { player.sendMessage(plugin.color("&cSpieler nicht gefunden!")); return; }
// BungeeCord: Ziel-Spieler lokal suchen
Player localTarget = Bukkit.getPlayer(args[2]);
if (localTarget == null) {
if (plugin.isBungeeCordEnabled()) {
player.sendMessage(plugin.color("&7[BungeeCord] Spieler &e" + args[2]
+ " &7ist auf diesem Server nicht online."));
player.sendMessage(plugin.color("&7Tipp: Forwarden geht nur zu Spielern auf &bdemselben Server&7."));
} else {
player.sendMessage(plugin.color("&cSpieler nicht gefunden!"));
}
return;
}
final int ticketId = id;
final String fromName = player.getName();
final Player t = target;
final Player t = localTarget;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
boolean success = plugin.getDatabaseManager().forwardTicket(ticketId, t.getUniqueId(), t.getName());
@@ -295,7 +324,6 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
Bukkit.getScheduler().runTask(plugin, () -> player.sendMessage(plugin.formatMessage("messages.ticket-not-found")));
return;
}
// Spieler darf nur auf eigene Tickets kommentieren (Supporter/Admin: alle)
boolean isOwner = ticket.getCreatorUUID().equals(player.getUniqueId());
boolean isStaff = player.hasPermission("ticket.support") || player.hasPermission("ticket.admin");
if (!isOwner && !isStaff) {
@@ -309,7 +337,6 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
Bukkit.getScheduler().runTask(plugin, () -> {
if (success) {
player.sendMessage(plugin.color("&aDein Kommentar zu Ticket &e#" + ticketId + " &awurde gespeichert."));
// Supporter/Admin und Ticket-Ersteller benachrichtigen
notifyCommentReceivers(player, ticket, message);
} else {
player.sendMessage(plugin.color("&cFehler beim Speichern des Kommentars."));
@@ -318,46 +345,82 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
});
}
/**
* Benachrichtigt alle relevanten Empfänger über einen neuen Kommentar.
*
* ── BUG FIX #2 ──────────────────────────────────────────────────────────
* Vorher: broadcastTeamNotification() wurde am Ende ZUSÄTZLICH aufgerufen
* obwohl alle lokalen Supporter bereits einzeln per Schleife
* benachrichtigt wurden. Das führte zu:
* a) Doppelter Nachricht für lokale Supporter
* b) broadcastTeamNotification() sendet intern ebenfalls lokal →
* lokale Supporter sahen die Nachricht dreifach
* c) Das Forward-Paket an andere Server war korrekt, aber die
* Empfänger auf anderen Servern sahen auch Duplikate da
* broadcastTeamNotification() wiederum lokal sendet
*
* Fix: broadcastTeamNotification() ERSETZT die lokale Supporter-Schleife
* komplett. Die Methode sendet bereits lokal direkt und forwardet
* gleichzeitig an alle anderen BungeeCord-Server.
* Im Standalone-Modus bleibt die lokale Schleife erhalten.
* ────────────────────────────────────────────────────────────────────────
*/
private void notifyCommentReceivers(Player author, Ticket ticket, String message) {
String onlineMsg = plugin.color("&e[Ticket #" + ticket.getId() + "] &f" + author.getName() + " &7hat kommentiert: &f" + message);
String offlineMsg = "&e[Ticket #" + ticket.getId() + "] &f" + author.getName() + " &7hat kommentiert (während du offline warst): &f" + message;
// Ticket-Ersteller benachrichtigen (wenn nicht der Autor selbst)
// ── 1. Ticket-Ersteller benachrichtigen (wenn nicht der Autor selbst) ──
if (!ticket.getCreatorUUID().equals(author.getUniqueId())) {
Player creator = Bukkit.getPlayer(ticket.getCreatorUUID());
if (creator != null && creator.isOnline()) {
creator.sendMessage(onlineMsg);
} else if (plugin.isBungeeCordEnabled()) {
// BungeeCord: Zustellung via Plugin-Messaging, kein Pending-Eintrag
// (PlayerJoinListener übernimmt Offline-Fallback via close_notified-Logik)
plugin.getBungeeMessenger().sendMessageToPlayer(
ticket.getCreatorUUID(), ticket.getCreatorName(), onlineMsg);
} else {
// Offline → für nächsten Login speichern
// Standalone: Offline → für nächsten Login speichern
Bukkit.getScheduler().runTaskAsynchronously(plugin, () ->
plugin.getDatabaseManager().addPendingNotification(ticket.getCreatorUUID(), offlineMsg));
}
}
// Supporter/Admin benachrichtigen (wenn Autor kein Supporter ist)
// ── 2. Supporter/Admin benachrichtigen (wenn Kommentar vom Spieler kommt) ──
if (!author.hasPermission("ticket.support") && !author.hasPermission("ticket.admin")) {
// Claimer des Tickets bevorzugt benachrichtigen
if (plugin.isBungeeCordEnabled()) {
// BungeeCord-Modus: broadcastTeamNotification() übernimmt ALLES
// lokal direkt + Forward an alle anderen Server in einem Paket.
// KEINE zusätzliche lokale Schleife, da das zu Duplikaten führt.
plugin.getBungeeMessenger().broadcastTeamNotification(onlineMsg);
} else {
// Standalone-Modus: Claimer gezielt benachrichtigen
UUID claimerUUID = ticket.getClaimerUUID();
if (claimerUUID != null && !claimerUUID.equals(author.getUniqueId())) {
Player claimer = Bukkit.getPlayer(claimerUUID);
if (claimer != null && claimer.isOnline()) {
claimer.sendMessage(onlineMsg);
} else {
String claimerOffline = "&e[Ticket #" + ticket.getId() + "] &f" + author.getName() + " &7hat auf dein bearbeitetes Ticket kommentiert (offline): &f" + message;
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()) {
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")) {
p.sendMessage(onlineMsg);
}
}
}
}
}
// ─────────────────────────── /ticket rate ──────────────────────────────
@@ -468,7 +531,6 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
player.sendMessage(plugin.color("&7Keine gesperrten Spieler."));
} else {
for (String[] entry : list) {
// {uuid, name, reason, bannedBy, bannedAt}
player.sendMessage(plugin.color("&e" + entry[1] + " &7 &f" + entry[2]
+ " &7(gesperrt von &e" + entry[3] + "&7)"));
}
@@ -488,6 +550,9 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
plugin.reloadConfig();
plugin.getCategoryManager().reload();
player.sendMessage(plugin.color("&aKonfiguration wurde neu geladen. &7(inkl. Kategorien)"));
if (plugin.isBungeeCordEnabled()) {
player.sendMessage(plugin.color("&8[BungeeCord] &7Server: &b" + plugin.getServerName()));
}
}
// ─────────────────────────── /ticket archive ───────────────────────────
@@ -509,25 +574,54 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
if (!player.hasPermission("ticket.admin")) { player.sendMessage(plugin.formatMessage("messages.no-permission")); return; }
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
var stats = plugin.getDatabaseManager().getTicketStats();
var staffRatings = plugin.getDatabaseManager().getStaffRatings();
Bukkit.getScheduler().runTask(plugin, () -> {
player.sendMessage(plugin.color("&8&m "));
player.sendMessage(plugin.color("&6Ticket Statistik"));
player.sendMessage(plugin.color("&8&m "));
player.sendMessage(plugin.color("&eGesamt: &a" + stats.total));
player.sendMessage(plugin.color("&eOffen: &a" + stats.open));
player.sendMessage(plugin.color("&eGeschlossen: &a" + stats.closed));
player.sendMessage(plugin.color("&eGeschlossen: &a" + stats.closed + " &7(historisch)"));
player.sendMessage(plugin.color("&eWeitergeleitet: &a" + stats.forwarded));
if (plugin.getConfig().getBoolean("rating-enabled", true)) {
player.sendMessage(plugin.color("&8&m "));
player.sendMessage(plugin.color("&6Support-Bewertungen"));
player.sendMessage(plugin.color("&6Support-Bewertungen &7(gesamt, historisch)"));
player.sendMessage(plugin.color("&a👍 Positiv: &f" + stats.thumbsUp
+ " &c👎 Negativ: &f" + stats.thumbsDown));
int total = stats.thumbsUp + stats.thumbsDown;
if (total > 0) {
int percent = (int) Math.round(stats.thumbsUp * 100.0 / total);
int totalRated = stats.thumbsUp + stats.thumbsDown;
if (totalRated > 0) {
int percent = (int) Math.round(stats.thumbsUp * 100.0 / totalRated);
player.sendMessage(plugin.color("&7Zufriedenheit: &e" + percent + "%"));
}
// Bewertungen pro Support-Mitarbeiter
if (!staffRatings.isEmpty()) {
player.sendMessage(plugin.color("&8&m "));
player.sendMessage(plugin.color("&6Bewertungen nach Support-Mitarbeiter:"));
player.sendMessage(plugin.color("&7 Name 👍 👎 Tickets Zufrieden"));
for (String[] row : staffRatings) {
// row: [name, up, down, totalClosed, percent]
String name = String.format("%-16s", row[0]);
String up = String.format("%-5s", row[1]);
String down = String.format("%-5s", row[2]);
String total = String.format("%-8s", row[3]);
String percent = row[4];
player.sendMessage(plugin.color(
"&e " + name + " &a" + up + " &c" + down + " &7" + total + " &e" + percent));
}
}
}
// BungeeCord: Tickets pro Server anzeigen
if (plugin.isBungeeCordEnabled() && !stats.byServer.isEmpty()) {
player.sendMessage(plugin.color("&8&m "));
player.sendMessage(plugin.color("&6Tickets nach Server:"));
stats.byServer.entrySet().stream()
.sorted((a, b) -> b.getValue() - a.getValue())
.forEach(e -> player.sendMessage(plugin.color("&b " + e.getKey() + ": &a" + e.getValue())));
}
player.sendMessage(plugin.color("&8&m "));
player.sendMessage(plugin.color("&6Top Ersteller:"));
stats.byPlayer.entrySet().stream()
@@ -625,8 +719,7 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
});
}
/** Parst Benutzer-Eingaben wie "high", "hoch", "urgent", "dringend" etc. zu TicketPriority.
* Gibt null zurück wenn keine Übereinstimmung. */
/** Parst Benutzer-Eingaben zu TicketPriority. Gibt null zurück wenn keine Übereinstimmung. */
private TicketPriority parsePriority(String input) {
if (input == null) return null;
return switch (input.toLowerCase()) {
@@ -661,14 +754,12 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
&& plugin.getConfig().getBoolean("categories-enabled", true)) {
for (ConfigCategory c : plugin.getCategoryManager().getAll())
if (c.getKey().startsWith(args[1].toLowerCase())) completions.add(c.getKey());
// auch Priorität direkt ohne Kategorie anbieten
if (plugin.getConfig().getBoolean("priorities-enabled", true))
for (String p : List.of("low", "normal", "high", "urgent"))
if (p.startsWith(args[1].toLowerCase())) completions.add(p);
} else if (args.length == 3 && args[0].equalsIgnoreCase("create")
&& plugin.getConfig().getBoolean("priorities-enabled", true)) {
// Priorität nach Kategorie
for (String p : List.of("low", "normal", "high", "urgent"))
if (p.startsWith(args[2].toLowerCase())) completions.add(p);
@@ -677,6 +768,7 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
if (p.startsWith(args[2].toLowerCase())) completions.add(p);
} else if (args.length == 3 && args[0].equalsIgnoreCase("forward")) {
// BungeeCord: Nur lokal online Spieler als Tab-Completion
for (Player p : Bukkit.getOnlinePlayers())
if (p.getName().toLowerCase().startsWith(args[2].toLowerCase())) completions.add(p.getName());

View File

@@ -161,6 +161,7 @@ public class DatabaseManager {
private void createTables() {
// Haupt-Tickets-Tabelle
// BungeeCord: server_name speichert auf welchem Server das Ticket erstellt wurde
String ticketsSql = """
CREATE TABLE IF NOT EXISTS tickets (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -186,7 +187,9 @@ public class DatabaseManager {
category VARCHAR(16) NOT NULL DEFAULT 'GENERAL',
priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL',
player_rating VARCHAR(16) NULL,
claimer_notified BOOLEAN DEFAULT FALSE
claimer_notified BOOLEAN DEFAULT FALSE,
close_notified BOOLEAN DEFAULT FALSE,
server_name VARCHAR(64) NOT NULL DEFAULT 'unknown'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""";
@@ -225,11 +228,50 @@ public class DatabaseManager {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""";
// Persistente Statistik-Tabelle überlebt das Löschen/Archivieren von Tickets.
// Wird beim Schließen eines Tickets befüllt und beim Bewerten aktualisiert.
// So gehen keine Zahlen verloren wenn das Archiv geleert wird.
String statsSql = """
CREATE TABLE IF NOT EXISTS ticket_stats (
id INT AUTO_INCREMENT PRIMARY KEY,
ticket_id INT NOT NULL,
claimer_uuid VARCHAR(36) NULL,
claimer_name VARCHAR(16) NULL,
creator_uuid VARCHAR(36) NOT NULL,
creator_name VARCHAR(16) NOT NULL,
category VARCHAR(16) NOT NULL DEFAULT 'general',
priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL',
server_name VARCHAR(64) NOT NULL DEFAULT 'unknown',
player_rating VARCHAR(16) NULL,
closed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_claimer_uuid (claimer_uuid),
INDEX idx_closed_at (closed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""";
// Ausstehende BungeeCord-Teleport-Aufträge.
// Wird gesetzt wenn ein Admin via GUI/Command auf einen anderen Server teleportiert.
// PlayerJoinListener liest den Eintrag beim Ankommen, teleportiert, löscht ihn dann.
String pendingTeleportSql = """
CREATE TABLE IF NOT EXISTS ticket_pending_teleport (
player_uuid VARCHAR(36) NOT NULL PRIMARY KEY,
world VARCHAR(64) NOT NULL,
x DOUBLE NOT NULL,
y DOUBLE NOT NULL,
z DOUBLE NOT NULL,
yaw FLOAT NOT NULL DEFAULT 0,
pitch FLOAT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""";
try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) {
stmt.execute(ticketsSql);
stmt.execute(commentsSql);
stmt.execute(blacklistSql);
stmt.execute(notifSql);
stmt.execute(statsSql);
stmt.execute(pendingTeleportSql);
} catch (SQLException e) {
plugin.getLogger().log(Level.SEVERE, "Fehler beim Erstellen der Tabellen: " + e.getMessage(), e);
}
@@ -237,6 +279,7 @@ public class DatabaseManager {
/**
* Ergänzt fehlende Spalten in bestehenden Datenbanken automatisch.
* Wichtig für Upgrades von älteren Versionen.
*/
private void ensureColumns() {
ensureColumn("close_comment", "ALTER TABLE tickets ADD COLUMN close_comment VARCHAR(500) NULL");
@@ -245,6 +288,13 @@ public class DatabaseManager {
ensureColumn("priority", "ALTER TABLE tickets ADD COLUMN priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL'");
ensureColumn("player_rating", "ALTER TABLE tickets ADD COLUMN player_rating VARCHAR(16) NULL");
ensureColumn("claimer_notified", "ALTER TABLE tickets ADD COLUMN claimer_notified BOOLEAN DEFAULT FALSE");
// Bug-Fix: close_notified verhindert Duplikat-Discord-Nachrichten und doppelte Offline-Benachrichtigungen bei Server-Wechsel
ensureColumn("close_notified", "ALTER TABLE tickets ADD COLUMN close_notified BOOLEAN DEFAULT FALSE");
// BungeeCord: Server-Name-Spalte für bestehende Datenbanken nachrüsten
ensureColumn("server_name", "ALTER TABLE tickets ADD COLUMN server_name VARCHAR(64) NOT NULL DEFAULT 'unknown'");
// ticket_stats: Spalte player_rating nachrüsten falls Tabelle vor diesem Feature existiert
ensureStatsColumn("player_rating", "ALTER TABLE ticket_stats ADD COLUMN player_rating VARCHAR(16) NULL");
}
private void ensureColumn(String columnName, String alterSql) {
@@ -268,13 +318,36 @@ public class DatabaseManager {
}
}
private void ensureStatsColumn(String columnName, String alterSql) {
String checkSql = """
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ticket_stats'
AND COLUMN_NAME = ?
""";
try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(checkSql)) {
ps.setString(1, columnName);
ResultSet rs = ps.executeQuery();
if (rs.next() && rs.getInt(1) == 0) {
try (Statement stmt = conn.createStatement()) {
stmt.execute(alterSql);
plugin.getLogger().info("[TicketSystem] Stats-Spalte '" + columnName + "' wurde hinzugefügt.");
}
}
} catch (SQLException e) {
plugin.getLogger().log(Level.SEVERE, "Fehler bei ensureStatsColumn(" + columnName + "): " + e.getMessage(), e);
}
}
// ─────────────────────────── CRUD Tickets ──────────────────────────────
public int createTicket(Ticket ticket) {
if (useMySQL) {
// BungeeCord: server_name wird ebenfalls gespeichert
String sql = """
INSERT INTO tickets (creator_uuid, creator_name, message, world, x, y, z, yaw, pitch, category, priority)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO tickets (creator_uuid, creator_name, message, world, x, y, z, yaw, pitch,
category, priority, server_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
@@ -289,6 +362,7 @@ public class DatabaseManager {
ps.setFloat(9, ticket.getPitch());
ps.setString(10, ticket.getCategoryKey());
ps.setString(11, ticket.getPriority().name());
ps.setString(12, ticket.getServerName()); // BungeeCord
ps.executeUpdate();
ResultSet rs = ps.getGeneratedKeys();
if (rs.next()) return rs.getInt(1);
@@ -306,13 +380,21 @@ public class DatabaseManager {
}
}
// ── BUG FIX #1 ──────────────────────────────────────────────────────────
// Vorher: WHERE id = ? AND status = 'OPEN'
// Problem: Ein FORWARDED-Ticket konnte nicht geclaimed werden das UPDATE
// schlug lautlos fehl, claimer_uuid/claimer_name wurden nie geschrieben.
// Fix: WHERE id = ? AND status != 'CLOSED'
// Damit können sowohl OPEN als auch FORWARDED Tickets korrekt
// angenommen werden und claimer_uuid/claimer_name werden immer gesetzt.
// ────────────────────────────────────────────────────────────────────────
public boolean claimTicket(int ticketId, UUID claimerUUID, String claimerName) {
if (useMySQL) {
String sql = """
UPDATE tickets
SET status = 'CLAIMED', claimer_uuid = ?, claimer_name = ?,
claimed_at = NOW(), player_deleted = FALSE
WHERE id = ? AND status = 'OPEN'
WHERE id = ? AND status != 'CLOSED'
""";
try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, claimerUUID.toString());
@@ -325,7 +407,7 @@ public class DatabaseManager {
return false;
} else {
Ticket t = getTicketById(ticketId);
if (t == null || t.getStatus() != TicketStatus.OPEN) return false;
if (t == null || t.getStatus() == TicketStatus.CLOSED) return false;
t.setStatus(TicketStatus.CLAIMED);
t.setClaimerUUID(claimerUUID);
t.setClaimerName(claimerName);
@@ -452,11 +534,8 @@ public class DatabaseManager {
}
}
// ─────────────────────────── [NEW] Claim-Benachrichtigung markieren ────
// ─────────────────────────── Claim-Benachrichtigung markieren ──────────
/**
* Setzt claimer_notified = TRUE für ein Ticket (persistiert in DB/Datei).
*/
public void markClaimerNotified(int ticketId) {
if (useMySQL) {
String sql = "UPDATE tickets SET claimer_notified = TRUE WHERE id = ?";
@@ -476,21 +555,42 @@ public class DatabaseManager {
}
}
// ─────────────────────────── [NEW] Bewertung ───────────────────────────
// ─────────────────────────── Schließ-Benachrichtigung markieren ────────
/**
* Speichert die Bewertung eines Spielers für sein geschlossenes Ticket.
* @param ticketId ID des Tickets
* @param rating "THUMBS_UP" oder "THUMBS_DOWN"
* @return true bei Erfolg
* Setzt close_notified = TRUE für ein Ticket (persistiert in DB/Datei).
* Verhindert Duplikat-Benachrichtigungen und doppelte Discord-Nachrichten
* bei Server-Wechseln in BungeeCord-Netzwerken.
*/
public void markCloseNotified(int ticketId) {
if (useMySQL) {
String sql = "UPDATE tickets SET close_notified = TRUE WHERE id = ?";
try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, ticketId);
ps.executeUpdate();
} catch (SQLException e) {
plugin.getLogger().log(Level.SEVERE, "Fehler bei markCloseNotified: " + e.getMessage(), e);
}
} else {
Ticket t = getTicketById(ticketId);
if (t != null) {
t.setCloseNotified(true);
dataConfig.set("tickets." + ticketId, t);
saveDataConfig();
}
}
}
public boolean rateTicket(int ticketId, String rating) {
if (useMySQL) {
String sql = "UPDATE tickets SET player_rating = ? WHERE id = ? AND status = 'CLOSED' AND player_rating IS NULL";
try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, rating);
ps.setInt(2, ticketId);
return ps.executeUpdate() > 0;
boolean updated = ps.executeUpdate() > 0;
// Bewertung auch in die persistente Stats-Tabelle übertragen
if (updated) updateStatsRating(ticketId, rating);
return updated;
} catch (SQLException e) {
plugin.getLogger().log(Level.SEVERE, "Fehler bei rateTicket: " + e.getMessage(), e);
}
@@ -505,11 +605,191 @@ public class DatabaseManager {
}
}
// ─────────────────────────── [NEW] Kommentare ──────────────────────────
/**
* Schreibt einen Eintrag in ticket_stats wenn ein Ticket geschlossen wird.
* Diese Tabelle bleibt dauerhaft erhalten auch wenn das Ticket später
* gelöscht oder archiviert wird. So gehen Statistiken nie verloren.
*
* @param ticket Das gerade geschlossene Ticket-Objekt
* @param closerName Name des Admins/Supporters der das Ticket geschlossen hat
* (kann vom claimer_name abweichen wenn ein Admin fremde Tickets schließt)
*/
public void recordClosedTicket(Ticket ticket, String closerName) {
if (!useMySQL) return;
// closer_name bevorzugen falls null, auf claimer_name zurückfallen
String effectiveCloser = (closerName != null && !closerName.isEmpty())
? closerName : ticket.getClaimerName();
String effectiveCloserUuid = null;
// UUID nur setzen wenn closer == claimer (sonst haben wir die UUID des Admins nicht direkt)
if (effectiveCloser != null && effectiveCloser.equals(ticket.getClaimerName())
&& ticket.getClaimerUUID() != null) {
effectiveCloserUuid = ticket.getClaimerUUID().toString();
}
String sql = """
INSERT INTO ticket_stats
(ticket_id, claimer_uuid, claimer_name, creator_uuid, creator_name,
category, priority, server_name, player_rating, closed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE closed_at = closed_at
""";
try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, ticket.getId());
ps.setString(2, effectiveCloserUuid);
ps.setString(3, effectiveCloser);
ps.setString(4, ticket.getCreatorUUID().toString());
ps.setString(5, ticket.getCreatorName());
ps.setString(6, ticket.getCategoryKey());
ps.setString(7, ticket.getPriority().name());
ps.setString(8, ticket.getServerName());
ps.setString(9, ticket.getPlayerRating());
ps.executeUpdate();
} catch (SQLException e) {
plugin.getLogger().log(Level.SEVERE, "Fehler bei recordClosedTicket: " + e.getMessage(), e);
}
}
/**
* Speichert einen neuen Kommentar/Reply auf ein Ticket.
* Aktualisiert die Bewertung in ticket_stats wenn ein Spieler sein Ticket bewertet.
* Wird von rateTicket() intern aufgerufen.
*/
private void updateStatsRating(int ticketId, String rating) {
String sql = "UPDATE ticket_stats SET player_rating = ? WHERE ticket_id = ?";
try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, rating);
ps.setInt(2, ticketId);
ps.executeUpdate();
} catch (SQLException e) {
plugin.getLogger().log(Level.SEVERE, "Fehler bei updateStatsRating: " + e.getMessage(), e);
}
}
/**
* Gibt eine Liste aller Support-Mitarbeiter mit ihren Bewertungsstatistiken zurück.
* Liest aus ticket_stats unabhängig davon ob die Tickets noch in der DB existieren.
*
* Rückgabe: Liste von String-Arrays mit
* [0] claimer_name, [1] thumbsUp, [2] thumbsDown, [3] total, [4] prozent
*/
public List<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) {
if (useMySQL) {
String sql = """
@@ -527,7 +807,6 @@ public class DatabaseManager {
}
return false;
} else {
// YAML: comments.<ticketId>.<index>
int index = dataConfig.getInt("comments." + comment.getTicketId() + ".count", 0);
String base = "comments." + comment.getTicketId() + "." + index + ".";
dataConfig.set(base + "authorUUID", comment.getAuthorUUID().toString());
@@ -540,9 +819,6 @@ public class DatabaseManager {
}
}
/**
* Lädt alle Kommentare für ein Ticket, sortiert nach Datum.
*/
public List<TicketComment> getComments(int ticketId) {
List<TicketComment> list = new ArrayList<>();
if (useMySQL) {
@@ -582,12 +858,8 @@ public class DatabaseManager {
return list;
}
// ─────────────────────────── Pending Notifications ────────────────────
// ─────────────────────────── Pending Notifications ────────────────────
/**
* Speichert eine Benachrichtigung für einen offline Spieler.
* Wird beim nächsten Login angezeigt.
*/
public void addPendingNotification(UUID playerUUID, String rawMessage) {
if (useMySQL) {
String sql = "INSERT INTO ticket_pending_notifications (player_uuid, message) VALUES (?, ?)";
@@ -607,9 +879,6 @@ public class DatabaseManager {
}
}
/**
* Lädt alle ausstehenden Benachrichtigungen für einen Spieler.
*/
public List<String> getPendingNotifications(UUID playerUUID) {
List<String> messages = new ArrayList<>();
if (useMySQL) {
@@ -627,9 +896,6 @@ public class DatabaseManager {
return messages;
}
/**
* Löscht alle ausstehenden Benachrichtigungen eines Spielers nach dem Anzeigen.
*/
public void clearPendingNotifications(UUID playerUUID) {
if (useMySQL) {
String sql = "DELETE FROM ticket_pending_notifications WHERE player_uuid = ?";
@@ -645,7 +911,7 @@ public class DatabaseManager {
}
}
// ─────────────────────────── [NEW] Blacklist ───────────────────────────
// ─────────────────────────── Blacklist ─────────────────────────────────
public boolean isBlacklisted(UUID uuid) {
if (useMySQL) {
@@ -705,7 +971,6 @@ public class DatabaseManager {
}
}
/** Gibt alle gesperrten Spieler als Liste von String-Arrays {uuid, name, reason, bannedBy} zurück. */
public List<String[]> getBlacklist() {
List<String[]> list = new ArrayList<>();
if (useMySQL) {
@@ -890,28 +1155,69 @@ public class DatabaseManager {
// ─────────────────────────── Statistiken ───────────────────────────────
public TicketStats getTicketStats() {
// Aktuelle Live-Daten aus der tickets-Tabelle
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> byServer = new java.util.HashMap<>();
for (Ticket t : all) {
switch (t.getStatus()) {
case OPEN -> open++;
case CLAIMED -> claimed++;
case FORWARDED -> forwarded++;
case CLOSED -> closed++;
case CLOSED -> closedLive++;
}
byPlayer.merge(t.getCreatorName(), 1, Integer::sum);
byServer.merge(t.getServerName(), 1, Integer::sum);
}
// 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++;
byPlayer.merge(t.getCreatorName(), 1, Integer::sum);
}
return new TicketStats(all.size(), open, closed, forwarded, thumbsUp, thumbsDown, byPlayer);
}
} else {
// Datei-Modus: nur Live-Daten verfügbar
for (Ticket t : all) {
if ("THUMBS_UP".equals(t.getPlayerRating())) thumbsUp++;
if ("THUMBS_DOWN".equals(t.getPlayerRating())) thumbsDown++;
}
}
return new TicketStats(all.size(), open, totalClosedEver, forwarded, thumbsUp, thumbsDown, byPlayer, byServer);
}
public static class TicketStats {
public final int total, open, closed, forwarded, thumbsUp, thumbsDown;
public final java.util.Map<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,
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.open = open;
this.closed = closed;
@@ -919,6 +1225,7 @@ public class DatabaseManager {
this.thumbsUp = thumbsUp;
this.thumbsDown = thumbsDown;
this.byPlayer = byPlayer;
this.byServer = byServer;
}
}
@@ -998,6 +1305,10 @@ public class DatabaseManager {
try { t.setPriority(TicketPriority.fromString(rs.getString("priority"))); } catch (SQLException ignored) {}
try { t.setPlayerRating(rs.getString("player_rating")); } catch (SQLException ignored) {}
try { t.setClaimerNotified(rs.getBoolean("claimer_notified")); } catch (SQLException ignored) {}
// Bug-Fix: close_notified für duplikat-freie Schließ-Benachrichtigungen
try { t.setCloseNotified(rs.getBoolean("close_notified")); } catch (SQLException ignored) {}
// BungeeCord: Server-Name einlesen
try { t.setServerName(rs.getString("server_name")); } catch (SQLException ignored) {}
return t;
}
@@ -1032,6 +1343,9 @@ public class DatabaseManager {
obj.put("priority", t.getPriority().name());
if (t.getPlayerRating() != null) obj.put("playerRating", t.getPlayerRating());
obj.put("claimerNotified", t.isClaimerNotified());
obj.put("closeNotified", t.isCloseNotified());
// BungeeCord: Server-Name im JSON-Export
obj.put("serverName", t.getServerName());
return obj;
}
@@ -1060,6 +1374,9 @@ public class DatabaseManager {
if (obj.containsKey("priority")) t.setPriority(TicketPriority.fromString((String) obj.get("priority")));
if (obj.containsKey("playerRating")) t.setPlayerRating((String) obj.get("playerRating"));
if (obj.containsKey("claimerNotified"))t.setClaimerNotified((Boolean) obj.get("claimerNotified"));
if (obj.containsKey("closeNotified")) t.setCloseNotified((Boolean) obj.get("closeNotified"));
// BungeeCord: Server-Name aus JSON
if (obj.containsKey("serverName")) t.setServerName((String) obj.get("serverName"));
return t;
} catch (Exception e) {
if (plugin != null) plugin.getLogger().severe("Fehler beim Parsen eines Tickets: " + e.getMessage());

View File

@@ -3,6 +3,7 @@ package de.ticketsystem.discord;
import de.ticketsystem.TicketPlugin;
import de.ticketsystem.model.ConfigCategory;
import de.ticketsystem.model.Ticket;
import de.ticketsystem.model.TicketPriority;
import org.bukkit.Bukkit;
import java.io.OutputStream;
@@ -10,96 +11,94 @@ import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
/**
* Sendet Benachrichtigungen an einen Discord-Webhook.
* Unterstützt Embeds mit Farbe, Feldern, Timestamp, Kategorie, Priorität und Rollen-Ping.
*
* Relevante config.yml-Felder:
*
* discord:
* enabled: true
* webhook-url: "https://discord.com/api/webhooks/..."
* role-ping-id: "" # Rollen-ID für @Ping, leer = kein Ping
* messages:
* new-ticket:
* title: "🎫 Neues Ticket erstellt"
* color: "3066993"
* footer: "TicketSystem"
* show-position: true
* show-category: true
* show-priority: true
* role-ping: true # Ping bei neuem Ticket an/aus
* ticket-closed:
* enabled: false
* title: "🔒 Ticket geschlossen"
* color: "15158332"
* footer: "TicketSystem"
* show-category: true
* show-priority: true
* role-ping: false
* ticket-forwarded:
* enabled: false
* title: "🔀 Ticket weitergeleitet"
* color: "15105570"
* footer: "TicketSystem"
* show-category: true
* show-priority: true
* role-ping: false
*/
public class DiscordWebhook {
// ─────────────────────────────────────────────────────────────────────────
// Konstanten & Felder
// ─────────────────────────────────────────────────────────────────────────
private static final String AVATAR_URL = "https://mc-heads.net/avatar/%s/64";
private final TicketPlugin plugin;
// ─────────────────────────────────────────────────────────────────────────
// Konstruktor
// ─────────────────────────────────────────────────────────────────────────
public DiscordWebhook(TicketPlugin plugin) {
this.plugin = plugin;
}
// ─────────────────────────── Öffentliche Methoden ──────────────────────
// ─────────────────────────────────────────────────────────────────────────
// Öffentliche Methoden Webhook-Events
// ─────────────────────────────────────────────────────────────────────────
/**
* Sendet eine Benachrichtigung wenn ein neues Ticket erstellt wurde.
*/
public void sendNewTicket(Ticket ticket) {
if (!isEnabled()) return;
String webhookUrl = getWebhookUrl();
if (webhookUrl == null) return;
String title = plugin.getConfig().getString ("discord.messages.new-ticket.title", "🎫 Neues Ticket erstellt");
String color = plugin.getConfig().getString ("discord.messages.new-ticket.color", "3066993");
// Konfiguration lesen
String title = plugin.getConfig().getString ("discord.messages.new-ticket.title", "Neues Ticket");
String color = plugin.getConfig().getString ("discord.messages.new-ticket.color", "5793266");
String footer = plugin.getConfig().getString ("discord.messages.new-ticket.footer", "TicketSystem");
boolean showPos = plugin.getConfig().getBoolean("discord.messages.new-ticket.show-position", true);
boolean showCat = plugin.getConfig().getBoolean("discord.messages.new-ticket.show-category", true);
boolean showPri = plugin.getConfig().getBoolean("discord.messages.new-ticket.show-priority", true);
boolean showSrv = plugin.getConfig().getBoolean("discord.messages.new-ticket.show-server", true);
boolean ping = plugin.getConfig().getBoolean("discord.messages.new-ticket.role-ping", true);
StringBuilder fields = new StringBuilder();
fields.append(field("Spieler", ticket.getCreatorName(), true));
fields.append(",").append(field("Ticket ID", "#" + ticket.getId(), true));
fields.append(",").append(field("Anliegen", ticket.getMessage(), false));
// Hilfs-Werte berechnen
String prioEmoji = getPriorityEmoji(ticket.getPriority());
String avatarUrl = String.format(AVATAR_URL, ticket.getCreatorUUID().toString());
// Felder aufbauen
List<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)) {
ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey());
fields.append(",").append(field("Kategorie", cat.getName(), true));
fields.add(new Field("🏷️ Kategorie", cat.getName(), true));
}
if (showPri && plugin.getConfig().getBoolean("priorities-enabled", true)) {
fields.append(",").append(field("Priorität", ticket.getPriority().getDisplayName(), true));
fields.add(new Field("Priorität", prioEmoji + " " + ticket.getPriority().getDisplayName(), true));
}
if (showSrv && plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) {
fields.add(new Field("🖥️ Server", ticket.getServerName(), true));
}
if (showPos) {
fields.append(",").append(field("Welt", ticket.getWorldName(), true));
fields.append(",").append(field("Position",
fields.add(new Field("🌍 Welt", ticket.getWorldName(), true));
fields.add(new Field("📍 Position",
String.format("%.0f, %.0f, %.0f", ticket.getX(), ticket.getY(), ticket.getZ()), true));
}
String content = ping ? buildRolePing() : "";
String json = buildPayload(content, title, Integer.parseInt(color), fields.toString(), footer);
// JSON zusammenbauen & senden
String description = "**Anliegen**\n> " + j(ticket.getMessage());
String json = buildJson(
ping ? buildRolePing() : "",
prioEmoji + " " + j(title) + " #" + ticket.getId(),
description,
Integer.parseInt(color),
j(ticket.getCreatorName()), avatarUrl,
avatarUrl,
fields,
j(footer) + " • Neues Ticket"
);
sendAsync(webhookUrl, json);
}
/**
* Sendet eine Benachrichtigung wenn ein Ticket geschlossen wurde.
*/
// ─────────────────────────────────────────────────────────────────────────
public void sendTicketClosed(Ticket ticket, String closerName) {
if (!isEnabled()) return;
if (!plugin.getConfig().getBoolean("discord.messages.ticket-closed.enabled", false)) return;
@@ -107,37 +106,68 @@ public class DiscordWebhook {
String webhookUrl = getWebhookUrl();
if (webhookUrl == null) return;
String title = plugin.getConfig().getString ("discord.messages.ticket-closed.title", "🔒 Ticket geschlossen");
String color = plugin.getConfig().getString ("discord.messages.ticket-closed.color", "15158332");
// Konfiguration lesen
String title = plugin.getConfig().getString ("discord.messages.ticket-closed.title", "Ticket geschlossen");
String color = plugin.getConfig().getString ("discord.messages.ticket-closed.color", "15548997");
String footer = plugin.getConfig().getString ("discord.messages.ticket-closed.footer", "TicketSystem");
boolean showCat = plugin.getConfig().getBoolean("discord.messages.ticket-closed.show-category", true);
boolean showPri = plugin.getConfig().getBoolean("discord.messages.ticket-closed.show-priority", true);
boolean showSrv = plugin.getConfig().getBoolean("discord.messages.ticket-closed.show-server", true);
boolean ping = plugin.getConfig().getBoolean("discord.messages.ticket-closed.role-ping", false);
StringBuilder fields = new StringBuilder();
fields.append(field("Ticket ID", "#" + ticket.getId(), true));
fields.append(",").append(field("Ersteller", ticket.getCreatorName(), true));
fields.append(",").append(field("Geschlossen von", closerName, true));
// Hilfs-Werte berechnen
String prioEmoji = getPriorityEmoji(ticket.getPriority());
String avatarUrl = String.format(AVATAR_URL, ticket.getCreatorUUID().toString());
// Beschreibung aufbauen
StringBuilder desc = new StringBuilder();
desc.append("**Anliegen**\n> ").append(j(ticket.getMessage()));
if (ticket.getCloseComment() != null && !ticket.getCloseComment().isEmpty()) {
desc.append("\n\n**Kommentar des Supports**\n> ").append(j(ticket.getCloseComment()));
}
// Felder aufbauen
List<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)) {
ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey());
fields.append(",").append(field("Kategorie", cat.getName(), true));
}
if (showPri && plugin.getConfig().getBoolean("priorities-enabled", true)) {
fields.append(",").append(field("Priorität", ticket.getPriority().getDisplayName(), true));
}
if (ticket.getCloseComment() != null && !ticket.getCloseComment().isEmpty()) {
fields.append(",").append(field("Kommentar", ticket.getCloseComment(), false));
fields.add(new Field("🏷️ Kategorie", cat.getName(), true));
}
String content = ping ? buildRolePing() : "";
String json = buildPayload(content, title, Integer.parseInt(color), fields.toString(), footer);
if (showPri && plugin.getConfig().getBoolean("priorities-enabled", true)) {
fields.add(new Field("⚡ Priorität", prioEmoji + " " + ticket.getPriority().getDisplayName(), true));
}
if (showSrv && plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) {
fields.add(new Field("🖥️ Server", ticket.getServerName(), true));
}
if (plugin.getConfig().getBoolean("rating-enabled", true) && ticket.getPlayerRating() != null) {
String rating = "THUMBS_UP".equals(ticket.getPlayerRating()) ? "👍 Positiv" : "👎 Negativ";
fields.add(new Field("⭐ Bewertung", rating, true));
}
// JSON zusammenbauen & senden
String json = buildJson(
ping ? buildRolePing() : "",
"🔒 " + j(title) + " #" + ticket.getId(),
desc.toString(),
Integer.parseInt(color),
j(ticket.getCreatorName()), avatarUrl,
avatarUrl,
fields,
j(footer) + " • Ticket geschlossen"
);
sendAsync(webhookUrl, json);
}
/**
* Sendet eine Benachrichtigung wenn ein Ticket weitergeleitet wurde.
*/
// ─────────────────────────────────────────────────────────────────────────
public void sendTicketForwarded(Ticket ticket, String fromName) {
if (!isEnabled()) return;
if (!plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.enabled", false)) return;
@@ -145,98 +175,180 @@ public class DiscordWebhook {
String webhookUrl = getWebhookUrl();
if (webhookUrl == null) return;
String title = plugin.getConfig().getString ("discord.messages.ticket-forwarded.title", "🔀 Ticket weitergeleitet");
// Konfiguration lesen
String title = plugin.getConfig().getString ("discord.messages.ticket-forwarded.title", "Ticket weitergeleitet");
String color = plugin.getConfig().getString ("discord.messages.ticket-forwarded.color", "15105570");
String footer = plugin.getConfig().getString ("discord.messages.ticket-forwarded.footer", "TicketSystem");
boolean showCat = plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.show-category", true);
boolean showPri = plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.show-priority", true);
boolean showSrv = plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.show-server", true);
boolean ping = plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.role-ping", false);
StringBuilder fields = new StringBuilder();
fields.append(field("Ticket ID", "#" + ticket.getId(), true));
fields.append(",").append(field("Ersteller", ticket.getCreatorName(), true));
fields.append(",").append(field("Weitergeleitet von", fromName, true));
fields.append(",").append(field("Weitergeleitet an", ticket.getForwardedToName(), true));
// Hilfs-Werte berechnen
String prioEmoji = getPriorityEmoji(ticket.getPriority());
String avatarUrl = String.format(AVATAR_URL, ticket.getCreatorUUID().toString());
// Felder aufbauen
String forwardedTo = ticket.getForwardedToName() != null ? j(ticket.getForwardedToName()) : "";
List<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)) {
ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey());
fields.append(",").append(field("Kategorie", cat.getName(), true));
}
if (showPri && plugin.getConfig().getBoolean("priorities-enabled", true)) {
fields.append(",").append(field("Priorität", ticket.getPriority().getDisplayName(), true));
fields.add(new Field("🏷️ Kategorie", cat.getName(), true));
}
String content = ping ? buildRolePing() : "";
String json = buildPayload(content, title, Integer.parseInt(color), fields.toString(), footer);
if (showPri && plugin.getConfig().getBoolean("priorities-enabled", true)) {
fields.add(new Field("⚡ Priorität", prioEmoji + " " + ticket.getPriority().getDisplayName(), true));
}
if (showSrv && plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) {
fields.add(new Field("🖥️ Server", ticket.getServerName(), true));
}
// JSON zusammenbauen & senden
String json = buildJson(
ping ? buildRolePing() : "",
"🔀 " + j(title) + " #" + ticket.getId(),
"**Anliegen**\n> " + j(ticket.getMessage()),
Integer.parseInt(color),
j(ticket.getCreatorName()), avatarUrl,
avatarUrl,
fields,
j(footer) + " • Ticket weitergeleitet"
);
sendAsync(webhookUrl, json);
}
// ─────────────────────────── Private Hilfsmethoden ─────────────────────
// ─────────────────────────────────────────────────────────────────────────
// JSON-Bau
// ─────────────────────────────────────────────────────────────────────────
private record Field(String name, String value, boolean inline) {}
/**
* Baut den kompletten JSON-Payload ohne String.format()-Chaos.
* Kein verschachteltes Escaping, kein ungültiges JSON.
*/
private String buildJson(
String content,
String title,
String description,
int color,
String authorName,
String authorIcon,
String thumbnailUrl,
List<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() {
return plugin.getConfig().getBoolean("discord.enabled", false);
}
/** Gibt die Webhook-URL zurück oder null wenn nicht gesetzt. */
private String getWebhookUrl() {
String url = plugin.getConfig().getString("discord.webhook-url", "");
return url.isEmpty() ? null : url;
}
/**
* Baut den @Rollen-Ping-String aus der konfigurierten Rollen-ID.
* Leer wenn keine ID gesetzt.
*/
private String buildRolePing() {
String roleId = plugin.getConfig().getString("discord.role-ping-id", "").trim();
if (roleId.isEmpty()) return "";
return "<@&" + roleId + ">";
return roleId.isEmpty() ? "" : "<@&" + roleId + ">";
}
/**
* Baut einen einzelnen Embed-Field als JSON-String.
*/
private String field(String name, String value, boolean inline) {
String safeValue = value != null
? value.replace("\\", "\\\\").replace("\"", "\\\"")
: "";
String safeName = name.replace("\\", "\\\\").replace("\"", "\\\"");
return String.format("{\"name\":\"%s\",\"value\":\"%s\",\"inline\":%b}",
safeName, safeValue, inline);
private String getPriorityEmoji(TicketPriority priority) {
return switch (priority) {
case LOW -> "🟢";
case NORMAL -> "🟡";
case HIGH -> "🟠";
case URGENT -> "🔴";
};
}
/**
* Baut den kompletten Webhook-Payload als JSON.
* content = optionaler Ping-Text außerhalb des Embeds.
*/
private String buildPayload(String content, String title, int color, String fieldsJson, String footer) {
String timestamp = Instant.now().toString();
String safeTitle = title.replace("\"", "\\\"");
String safeFooter = footer.replace("\"", "\\\"");
String safeContent = content.replace("\"", "\\\"");
return String.format("""
{
"content": "%s",
"embeds": [{
"title": "%s",
"color": %d,
"fields": [%s],
"footer": { "text": "%s" },
"timestamp": "%s"
}]
}""",
safeContent,
safeTitle,
color,
fieldsJson,
safeFooter,
timestamp);
}
/**
* Sendet den JSON-Payload asynchron an den Webhook.
*/
private void sendAsync(String webhookUrl, String json) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
@@ -254,8 +366,12 @@ public class DiscordWebhook {
}
int responseCode = conn.getResponseCode();
if (plugin.isDebug()) {
plugin.getLogger().info("[DEBUG] Discord Webhook Response: " + responseCode);
plugin.getLogger().info("[DEBUG][DiscordWebhook] Response: " + responseCode);
if (responseCode != 200 && responseCode != 204) {
plugin.getLogger().info("[DEBUG][DiscordWebhook] Payload: " + json);
}
}
if (responseCode != 200 && responseCode != 204) {
@@ -263,6 +379,7 @@ public class DiscordWebhook {
}
conn.disconnect();
} catch (Exception e) {
plugin.getLogger().warning("[DiscordWebhook] Fehler beim Senden: " + e.getMessage());
if (plugin.isDebug()) e.printStackTrace();

View File

@@ -196,9 +196,32 @@ public class TicketGUI implements Listener {
// Slot 4: Ticket-Info
inv.setItem(4, buildDetailInfoItem(ticket));
// Slot 10: Teleportieren
// ── Teleport-Button ───────────────────────────────────────────────
// Standalone: → normaler Teleport-Button
// BungeeCord + bungee-teleport-enabled: → serverübergreifender Teleport-Button
// BungeeCord + bungee-teleport deaktiviert → gesperrter Button
if (!plugin.isBungeeCordEnabled()) {
inv.setItem(10, buildActionItem(Material.ENDER_PEARL, "§b§lTeleportieren",
List.of("§7Teleportiert dich zur", "§7Position des Tickets.")));
} else if (plugin.getConfig().getBoolean("bungee-teleport-enabled", true)) {
String targetServer = ticket.getServerName();
boolean sameServer = plugin.getServerName().equals(targetServer);
String serverLine = "unknown".equals(targetServer)
? "§cServer unbekannt"
: sameServer
? "§7Dieser Server §a(direkt)"
: "§7Ziel-Server: §b" + targetServer;
inv.setItem(10, buildActionItem(Material.ENDER_PEARL, "§b§lTeleportieren",
List.of("§7Teleportiert dich zur Ticket-Position.", serverLine,
"§8" + (sameServer ? "Lokaler Teleport" : "Server-Wechsel erforderlich"))));
} else {
String serverInfo = !"unknown".equals(ticket.getServerName())
? "§7Ticket-Server: §b" + ticket.getServerName()
: "§7Server unbekannt";
inv.setItem(10, buildActionItem(Material.GRAY_STAINED_GLASS_PANE, "§8Teleport deaktiviert",
List.of("§7Im BungeeCord-Modus ist", "§7Teleportation deaktiviert.", serverInfo,
"§8(bungee-teleport-enabled: false)")));
}
// Slot 12: Claimen / Löschen / Grau
if (ticket.getStatus() == TicketStatus.OPEN) {
@@ -299,7 +322,6 @@ public class TicketGUI implements Listener {
// ── Spieler-GUI ────────────────────────────────────────────────────
if (title.equals(PLAYER_GUI_TITLE)) {
// Navigationstasten
int curPage = playerPage.getOrDefault(player.getUniqueId(), 0);
if (slot == 45) { openPlayerGUI(player, curPage - 1); return; }
if (slot == 53) { openPlayerGUI(player, curPage + 1); return; }
@@ -355,19 +377,16 @@ public class TicketGUI implements Listener {
// ─────────────────────────── Navigation-Handler ─────────────────────────
/**
* Verarbeitet Klicks auf die Navigationsleiste der Admin-Übersicht (Slots 4553).
*/
private void handleAdminNavClick(Player player, int slot, boolean isArchive) {
int curPage = adminPage.getOrDefault(player.getUniqueId(), 0);
switch (slot) {
case 45 -> openGUI(player, curPage - 1); // Zurück
case 53 -> openGUI(player, curPage + 1); // Vor
case 49 -> { // Archiv-Button oder Zurück im Archiv
case 45 -> openGUI(player, curPage - 1);
case 53 -> openGUI(player, curPage + 1);
case 49 -> {
if (player.hasPermission(ARCHIVE_PERMISSION)) openClosedGUI(player);
else player.sendMessage(plugin.color("&cDu hast keine Berechtigung, das Archiv zu öffnen."));
}
case 47 -> { // Kategorie-Filter (wenn aktiviert)
case 47 -> {
if (plugin.getConfig().getBoolean("categories-enabled", true)) {
cycleCategoryFilter(player);
openGUI(player, 0);
@@ -381,11 +400,10 @@ public class TicketGUI implements Listener {
switch (slot) {
case 45 -> openClosedGUI(player, curPage - 1);
case 53 -> openClosedGUI(player, curPage + 1);
case 49 -> openGUI(player); // Zurück zur Hauptübersicht
case 49 -> openGUI(player);
}
}
/** Wechselt zum nächsten Kategorie-Filter */
private void cycleCategoryFilter(Player player) {
CategoryManager cm = plugin.getCategoryManager();
List<ConfigCategory> all = cm.getAll();
@@ -396,7 +414,7 @@ public class TicketGUI implements Listener {
} else {
int idx = all.indexOf(current);
int next = idx + 1;
if (next >= all.size()) categoryFilter.remove(player.getUniqueId()); // Zurück zu Alle
if (next >= all.size()) categoryFilter.remove(player.getUniqueId());
else categoryFilter.put(player.getUniqueId(), all.get(next));
}
ConfigCategory newFilter = categoryFilter.getOrDefault(player.getUniqueId(), null);
@@ -416,15 +434,97 @@ public class TicketGUI implements Listener {
});
}
// ── BUG FIX: handleDetailTeleport ────────────────────────────────────────
// Vorher: Teleport wurde immer ausgeführt auch bei aktivem BungeeCord.
// ticket.getLocation() gibt null zurück wenn die World auf diesem
// Server nicht existiert → NullPointerException oder falscher Teleport.
//
// Fix: Bei bungeecord: true + bungee-teleport-enabled: true →
// 1. Zielposition in DB speichern (ticket_pending_teleport)
// 2. Spieler via Plugin Messaging Channel auf Ziel-Server schicken
// 3. PlayerJoinListener teleportiert ihn dort zur Position
// Bei bungeecord: true + bungee-teleport-enabled: false → gesperrt.
// Bei bungeecord: false → normaler lokaler Teleport wie bisher.
//
// Hinweis: Ist der Admin bereits auf dem Ziel-Server, wird direkt teleportiert.
// ─────────────────────────────────────────────────────────────────────────
private void handleDetailTeleport(Player player, Ticket ticket) {
if (!plugin.isBungeeCordEnabled()) {
// ── Standalone-Modus: direkt teleportieren ──
if (ticket.getLocation() != null) {
player.teleport(ticket.getLocation());
player.sendMessage(plugin.color("&7Du wurdest zu Ticket &e#" + ticket.getId() + " &7teleportiert."));
} else {
player.sendMessage(plugin.color("&cDie Welt des Tickets ist nicht geladen!"));
}
openDetailGUI(player, ticket);
return;
}
// ── BungeeCord-Modus ──────────────────────────────────────────────
boolean bungeeTP = plugin.getConfig().getBoolean("bungee-teleport-enabled", true);
if (!bungeeTP) {
String serverHint = !"unknown".equals(ticket.getServerName())
? " §7(Server: §b" + ticket.getServerName() + "§7)" : "";
player.sendMessage(plugin.color("&cServerübergreifender Teleport ist in der Config deaktiviert." + serverHint));
openDetailGUI(player, ticket);
return;
}
String targetServer = ticket.getServerName();
if ("unknown".equals(targetServer)) {
player.sendMessage(plugin.color("&cServer des Tickets unbekannt Teleport nicht möglich."));
openDetailGUI(player, ticket);
return;
}
String currentServer = plugin.getServerName();
if (currentServer.equals(targetServer)) {
// ── Bereits auf dem richtigen Server: direkt teleportieren ────
if (ticket.getLocation() != null) {
player.teleport(ticket.getLocation());
player.sendMessage(plugin.color("&7Du wurdest zu Ticket &e#" + ticket.getId() + " &7teleportiert."));
} else {
player.sendMessage(plugin.color("&cDie Welt des Tickets ist nicht geladen!"));
}
openDetailGUI(player, ticket);
} else {
// ── Anderer Server: Position in DB speichern + Server-Wechsel ─
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
plugin.getDatabaseManager().setPendingTeleport(
player.getUniqueId(),
ticket.getWorldName(),
ticket.getX(), ticket.getY(), ticket.getZ(),
ticket.getYaw(), ticket.getPitch()
);
Bukkit.getScheduler().runTask(plugin, () -> {
// BungeeCord Plugin Messaging Channel: Spieler auf Ziel-Server schicken
player.sendMessage(plugin.color("&7Verbinde dich mit Server &b" + targetServer
+ " &7für Ticket &e#" + ticket.getId() + "&7..."));
try {
java.io.ByteArrayOutputStream b = new java.io.ByteArrayOutputStream();
java.io.DataOutputStream out = new java.io.DataOutputStream(b);
out.writeUTF("Connect");
out.writeUTF(targetServer);
player.sendPluginMessage(plugin, "BungeeCord", b.toByteArray());
} catch (Exception e) {
plugin.getLogger().warning("[TicketSystem] BungeeCord Connect fehlgeschlagen: " + e.getMessage());
player.sendMessage(plugin.color("&cServer-Wechsel fehlgeschlagen. Bitte manuell verbinden."));
}
});
});
}
}
// ── BUG FIX: handleDetailClaim ───────────────────────────────────────────
// Vorher: Nach erfolgreichem Claim wurde immer teleportiert wenn
// ticket.getLocation() != null unabhängig von BungeeCord.
//
// Fix: Teleport nach Claim nutzt dieselbe Logik wie handleDetailTeleport:
// Standalone → direkt, BungeeCord + enabled → Server-Wechsel + pending,
// BungeeCord + disabled → nur Nachricht, kein Teleport.
// ─────────────────────────────────────────────────────────────────────────
private void handleDetailClaim(Player player, Ticket ticket) {
if (ticket.getStatus() != TicketStatus.OPEN) {
player.sendMessage(plugin.formatMessage("messages.already-claimed"));
@@ -440,11 +540,9 @@ public class TicketGUI implements Listener {
ticket.setClaimerUUID(player.getUniqueId());
ticket.setClaimerName(player.getName());
plugin.getTicketManager().notifyCreatorClaimed(ticket);
if (ticket.getLocation() != null) player.teleport(ticket.getLocation());
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
Ticket fresh = plugin.getDatabaseManager().getTicketById(ticket.getId());
Bukkit.getScheduler().runTask(plugin, () -> { if (fresh != null) openDetailGUI(player, fresh); });
});
// Teleport nach dem Claim gleiche Logik wie handleDetailTeleport
handleDetailTeleport(player, ticket);
});
});
}
@@ -513,7 +611,6 @@ public class TicketGUI implements Listener {
});
}
private void handleShowComments(Player player, Ticket ticket) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
List<TicketComment> comments = plugin.getDatabaseManager().getComments(ticket.getId());
@@ -529,7 +626,6 @@ public class TicketGUI implements Listener {
}
}
player.sendMessage(plugin.color("&8&m "));
// Gleich wieder Detail-GUI öffnen
openDetailGUI(player, ticket);
});
});
@@ -552,10 +648,21 @@ public class TicketGUI implements Listener {
}
final String comment = input.equals("-") ? "" : input;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
boolean success = plugin.getDatabaseManager().closeTicket(ticketId, comment);
if (success) {
Ticket ticket = plugin.getDatabaseManager().getTicketById(ticketId);
// ── FIX: Schließung in persistente Stats-Tabelle eintragen ──────────
// Vorher fehlte dieser Aufruf in der GUI Bewertungen wurden dem
// schließenden Admin zugeordnet nur wenn /ticket close genutzt wurde.
// Jetzt wird player.getName() korrekt als closerName übergeben,
// unabhängig davon ob das Ticket vorher von jemand anderem geclaimed war.
if (ticket != null) {
plugin.getDatabaseManager().recordClosedTicket(ticket, player.getName());
}
Bukkit.getScheduler().runTask(plugin, () -> {
player.sendMessage(plugin.formatMessage("messages.ticket-closed").replace("{id}", String.valueOf(ticketId)));
if (!comment.isEmpty()) player.sendMessage(plugin.color("&7Kommentar: &f" + comment));
@@ -570,33 +677,24 @@ public class TicketGUI implements Listener {
// ─────────────────────────── Item-Builder ──────────────────────────────
/**
* Füllt die Navigationsleiste (letzte Reihe, Slots 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) {
ItemStack glass = makeGlass();
for (int i = 45; i < 54; i++) inv.setItem(i, glass);
// Zurück (Slot 45)
if (page > 0) {
inv.setItem(45, buildActionItem(Material.ARROW, "§7§l◄ Zurück",
List.of("§7Seite " + page + " von " + totalPages)));
}
// Weiter (Slot 53)
if (page < totalPages - 1) {
inv.setItem(53, buildActionItem(Material.ARROW, "§7§lWeiter ►",
List.of("§7Seite " + (page + 2) + " von " + totalPages)));
}
// Seitenanzeige (Slot 49)
if (!isArchiveView) {
if (player.hasPermission(ARCHIVE_PERMISSION)) {
inv.setItem(49, buildActionItem(Material.CHEST, "§7§lGeschlossene Tickets",
List.of("§7Zeigt alle abgeschlossenen", "§7Tickets im Archiv an.")));
}
// Kategorie-Filter (Slot 47), nur wenn aktiviert
if (plugin.getConfig().getBoolean("categories-enabled", true)) {
ConfigCategory currentFilter = categoryFilter.getOrDefault(player.getUniqueId(), null);
String filterLabel = currentFilter != null ? currentFilter.getColored() : "§7Alle";
@@ -611,12 +709,10 @@ public class TicketGUI implements Listener {
inv.setItem(47, buildActionItem(Material.HOPPER, "§e§lKategorie-Filter", filterLore));
}
} else {
// Im Archiv: Zurück-Button in Slot 49
inv.setItem(49, buildActionItem(Material.ARROW, "§7§lZurück zur Übersicht",
List.of("§7Zeigt alle offenen Tickets.")));
}
// Seitenanzeige Mitte oben (Slot 48)
inv.setItem(48, buildActionItem(Material.PAPER, "§8Seite " + (page + 1) + "/" + totalPages,
List.of("§7Gesamt: " + (playerSlotMap.containsKey(player.getUniqueId())
? playerSlotMap.get(player.getUniqueId()).size() + "+" : "?") + " Tickets auf dieser Seite")));
@@ -630,11 +726,7 @@ public class TicketGUI implements Listener {
inv.setItem(49, buildActionItem(Material.PAPER, "§8Seite " + (page + 1) + "/" + totalPages, List.of()));
}
// ─────────────────────────── Item-Builder ──────────────────────────────
private ItemStack buildAdminListItem(Ticket ticket) {
// Material: Kategorie aus Config (z.B. REDSTONE für Bug, BOOK für Frage)
// Fallback auf Status-Material wenn categories-enabled: false
Material mat;
if (plugin.getConfig().getBoolean("categories-enabled", true)) {
mat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey()).getMaterial();
@@ -651,7 +743,6 @@ public class TicketGUI implements Listener {
ItemMeta meta = item.getItemMeta();
if (meta == null) return item;
// Priorität farblich im Titel anzeigen (wenn aktiviert)
String priorityPrefix = plugin.getConfig().getBoolean("priorities-enabled", true)
? ticket.getPriority().getColored() + " §8| " : "";
meta.setDisplayName(priorityPrefix + "§6§lTicket #" + ticket.getId() + " §r" + ticket.getStatus().getColored());
@@ -693,6 +784,9 @@ public class TicketGUI implements Listener {
lore.add("§7Ersteller: §e" + ticket.getCreatorName());
lore.add("§7Anliegen: §f" + ticket.getMessage());
lore.add("§7Erstellt: §e" + DATE_FORMAT.format(ticket.getCreatedAt()));
if (plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) {
lore.add("§7Server: §b" + ticket.getServerName());
}
lore.add("§7Welt: §e" + ticket.getWorldName());
lore.add(String.format("§7Position: §e%.0f, %.0f, %.0f", ticket.getX(), ticket.getY(), ticket.getZ()));
if (plugin.getConfig().getBoolean("categories-enabled", true)) {
@@ -739,6 +833,9 @@ public class TicketGUI implements Listener {
lore.add("§8§m ");
lore.add("§7Anliegen: §f" + ticket.getMessage());
lore.add("§7Erstellt: §e" + DATE_FORMAT.format(ticket.getCreatedAt()));
if (plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) {
lore.add("§7Server: §b" + ticket.getServerName());
}
lore.add("§7Welt: §e" + ticket.getWorldName());
lore.add(String.format("§7Position: §e%.0f, %.0f, %.0f", ticket.getX(), ticket.getY(), ticket.getZ()));
if (plugin.getConfig().getBoolean("categories-enabled", true)) {

View File

@@ -3,10 +3,13 @@ package de.ticketsystem.listeners;
import java.util.List;
import de.ticketsystem.TicketPlugin;
import de.ticketsystem.database.DatabaseManager;
import de.ticketsystem.model.Ticket;
import de.ticketsystem.model.TicketStatus;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
@@ -39,6 +42,37 @@ public class PlayerJoinListener implements Listener {
});
}
// ── BungeeCord: ausstehenden Teleport-Auftrag prüfen ─────────────
// Wenn ein Admin via GUI auf einen anderen Server geschickt wurde,
// liegt hier die Zielposition. Wir teleportieren ihn nach dem Spawn.
if (plugin.isBungeeCordEnabled()
&& plugin.getConfig().getBoolean("bungee-teleport-enabled", true)) {
Bukkit.getScheduler().runTaskLater(plugin, () -> {
if (!player.isOnline()) return;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
DatabaseManager.PendingTeleport pt =
plugin.getDatabaseManager().consumePendingTeleport(player.getUniqueId());
if (pt == null) return;
Bukkit.getScheduler().runTask(plugin, () -> {
if (!player.isOnline()) return;
World world = Bukkit.getWorld(pt.world());
if (world == null) {
player.sendMessage(plugin.color(
"&cTeleport-Zielwelt &e" + pt.world() + " &cnicht gefunden!"));
return;
}
Location loc = new Location(world, pt.x(), pt.y(), pt.z(), pt.yaw(), pt.pitch());
player.teleport(loc);
player.sendMessage(plugin.color(
"&7Du wurdest zur Ticket-Position teleportiert. &8("
+ String.format("%.0f, %.0f, %.0f", pt.x(), pt.y(), pt.z()) + ")"));
});
});
// 40 Ticks (2 Sek) Verzögerung damit der Spieler vollständig gespawnt ist
}, 40L);
}
// ── Ausstehende Kommentar-/Schließ-Benachrichtigungen anzeigen ────
// (Nachrichten die ankamen während der Spieler offline war)
Bukkit.getScheduler().runTaskLater(plugin, () -> {
@@ -66,15 +100,17 @@ public class PlayerJoinListener implements Listener {
plugin.getTicketManager().notifyClaimedWhileOffline(player);
}, 60L);
// ── Spieler: über geschlossene Tickets mit Kommentar informieren ──
// ── Spieler: über geschlossene Tickets informieren (nur wenn noch nicht geschehen) ──
// Bug-Fix: Nutzt close_notified aus der DB statt in-memory Set.
// Verhindert Duplikate bei Server-Wechseln in BungeeCord-Netzwerken.
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
List<Ticket> closed = plugin.getDatabaseManager()
.getTicketsByStatus(TicketStatus.CLOSED);
for (Ticket t : closed) {
if (!t.getCreatorUUID().equals(player.getUniqueId())) continue;
if (t.getCloseComment() == null || t.getCloseComment().isEmpty()) continue;
if (plugin.getTicketManager().wasClosedNotificationSent(t.getId())) continue;
// DB-Feld prüfen funktioniert serverübergreifend
if (t.isCloseNotified()) continue;
Bukkit.getScheduler().runTask(plugin, () ->
plugin.getTicketManager().notifyCreatorClosed(t));

View File

@@ -8,9 +8,7 @@ import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
public class TicketManager {
@@ -20,9 +18,6 @@ public class TicketManager {
/** Cooldown Map: UUID → Zeitstempel letztes Ticket */
private final Map<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) {
this.plugin = plugin;
}
@@ -46,8 +41,11 @@ public class TicketManager {
// ─────────────────────────── Benachrichtigungen ────────────────────────
/**
* Benachrichtigt alle Online-Supporter/Admins über ein neues Ticket
* und sendet optional eine Discord-Webhook-Nachricht.
* Benachrichtigt alle Supporter/Admins über ein neues Ticket auch auf anderen Servern.
*
* Lokal online Spieler werden direkt angesprochen.
* Über BungeeCord werden alle anderen Server im Netzwerk ebenfalls benachrichtigt.
* Optional sendet der Discord-Webhook eine Nachricht.
*/
public void notifyTeam(Ticket ticket) {
String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unbekannt";
@@ -57,23 +55,41 @@ public class TicketManager {
String categoryInfo = "";
String priorityInfo = "";
if (plugin.getConfig().getBoolean("categories-enabled", true)) {
de.ticketsystem.model.ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey());
ConfigCategory cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey());
categoryInfo = " §7[§r" + cat.getColored() + "§7]";
}
if (plugin.getConfig().getBoolean("priorities-enabled", true)) {
priorityInfo = " §7Priorität: §r" + ticket.getPriority().getColored();
}
// BungeeCord: Server-Herkunft anzeigen wenn BungeeCord aktiviert
String serverInfo = "";
if (plugin.isBungeeCordEnabled() && !"unknown".equals(ticket.getServerName())) {
serverInfo = " §7Server: §b" + ticket.getServerName();
}
String msg = plugin.formatMessage("messages.new-ticket-notify")
.replace("{player}", creatorName)
.replace("{message}", message)
.replace("{id}", String.valueOf(ticket.getId()))
+ categoryInfo + priorityInfo;
+ categoryInfo + priorityInfo + serverInfo;
String guiHint = plugin.color("&7» Klicke &e/ticket list &7um die GUI zu öffnen.");
if (plugin.isBungeeCordEnabled()) {
// ─ BungeeCord-Modus: Team-Broadcast über alle Server ─────────────────
// BungeeMessenger sendet lokal direkt, dann per Forward an alle anderen Server.
// Beide Nachrichten werden zu einer zusammengefasst um ein einzelnes
// Forward-Paket zu erzeugen statt zwei (reduziert Netzwerklast und
// verhindert mögliche Reihenfolge-Probleme).
plugin.getBungeeMessenger().broadcastTeamNotification(msg + "\n" + guiHint);
} else {
// ─ Standalone-Modus: Nur lokal ───────────────────────────────
for (Player p : Bukkit.getOnlinePlayers()) {
if (p.hasPermission("ticket.support") || p.hasPermission("ticket.admin")) {
p.sendMessage(msg);
p.sendMessage(plugin.color("&7» Klicke &e/ticket list &7um die GUI zu öffnen."));
p.sendMessage(guiHint);
}
}
}
@@ -83,20 +99,18 @@ public class TicketManager {
/**
* Benachrichtigt den Ersteller, wenn sein Ticket angenommen wurde.
* Setzt claimer_notified = true und persistiert es.
*
* BungeeCord: Zustellung auch wenn der Spieler auf einem anderen Server ist.
*/
public void notifyCreatorClaimed(Ticket ticket) {
Player creator = Bukkit.getPlayer(ticket.getCreatorUUID());
if (creator != null && creator.isOnline()) {
String claimerName = ticket.getClaimerName();
if (claimerName == null && ticket.getClaimerUUID() != null)
claimerName = Bukkit.getOfflinePlayer(ticket.getClaimerUUID()).getName();
if (claimerName == null) claimerName = "Support";
String claimerName = resolveClaimerName(ticket);
String msg = plugin.formatMessage("messages.ticket-claimed-notify")
.replace("{id}", String.valueOf(ticket.getId()))
.replace("{claimer}", claimerName);
creator.sendMessage(msg);
}
deliverToPlayer(ticket.getCreatorUUID(), ticket.getCreatorName(), msg);
// Persistiert setzen, damit Join-Listener weiß, dass Spieler bereits informiert ist
plugin.getDatabaseManager().markClaimerNotified(ticket.getId());
}
@@ -106,15 +120,13 @@ public class TicketManager {
* die geclaimt oder weitergeleitet wurden während er offline war.
*/
public void notifyClaimedWhileOffline(Player player) {
// Suche alle Tickets dieses Spielers, die CLAIMED/FORWARDED sind,
// aber noch nicht notified wurden
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
var tickets = plugin.getDatabaseManager().getTicketsByStatus(
TicketStatus.CLAIMED, TicketStatus.FORWARDED);
for (Ticket t : tickets) {
if (!t.getCreatorUUID().equals(player.getUniqueId())) continue;
if (t.isClaimerNotified()) continue; // wurde schon informiert
if (t.isClaimerNotified()) continue;
String claimerName = t.getClaimerName() != null ? t.getClaimerName() : "Support";
final String name = claimerName;
@@ -142,63 +154,171 @@ public class TicketManager {
/**
* Benachrichtigt den Ersteller, wenn sein Ticket weitergeleitet wurde.
* BungeeCord: Cross-Server-Zustellung.
*/
public void notifyCreatorForwarded(Ticket ticket) {
Player creator = Bukkit.getPlayer(ticket.getCreatorUUID());
if (creator != null && creator.isOnline()) {
String forwardedTo = ticket.getForwardedToName() != null ? ticket.getForwardedToName() : "einen Supporter";
String msg = plugin.formatMessage("messages.ticket-forwarded-creator-notify")
.replace("{id}", String.valueOf(ticket.getId()))
.replace("{supporter}", forwardedTo);
creator.sendMessage(msg);
}
// Auch hier notified setzen
deliverToPlayer(ticket.getCreatorUUID(), ticket.getCreatorName(), msg);
// Auch bei Weiterleitung notified setzen
plugin.getDatabaseManager().markClaimerNotified(ticket.getId());
}
/**
* Sendet dem weitergeleiteten Supporter eine Benachrichtigung.
* BungeeCord: Zustellung auch wenn der Supporter auf einem anderen Server ist.
*/
public void notifyForwardedTo(Ticket ticket, String fromName) {
Player target = Bukkit.getPlayer(ticket.getForwardedToUUID());
if (target != null && target.isOnline()) {
if (ticket.getForwardedToUUID() == null) return;
String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unbekannt";
String msg = plugin.formatMessage("messages.ticket-forwarded-notify")
.replace("{player}", creatorName)
.replace("{id}", String.valueOf(ticket.getId()));
target.sendMessage(msg);
}
deliverToPlayer(ticket.getForwardedToUUID(), ticket.getForwardedToName(), msg);
plugin.getDiscordWebhook().sendTicketForwarded(ticket, fromName);
}
/**
* Benachrichtigt den Ersteller, wenn sein Ticket geschlossen wurde.
* BungeeCord: Cross-Server-Zustellung + Fallback in Pending-DB.
*/
public void notifyCreatorClosed(Ticket ticket) { notifyCreatorClosed(ticket, null); }
public void notifyCreatorClosed(Ticket ticket, String closerName) {
notifiedClosedTickets.add(ticket.getId());
// Bug-Fix: close_notified wird in der DB gespeichert kein In-Memory-Set mehr.
// Dadurch funktioniert der Check auch nach einem Server-Wechsel korrekt.
Bukkit.getScheduler().runTaskAsynchronously(plugin, () ->
plugin.getDatabaseManager().markCloseNotified(ticket.getId()));
Player creator = Bukkit.getPlayer(ticket.getCreatorUUID());
String comment = (ticket.getCloseComment() != null && !ticket.getCloseComment().isEmpty())
? ticket.getCloseComment() : "";
if (creator != null && creator.isOnline()) {
// 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()) {
// ─ Lokal online: direkt zustellen ────────────────────────────
creator.sendMessage(msg);
if (!comment.isEmpty())
creator.sendMessage(plugin.color("&7Kommentar des Supports: &f" + comment));
if (plugin.getConfig().getBoolean("rating-enabled", true)) {
creator.sendMessage(plugin.color("&8&m "));
creator.sendMessage(plugin.color("&6Wie zufrieden bist du mit dem Support?"));
creator.sendMessage(plugin.color("&a/ticket rate " + ticket.getId() + " good &7 👍 Gut"));
creator.sendMessage(plugin.color("&c/ticket rate " + ticket.getId() + " bad &7 👎 Schlecht"));
creator.sendMessage(plugin.color("&8&m "));
}
if (ratingMsg != null) creator.sendMessage(ratingMsg);
} else if (plugin.isBungeeCordEnabled()) {
// ─ BungeeCord: via Plugin-Messaging auf anderen Servern zustellen ─
// KEIN savePendingClosedNotification hier! Das würde bei Server-Wechsel
// als "Offline-Nachricht" doppelt angezeigt werden.
// BungeeCord's "Message"-Kanal erreicht den Spieler netzwerkweit sofern er online ist.
// Ist er wirklich offline, sieht er beim nächsten Login via PlayerJoinListener
// eine frische Benachrichtigung (close_notified=true verhindert Duplikate).
plugin.getBungeeMessenger().sendMessageToPlayer(
ticket.getCreatorUUID(), ticket.getCreatorName(), msg);
if (!comment.isEmpty())
plugin.getBungeeMessenger().sendMessageToPlayer(
ticket.getCreatorUUID(), ticket.getCreatorName(),
plugin.color("&7Kommentar des Supports: &f" + comment));
if (ratingMsg != null)
plugin.getBungeeMessenger().sendMessageToPlayer(
ticket.getCreatorUUID(), ticket.getCreatorName(), ratingMsg);
} else {
// Offline → ausstehende Benachrichtigung speichern
// ─ Standalone, Spieler offline: in Pending-DB speichern ──────
savePendingClosedNotification(ticket, comment);
}
String closer = closerName != null ? closerName : "Unbekannt";
plugin.getDiscordWebhook().sendTicketClosed(ticket, closer);
}
/**
* Bug-Fix: Nutzt jetzt close_notified aus der DB statt ein In-Memory-Set.
* Funktioniert damit auch nach Server-Wechseln in BungeeCord-Netzwerken korrekt.
*
* @deprecated Bitte stattdessen ticket.isCloseNotified() direkt prüfen,
* da das Ticket-Objekt aus der DB bereits den korrekten Wert hat.
*/
public boolean wasClosedNotificationSent(int ticketId) {
// 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)
@@ -207,16 +327,17 @@ public class TicketManager {
plugin.getDatabaseManager().addPendingNotification(ticket.getCreatorUUID(), pendingMsg));
}
String closer = closerName != null ? closerName : "Unbekannt";
plugin.getDiscordWebhook().sendTicketClosed(ticket, closer);
}
public boolean wasClosedNotificationSent(int ticketId) {
return notifiedClosedTickets.contains(ticketId);
}
// ─────────────────────────── Hilfsmethoden ─────────────────────────────
private String resolveClaimerName(Ticket ticket) {
if (ticket.getClaimerName() != null) return ticket.getClaimerName();
if (ticket.getClaimerUUID() != null) {
String name = Bukkit.getOfflinePlayer(ticket.getClaimerUUID()).getName();
if (name != null) return name;
}
return "Support";
}
public boolean hasReachedTicketLimit(UUID uuid) {
int max = plugin.getConfig().getInt("max-open-tickets-per-player", 2);
if (max <= 0) return false;
@@ -245,5 +366,11 @@ public class TicketManager {
player.sendMessage(plugin.color("&e/ticket stats &7 Statistiken anzeigen"));
}
player.sendMessage(plugin.color("&8&m "));
// BungeeCord-Status anzeigen
if (player.hasPermission("ticket.admin") && plugin.isBungeeCordEnabled()) {
player.sendMessage(plugin.color("&8[BungeeCord] &7Server: &b" + plugin.getServerName()
+ " &8| Cross-Server-Benachrichtigungen &aaktiv"));
}
}
}

View File

@@ -25,6 +25,13 @@ public class Ticket implements ConfigurationSerializable {
private double x, y, z;
private float yaw, pitch;
/**
* Name des Servers auf dem das Ticket erstellt wurde (BungeeCord-Netzwerk).
* Entspricht dem Wert aus config.yml → server-name.
* Standardwert: "unknown"
*/
private String serverName = "unknown";
private TicketStatus status;
private UUID claimerUUID;
private String claimerName;
@@ -46,6 +53,12 @@ public class Ticket implements ConfigurationSerializable {
private String playerRating = null;
private boolean claimerNotified = false;
/**
* Gibt an ob der Ersteller bereits über die Schließung informiert wurde.
* Wird in der DB gespeichert damit Server-Wechsel keine Duplikate erzeugen.
*/
private boolean closeNotified = false;
public Ticket() {}
public Ticket(UUID creatorUUID, String creatorName, String message, Location location) {
@@ -88,6 +101,9 @@ public class Ticket implements ConfigurationSerializable {
if (map.containsKey("priority")) this.priority = TicketPriority.fromString((String) map.get("priority"));
if (map.containsKey("playerRating")) this.playerRating = (String) map.get("playerRating");
if (map.containsKey("claimerNotified")) this.claimerNotified = (boolean) map.get("claimerNotified");
// BungeeCord: Server-Name laden (Fallback: "unknown")
if (map.containsKey("serverName")) this.serverName = (String) map.get("serverName");
if (map.containsKey("closeNotified")) this.closeNotified = (boolean) map.get("closeNotified");
}
@Override
@@ -112,6 +128,9 @@ public class Ticket implements ConfigurationSerializable {
map.put("priority", priority.name());
if (playerRating != null) map.put("playerRating", playerRating);
map.put("claimerNotified", claimerNotified);
// BungeeCord: Server-Name speichern
map.put("serverName", serverName);
map.put("closeNotified", closeNotified);
return map;
}
@@ -126,6 +145,8 @@ public class Ticket implements ConfigurationSerializable {
private static float toFloat(Object o) { return o instanceof Float f ? f : ((Number) o).floatValue(); }
private static long toLong(Object o) { return ((Number) o).longValue(); }
// ─────────────────────────── Getter & Setter ───────────────────────────
public int getId() { return id; }
public void setId(int id) { this.id = id; }
public UUID getCreatorUUID() { return creatorUUID; }
@@ -175,4 +196,14 @@ public class Ticket implements ConfigurationSerializable {
public boolean hasRating() { return playerRating != null; }
public boolean isClaimerNotified() { return claimerNotified; }
public void setClaimerNotified(boolean v) { this.claimerNotified = v; }
/** BungeeCord: Gibt den Server-Namen zurück, auf dem das Ticket erstellt wurde. */
public String getServerName() { return serverName != null ? serverName : "unknown"; }
/** BungeeCord: Setzt den Server-Namen (aus config.yml → server-name). */
public void setServerName(String v) { this.serverName = v != null ? v : "unknown"; }
/** Gibt an ob der Ersteller bereits über die Schließung informiert wurde (DB-persistent). */
public boolean isCloseNotified() { return closeNotified; }
/** Setzt den close_notified-Flag (wird in DB gespeichert). */
public void setCloseNotified(boolean v) { this.closeNotified = v; }
}

View File

@@ -17,6 +17,26 @@ version: "2.0"
# Debug-Modus (true = Logs in der Konsole)
debug: false
# ----------------------------------------------------
# BUNGEECORD (Cross-Server-Unterstützung)
# ----------------------------------------------------
# VORAUSSETZUNGEN:
# 1. In spigot.yml auf JEDEM Server: bungeecord: true
# 2. MySQL muss aktiviert sein (use-mysql: true)
# 3. Plugin auf JEDEM Spigot-Server installieren
# 4. Alle Server müssen dieselbe MySQL-Datenbank verwenden
#
# false = Normaler Single-Server-Modus (Standard)
# true = Cross-Server Benachrichtigungen aktiv
# ----------------------------------------------------
bungeecord: false
bungee-teleport-enabled: true
# Name dieses Servers im BungeeCord-Netzwerk.
# Wird in Tickets, GUI und Discord-Embeds angezeigt.
# Auf jedem Server ANDERS einstellen! (z.B. "survival", "creative", "skyblock")
server-name: "survival"
# ----------------------------------------------------
# SPEICHERPFAD & ARCHIV
# ----------------------------------------------------
@@ -156,6 +176,7 @@ discord:
show-position: true # Welt & Koordinaten im Embed anzeigen
show-category: true # Kategorie im Embed anzeigen
show-priority: true # Priorität im Embed anzeigen
show-server: true # BungeeCord: Server-Name im Embed anzeigen
role-ping: false # Rollen-Ping bei neuem Ticket senden
# ── Ticket geschlossen ──────────────────────────────────────────────────
@@ -166,6 +187,7 @@ discord:
footer: "TicketSystem"
show-category: true # Kategorie im Embed anzeigen
show-priority: true # Priorität im Embed anzeigen
show-server: true # BungeeCord: Server-Name im Embed anzeigen
role-ping: false # Rollen-Ping beim Schließen senden
# ── Ticket weitergeleitet ───────────────────────────────────────────────
@@ -176,6 +198,7 @@ discord:
footer: "TicketSystem"
show-category: true # Kategorie im Embed anzeigen
show-priority: true # Priorität im Embed anzeigen
show-server: true # BungeeCord: Server-Name im Embed anzeigen
role-ping: false # Rollen-Ping beim Weiterleiten senden
# ----------------------------------------------------

View File

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