17 Commits
1.0.2 ... 1.0.6

Author SHA1 Message Date
57a426a9c9 Update from Git Manager GUI 2026-02-21 22:26:13 +01:00
135d8b0fb3 Upload pom.xml via GUI 2026-02-21 21:26:12 +00:00
31c7a33cbb Upload pom.xml via GUI 2026-02-21 17:41:16 +00:00
301c0f1ce9 Update from Git Manager GUI 2026-02-21 18:41:15 +01:00
7ede377c07 Update from Git Manager GUI 2026-02-21 16:00:03 +01:00
834bd0e5e4 Upload pom.xml via GUI 2026-02-21 15:00:01 +00:00
f4bedaa288 README.md aktualisiert 2026-02-21 13:25:26 +00:00
8e7533a214 Upload pom.xml via GUI 2026-02-20 23:55:29 +00:00
b7de357e81 Update from Git Manager GUI 2026-02-21 00:55:27 +01:00
a91e17a097 README.md aktualisiert 2026-02-20 23:49:35 +00:00
12c9379797 Upload pom.xml via GUI 2026-02-20 17:38:44 +00:00
535b0aa2f3 Update from Git Manager GUI 2026-02-20 18:38:42 +01:00
d14646c5ae README.md aktualisiert 2026-02-20 16:49:53 +00:00
df6878db2f Update from Git Manager GUI 2026-02-20 12:31:38 +01:00
526cb8b442 Upload pom.xml via GUI 2026-02-20 11:31:37 +00:00
b930793c50 README.md aktualisiert 2026-02-20 11:29:55 +00:00
566941d687 README.md aktualisiert 2026-02-20 11:29:46 +00:00
19 changed files with 4758 additions and 1191 deletions

319
README.md
View File

@@ -1,219 +1,218 @@
<div align="center"> # TicketSystem
# 🎫 TicketSystem ![Version](https://img.shields.io/badge/Minecraft-1.18.x--1.21.x-green?style=for-the-badge) ![Java](https://img.shields.io/badge/Java-17+-orange?style=for-the-badge) ![Type](https://img.shields.io/badge/Type-Support-blue?style=for-the-badge)
### Das flexible, moderne Support- und Feedback-System für Minecraft-Server **TicketSystem** ist das flexible, moderne Support- und Feedback-Plugin für Minecraft-Server (Spigot/Paper 1.18.x1.21.x, Java 17+). Es bietet flexible Speicherung, automatische Backups & Migration, Export/Import, Statistiken, dynamische GUI, Kategorie- und Prioritäten-System, Bewertungs- und Kommentar-System, Discord-Webhook und volle BungeeCord-Unterstützung.
[![Minecraft](https://img.shields.io/badge/Minecraft-1.18.x--1.21.x-brightgreen?style=for-the-badge&logo=minecraft&logoColor=white)](https://www.spigotmc.org/)
[![Java](https://img.shields.io/badge/Java-17+-orange?style=for-the-badge&logo=openjdk&logoColor=white)](https://adoptium.net/)
[![Type](https://img.shields.io/badge/Type-Support%20Plugin-blue?style=for-the-badge)](https://github.com/)
[![License](https://img.shields.io/badge/License-All%20Rights%20Reserved-red?style=for-the-badge)](LICENSE)
**⭐ Zero-Lag · Production Ready · Fully Customizable ⭐**
[Features](#-features) · [Installation](#-installation) · [Konfiguration](#-konfiguration) · [Befehle](#-befehle--permissions) · [FAQ](#-faq) · [Support](#-support)
</div>
--- ---
## 📋 Über TicketSystem ## Features
**TicketSystem** ist die Komplettlösung für Support, Bug-Reports und Feedback auf deinem Minecraft-Server. Spieler erstellen Tickets direkt im Spiel Admins verwalten alles komfortabel per GUI oder Befehl. Optimiert für kleine und große Server, vollständig konfigurierbar und vollgepackt mit Profi-Features. - MySQL oder Datei-Speicherung (YAML/JSON oder MySQL/MariaDB, jederzeit umschaltbar)
- Automatische Backups & Migration
- Export/Import von Tickets
- Statistiken & Archivierung
- Rollenbasierter Archiv-Zugriff
- Konfigurierbare Speicherpfade
- Vollständige Validierung & Fehlerausgaben
- Debug-Modus & Versionsprüfung
- Anpassbare Nachrichten, Farben, Limits, Speicherpfade, Archiv-Intervall, Cooldowns, Rechte
- Dynamische GUI mit Seiten-System
- Kategorie- und Prioritäten-System
- Bewertungs- und Kommentar-System
- Offline-Benachrichtigungen
- Discord-Webhook mit Embeds & Rollen-Ping
- Blacklist für Spieler
- Performance: asynchron, ressourcenschonend
- Erweiterbarkeit: viele Hooks
- **BungeeCord-Unterstützung**: serverübergreifende Tickets, Teleports, Weiterleitungen, Benachrichtigungen
--- ---
## ✨ Features ## Installation & Setup
| Feature | Beschreibung | 1. TicketSystem.jar in den plugins-Ordner legen und Server starten
|---|---| 2. config.yml anpassen (Speicherorte, Nachrichten, Limits, Farben, MySQL-Daten etc.)
| 🗄️ **MySQL & Datei-Speicherung** | YAML/JSON oder MySQL/MariaDB jederzeit umschaltbar, Migration & Backup inklusive | 3. /ticket-Befehle nutzen
| 🔄 **Automatische Migration** | Sicheres Wechseln zwischen Speicherarten, Datenverlust ausgeschlossen |
| 📤 **Export / Import** | Tickets einfach zwischen Servern oder Instanzen übertragen |
| 📊 **Statistiken & Archivierung** | Übersichtliche Auswertung, automatische & manuelle Archivierung nach Zeitplan |
| ✅ **Vollständige Validierung** | Fehlerhafte Tickets werden erkannt, gemeldet und übersprungen |
| 🐛 **Debug-Modus** | Ausführliche Logs für Entwickler und Admins, erkennt veraltete `config.yml` |
| 🖥️ **Dynamische GUI** | Passt sich automatisch der Ticketanzahl an bis zu 54 Tickets pro Seite mit Blättern |
| ⚡ **Performance** | Alle Operationen laufen asynchron optimiert für große Server |
| 🔧 **Komplett anpassbar** | Nachrichten, Farben, Limits, Cooldowns, Rechte alles in der `config.yml` |
| 🧪 **Unit-Tests** | Getestete Speicher-Logik für maximale Zuverlässigkeit |
--- ---
## 📦 Installation ## Befehle & Rechte
> **Voraussetzungen:** Paper / Spigot / Purpur `1.18.x 1.21.x` · Java `17+` · optional MySQL/MariaDB ### Übersicht der Befehle
**Schritt 1 Plugin installieren** | Befehl | Beschreibung | Nutzergruppe |
``` |-------------------------------------|---------------------------------------------------|----------------------|
1. Lade die neueste TicketSystem.jar von den Releases herunter | /ticket | Hilfe & Übersicht | Spieler, Support |
2. Verschiebe die .jar in den /plugins Ordner deines Servers | /ticket create [Kategorie] [Priorität] <Text> | Ticket erstellen | Spieler |
3. Starte den Server neu (kein /reload verwenden!) | /ticket list | Eigene Tickets in der GUI anzeigen | Spieler |
4. Die Konfigurationsdateien werden automatisch generiert | /ticket comment <ID> <Nachricht> | Kommentar hinzufügen | Spieler, Support |
``` | /ticket rate <ID> <good|bad> | Support bewerten | Spieler |
| /ticket claim <ID> | Ticket annehmen | Support/Admin |
| /ticket close <ID> [Kommentar] | Ticket schließen | Support/Admin |
| /ticket forward <ID> <Spieler> | Ticket weiterleiten | Support/Admin |
| /ticket setpriority <ID> <low|normal|high|urgent> | Priorität ändern | Support/Admin |
| /ticket reload | Konfiguration neu laden | Support/Admin |
| /ticket stats | Statistiken anzeigen | Support/Admin |
| /ticket archive | Tickets archivieren | Support/Admin |
| /ticket blacklist <add|remove|list> [Spieler] [Grund] | Blacklist verwalten | Support/Admin |
| /ticket migrate <tomysql|tofile> | Speicherart wechseln | Support/Admin |
| /ticket export <Dateiname> | Tickets exportieren | Support/Admin |
| /ticket import <Dateiname> | Tickets importieren | Support/Admin |
| /ticket teleport <ID> | Teleport zu Ticket (BungeeCord) | Support/Admin |
**Schritt 2 Konfiguration anpassen** ### Rechte
```
1. Öffne plugins/TicketSystem/config.yml
2. Passe Speicherpfade, Nachrichten, Limits und Farben an
3. Aktiviere MySQL falls gewünscht und trage Zugangsdaten ein
4. Nutze /ticket reload um Änderungen zu übernehmen
```
**Fertig!** Dein Support-System ist einsatzbereit. 🎉 | Permission | Beschreibung | Standard |
|-------------------|---------------------------------------------------|------------------|
| ticket.create | Ticket erstellen | alle Spieler |
| ticket.support | Tickets einsehen, claimen, schließen, Priorität | manuell vergeben |
| ticket.archive | Archiv öffnen, Tickets löschen | manuell vergeben |
| ticket.admin | Voller Zugriff (inkl. Weiterleitung, Reload, Blacklist) | OP |
> ticket.archive ist nicht in ticket.admin enthalten und muss explizit vergeben werden.
--- ---
## ⚙️ Konfiguration ## Kategorie & Priorität
<details> Beim Erstellen eines Tickets können Kategorie und Priorität optional angegeben werden. Die folgende Tabelle zeigt die Möglichkeiten und Beispiele:
<summary><b>📄 Beispiel: config.yml (klicken zum Ausklappen)</b></summary>
```yaml | Befehl | Kategorie | Priorität |
# TicketSystem - Hauptkonfiguration |----------------------------------------|-------------|-----------|
# © 2026 Viper Plugins | /ticket create <Text> | Standard | NORMAL |
| /ticket create bug <Text> | Bug | NORMAL |
| /ticket create high <Text> | Standard | HIGH |
| /ticket create bug high <Text> | Bug | HIGH |
| /ticket create question urgent <Text> | Frage | URGENT |
version: "2.0" **Verfügbare Prioritäten:**
debug: false - low
- normal
- high
- urgent
# Speicherung (auch deutsch: niedrig, hoch, dringend)
data-file: "data.yml"
archive-file: "archive.yml"
use-mysql: false
use-json: false
# MySQL (nur wenn use-mysql: true) **Kategorien und Aliases** sind frei in der config.yml konfigurierbar.
mysql:
host: "localhost"
port: 3306
database: "tickets"
user: "root"
password: "password"
useSSL: false
# Archivierung
auto-archive-interval-hours: 24 # 0 = deaktiviert
# Allgemein
prefix: "&8[&6Ticket&8] &r"
ticket-cooldown: 60 # Sekunden zwischen Tickets
max-description-length: 100
max-open-tickets-per-player: 2
```
</details>
--- ---
## 💬 Befehle & Permissions ## Discord-Webhook
### Spieler-Befehle - Embeds mit Kategorie & Priorität
| Befehl | Beschreibung | Permission | - Rollen-Ping pro Nachrichtentyp
|---|---|---| - Drei Ereignisse: neues Ticket, Ticket geschlossen, Ticket weitergeleitet
| `/ticket` | GUI mit allen offenen Tickets öffnen | `ticket.use` |
| `/ticket create <Nachricht>` | Neues Ticket erstellen | `ticket.use` |
| `/ticket close <ID>` | Eigenes Ticket schließen | `ticket.use` |
### Admin-Befehle Konfiguration in config.yml:
| Befehl | Beschreibung | Permission |
|---|---|---|
| `/ticket claim <ID>` | Ticket übernehmen | `ticket.admin` |
| `/ticket forward <ID> <Spieler>` | Ticket weiterleiten | `ticket.admin` |
| `/ticket archive` | Tickets manuell archivieren | `ticket.admin` |
| `/ticket export <Datei>` | Tickets exportieren | `ticket.admin` |
| `/ticket import <Datei>` | Tickets importieren | `ticket.admin` |
| `/ticket migrate <tomysql\|tofile>` | Speicherart migrieren | `ticket.admin` |
| `/ticket stats` | Statistiken anzeigen | `ticket.admin` |
| `/ticket reload` | Konfiguration neu laden | `ticket.admin` |
### Permissions-Übersicht discord:
``` enabled: true
ticket.use → Ticket erstellen und eigene Tickets verwalten (Standard für alle Spieler) webhook-url: "https://discord.com/api/webhooks/..."
ticket.admin → Zugriff auf alle Admin- und Management-Funktionen role-ping-id: "123456789012345678"
``` messages:
new-ticket:
role-ping: true
show-category: true
show-priority: true
ticket-closed:
enabled: true
role-ping: false
--- ---
## ❓ FAQ ## BungeeCord-Unterstützung
<details> TicketSystem bietet volle Unterstützung für BungeeCord-Netzwerke:
<summary><b>Kann ich zwischen MySQL und Datei-Speicherung wechseln?</b></summary>
Ja! Einfach per `/ticket migrate tomysql` oder `/ticket migrate tofile`. Das Plugin migriert alle Daten automatisch und sicher kein Datenverlust. - Tickets von jedem Server im Netzwerk
</details> - Teleport zu Tickets auf anderen Servern (/ticket teleport <ID>)
- Tickets im Archiv und GUI serverübergreifend
- Benachrichtigungen an alle Server
- Discord-Webhooks zeigen Server-Namen
- Tickets an Supporter auf anderen Servern weiterleiten
- Teleport funktioniert auch zwischen Servern
<details> **Voraussetzungen:**
<summary><b>Wie viele Tickets passen in die GUI?</b></summary> - spigot.yml: bungeecord: true
- config.yml: bungeecord: true, server-name pro Server
- TicketSystem.jar auf allen Spigot-Servern
- Alle Server nutzen dieselbe MySQL-Datenbank
Bis zu 54 Tickets pro Seite. Bei mehr Tickets wird automatisch geblättert. **Cross-Server-Befehle:**
</details> - /ticket teleport <ID>
- /ticket forward <ID> <Spieler>
- /ticket archive
- /ticket list
<details> **Tipps:**
<summary><b>Werden automatisch Backups erstellt?</b></summary> - Server-Name erscheint in GUI & Discord
- Zielspieler muss online sein
Ja, bei jedem Speicherwechsel und regelmäßig nach dem konfigurierten Archiv-Intervall. - Funktionen auch im Einzelserver-Modus
</details> - Bei Problemen: gleiche MySQL, Kanäle in plugin.yml prüfen
<details>
<summary><b>Wie aktiviere ich den Debug-Modus?</b></summary>
Setze `debug: true` in der `config.yml` und nutze anschließend `/ticket reload`.
</details>
<details>
<summary><b>Wie exportiere/importiere ich Tickets?</b></summary>
Mit `/ticket export <Dateiname>` und `/ticket import <Dateiname>` ideal für Server-Umzüge oder Testumgebungen.
</details>
--- ---
## 📊 Vergleich ## Vergleich mit anderen Plugins
| | **TicketSystem** | SimpleTickets | AdvancedTickets | TicketSystem hebt sich durch viele Alleinstellungsmerkmale von anderen Ticket-Plugins ab. Die folgende Tabelle zeigt die wichtigsten Unterschiede:
|---|:---:|:---:|:---:|
| Speicher-Migration | ✅ Vollständig | ⚠️ Nur manuell | ❌ | | Feature | TicketSystem | SimpleTickets | AdvancedTickets |
| Automatische Backups | ✅ | ⚠️ Teilweise | ❌ | |-------------------------|:------------:|:-------------:|:---------------:|
| Dynamische GUI | ✅ Modern | ⚠️ Basic | ❌ | | Speicher-Migration | ✔️ | ⚠️ | ✖️ |
| Archivierung | ✅ Auto & manuell | ⚠️ Nur manuell | ❌ | | Automatische Backups | ✔️ | ⚠️ | ✖️ |
| Export / Import | ✅ | ❌ | ❌ | | GUI mit Kategorien | ✔️ | ⚠️ | ✖️ |
| Debug-Modus | ✅ | ❌ | ❌ | | Archivierung | ✔️ | ⚠️ | ✖️ |
| Update-Checker | ✅ | ❌ | ❌ | | Rollenbasierter Archiv | ✔️ | ✖️ | ✖️ |
| Unit-Tests | ✅ | ❌ | ❌ | | Kategorie-System | ✔️ | ✖️ | ✖️ |
| Prioritäten-System | ✔️ | ✖️ | ✖️ |
| Offline-Benachrichtigungen| ✔️ | ✖️ | ✖️ |
| Discord-Webhook | ✔️ | ✖️ | ✖️ |
| Bewertungs-System | ✔️ | ✖️ | ✖️ |
| Update-Checker | ✔️ | ✖️ | ✖️ |
| BungeeCord-Unterstützung | ✔️ | ✖️ | ✖️ |
Legende:
- ✔️ = Vollständige Unterstützung
- ⚠️ = Eingeschränkte oder fehleranfällige Unterstützung
- ✖️ = Nicht vorhanden
--- ---
## 🆘 Support ## FAQ
<div align="center"> **Kann ich zwischen MySQL und Datei-Speicherung wechseln?**
> Ja! Mit /ticket migrate tomysql oder /ticket migrate tofile werden alle Daten automatisch migriert.
Hast du Fragen, einen Bug gefunden oder eine Feature-Idee? **Wie konfiguriere ich eigene Kategorien?**
> In der config.yml unter categories: Name, Farbe, Material und Aliases frei wählbar. Änderungen mit /ticket reload übernehmen.
[![Discord](https://img.shields.io/badge/Discord-Support%20beitreten-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.com/invite/FdRs4BRd8D) **Was passiert mit Benachrichtigungen wenn ein Spieler offline ist?**
> Alle Benachrichtigungen werden gespeichert und beim nächsten Login angezeigt.
**Wir antworten in der Regel innerhalb von 24 Stunden!** **Wie ändere ich die Priorität eines Tickets?**
> Als Support/Admin per Befehl /ticket setpriority <ID> <Priorität> oder direkt in der GUI.
Bitte öffne für Bug-Reports ein Issue. **Wie aktiviere ich den Debug-Modus?**
> debug: true in der config.yml setzen.
</div> **Wer darf das Ticket-Archiv sehen?**
> Nur Spieler mit ticket.archive. Muss explizit vergeben werden.
**Wie funktioniert Teleport bei BungeeCord?**
> Mit /ticket teleport <ID> wirst du automatisch auf den richtigen Server und zur Ticket-Position teleportiert.
--- ---
## 📜 Kompatibilität ## Support, Community & Motivation
| Plattform | Version | Du hast Fragen, brauchst Hilfe oder möchtest Feedback geben?
|---|---|
| Paper | ✅ 1.18.x 1.21.x |
| Spigot | ✅ 1.18.x 1.21.x |
| Purpur | ✅ 1.18.x 1.21.x |
| Folia | ❌ Nicht unterstützt |
--- - [Discord Support](https://discord.com/invite/FdRs4BRd8D)
- [Git Issues](https://git.viper.ipv64.net/M_Viper/TicketSystem/issues)
<div align="center"> Wir antworten in der Regel innerhalb von 24 Stunden!
**© 2026 Viper Plugins · TicketSystem · Alle Rechte vorbehalten** **Dein Feedback zählt:**
Wenn TicketSystem deinen Server bereichert hat, freuen wir uns über eine 5-Sterne Bewertung auf SpigotMC!
Wenn TicketSystem deinen Server bereichert hat, freuen wir uns über eine Bewertung auf spigotmc! Jede Rückmeldung hilft, das Plugin weiter zu verbessern und die Community zu stärken.
</div>

View File

@@ -6,7 +6,7 @@
<groupId>de.ticketsystem</groupId> <groupId>de.ticketsystem</groupId>
<artifactId>TicketSystem</artifactId> <artifactId>TicketSystem</artifactId>
<version>1.0.1</version> <version>1.0.5</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>TicketSystem</name> <name>TicketSystem</name>

View File

@@ -1,11 +1,14 @@
package de.ticketsystem; package de.ticketsystem;
import de.ticketsystem.bungee.BungeeMessenger;
import de.ticketsystem.commands.TicketCommand; import de.ticketsystem.commands.TicketCommand;
import de.ticketsystem.database.DatabaseManager; import de.ticketsystem.database.DatabaseManager;
import de.ticketsystem.discord.DiscordWebhook;
import de.ticketsystem.gui.TicketGUI; import de.ticketsystem.gui.TicketGUI;
import de.ticketsystem.listeners.PlayerJoinListener; import de.ticketsystem.listeners.PlayerJoinListener;
import de.ticketsystem.manager.CategoryManager;
import de.ticketsystem.manager.TicketManager; import de.ticketsystem.manager.TicketManager;
import de.ticketsystem.model.Ticket;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
@@ -16,18 +19,58 @@ public class TicketPlugin extends JavaPlugin {
private static TicketPlugin instance; private static TicketPlugin instance;
private boolean debug; private boolean debug;
/**
* Name dieses Servers im BungeeCord-Netzwerk.
* Konfigurierbar in config.yml → server-name
* Wird in Tickets gespeichert und in Benachrichtigungen angezeigt.
*/
private String serverName;
private DatabaseManager databaseManager; private DatabaseManager databaseManager;
private TicketManager ticketManager; private TicketManager ticketManager;
private CategoryManager categoryManager;
private TicketGUI ticketGUI; private TicketGUI ticketGUI;
private DiscordWebhook discordWebhook;
private BungeeMessenger bungeeMessenger;
@Override @Override
public void onEnable() { public void onEnable() {
instance = this; instance = this;
// Config speichern falls nicht vorhanden
saveDefaultConfig(); saveDefaultConfig();
// Update-Checker (Spigot Resource-ID anpassen!) // 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; int resourceId = 132757;
new UpdateChecker(this, resourceId).getVersion(version -> { new UpdateChecker(this, resourceId).getVersion(version -> {
String current = getDescription().getVersion(); String current = getDescription().getVersion();
@@ -35,12 +78,11 @@ public class TicketPlugin extends JavaPlugin {
String msg = ChatColor.translateAlternateColorCodes('&', String msg = ChatColor.translateAlternateColorCodes('&',
"&6[TicketSystem] &eEs ist eine neue Version verfügbar: &a" + version + " &7(aktuell: " + current + ")"); "&6[TicketSystem] &eEs ist eine neue Version verfügbar: &a" + version + " &7(aktuell: " + current + ")");
getLogger().info("Es ist eine neue Version verfügbar: " + version + " (aktuell: " + current + ")"); getLogger().info("Es ist eine neue Version verfügbar: " + version + " (aktuell: " + current + ")");
// Sende Nachricht an alle Admins (online) mit 1 Sekunde Verzögerung
getServer().getScheduler().runTaskLater(this, () -> { getServer().getScheduler().runTaskLater(this, () -> {
getServer().getOnlinePlayers().stream() getServer().getOnlinePlayers().stream()
.filter(p -> p.hasPermission("ticket.admin")) .filter(p -> p.hasPermission("ticket.admin"))
.forEach(p -> p.sendMessage(msg)); .forEach(p -> p.sendMessage(msg));
}, 20L); // 20 Ticks = 1 Sekunde }, 20L);
} else { } else {
getLogger().info("TicketSystem ist aktuell (Version " + current + ")"); getLogger().info("TicketSystem ist aktuell (Version " + current + ")");
} }
@@ -50,46 +92,52 @@ public class TicketPlugin extends JavaPlugin {
String configVersion = getConfig().getString("version", ""); String configVersion = getConfig().getString("version", "");
String expectedVersion = "2.0"; String expectedVersion = "2.0";
if (!expectedVersion.equals(configVersion)) { if (!expectedVersion.equals(configVersion)) {
getLogger().warning("[WARNUNG] Die Version deiner config.yml (" + configVersion + ") stimmt nicht mit der erwarteten Version (" + expectedVersion + ") überein! Bitte prüfe, ob deine Konfiguration aktuell ist."); getLogger().warning("[WARNUNG] Die Version deiner config.yml (" + configVersion
+ ") stimmt nicht mit der erwarteten Version (" + expectedVersion + ") überein!");
} }
// Debug-Status aus Config lesen
debug = getConfig().getBoolean("debug", false); debug = getConfig().getBoolean("debug", false);
// Datenbankverbindung aufbauen // Datenbankverbindung
databaseManager = new DatabaseManager(this); databaseManager = new DatabaseManager(this);
if (!databaseManager.connect()) { if (!databaseManager.connect()) {
getLogger().severe("Konnte keine Datenbankverbindung herstellen! Plugin läuft im Datei-Modus weiter."); getLogger().severe("Konnte keine Datenbankverbindung herstellen! Plugin läuft im Datei-Modus weiter.");
if (isDebug()) getLogger().warning("[DEBUG] DatabaseManager.connect() fehlgeschlagen, Datei-Modus aktiviert.");
// Plugin bleibt aktiv, DatabaseManager wechselt auf Datei-Storage
} }
// Manager und GUI initialisieren // Manager, GUI & Discord-Webhook initialisieren
categoryManager = new CategoryManager(this);
ticketManager = new TicketManager(this); ticketManager = new TicketManager(this);
ticketGUI = new TicketGUI(this); ticketGUI = new TicketGUI(this);
discordWebhook = new DiscordWebhook(this);
// Commands registrieren if (getConfig().getBoolean("discord.enabled", false)) {
String url = getConfig().getString("discord.webhook-url", "");
if (url.isEmpty()) {
getLogger().warning("[DiscordWebhook] Aktiviert, aber keine Webhook-URL in der config.yml eingetragen!");
} else {
getLogger().info("[DiscordWebhook] Integration aktiv.");
}
}
// Commands & Events
TicketCommand ticketCommand = new TicketCommand(this); TicketCommand ticketCommand = new TicketCommand(this);
Objects.requireNonNull(getCommand("ticket")).setExecutor(ticketCommand); Objects.requireNonNull(getCommand("ticket")).setExecutor(ticketCommand);
Objects.requireNonNull(getCommand("ticket")).setTabCompleter(ticketCommand); Objects.requireNonNull(getCommand("ticket")).setTabCompleter(ticketCommand);
// Events registrieren
getServer().getPluginManager().registerEvents(new PlayerJoinListener(this), this); getServer().getPluginManager().registerEvents(new PlayerJoinListener(this), this);
getServer().getPluginManager().registerEvents(ticketGUI, this); getServer().getPluginManager().registerEvents(ticketGUI, this);
// Automatische Archivierung nach Zeitplan (Intervall in Stunden, Standard: 24h) // Automatische Archivierung
int archiveIntervalH = getConfig().getInt("auto-archive-interval-hours", 24); int archiveIntervalH = getConfig().getInt("auto-archive-interval-hours", 24);
if (archiveIntervalH > 0) { if (archiveIntervalH > 0) {
long ticks = archiveIntervalH * 60L * 60L * 20L; // Stunden → Ticks long ticks = archiveIntervalH * 60L * 60L * 20L;
getServer().getScheduler().runTaskTimer(this, () -> { getServer().getScheduler().runTaskTimer(this, () -> {
int archived = databaseManager.archiveClosedTickets(); int archived = databaseManager.archiveClosedTickets();
if (archived > 0) { if (archived > 0) {
getLogger().info("Automatische Archivierung: " + archived + " Tickets archiviert."); getLogger().info("Automatische Archivierung: " + archived + " Tickets archiviert.");
if (isDebug()) getLogger().info("[DEBUG] Archivierung ausgeführt, " + archived + " Tickets verschoben.");
} }
}, ticks, ticks); }, ticks, ticks);
getLogger().info("Automatische Archivierung alle " + archiveIntervalH + " Stunden aktiviert."); getLogger().info("Automatische Archivierung alle " + archiveIntervalH + " Stunden aktiviert.");
if (isDebug()) getLogger().info("[DEBUG] Archivierungs-Timer gesetzt: alle " + archiveIntervalH + " Stunden.");
} }
getLogger().info("TicketSystem erfolgreich gestartet!"); getLogger().info("TicketSystem erfolgreich gestartet!");
@@ -97,26 +145,22 @@ public class TicketPlugin extends JavaPlugin {
@Override @Override
public void onDisable() { public void onDisable() {
if (databaseManager != null) { // Plugin-Messaging-Kanäle abmelden
databaseManager.disconnect(); getServer().getMessenger().unregisterOutgoingPluginChannel(this);
} getServer().getMessenger().unregisterIncomingPluginChannel(this);
if (databaseManager != null) databaseManager.disconnect();
getLogger().info("TicketSystem wurde deaktiviert."); getLogger().info("TicketSystem wurde deaktiviert.");
} }
// ─────────────────────────── Hilfsmethoden ───────────────────────────── // ─────────────────────────── Hilfsmethoden ─────────────────────────────
/**
* Formatiert eine Nachricht aus der Config mit Prefix und Farben.
*/
public String formatMessage(String path) { public String formatMessage(String path) {
String prefix = color(getConfig().getString("prefix", "&8[&6Ticket&8] &r")); String prefix = color(getConfig().getString("prefix", "&8[&6Ticket&8] &r"));
String message = getConfig().getString(path, "&cNachricht nicht gefunden: " + path); String message = getConfig().getString(path, "&cNachricht nicht gefunden: " + path);
return prefix + color(message); return prefix + color(message);
} }
/**
* Konvertiert Farbcodes (&x → §x).
*/
public String color(String text) { public String color(String text) {
return ChatColor.translateAlternateColorCodes('&', text); return ChatColor.translateAlternateColorCodes('&', text);
} }
@@ -126,10 +170,21 @@ public class TicketPlugin extends JavaPlugin {
public static TicketPlugin getInstance() { return instance; } public static TicketPlugin getInstance() { return instance; }
public DatabaseManager getDatabaseManager() { return databaseManager; } public DatabaseManager getDatabaseManager() { return databaseManager; }
public TicketManager getTicketManager() { return ticketManager; } public TicketManager getTicketManager() { return ticketManager; }
public CategoryManager getCategoryManager() { return categoryManager; }
public TicketGUI getTicketGUI() { return ticketGUI; } public TicketGUI getTicketGUI() { return ticketGUI; }
public DiscordWebhook getDiscordWebhook() { return discordWebhook; }
public BungeeMessenger getBungeeMessenger() { return bungeeMessenger; }
public boolean isDebug() { return debug; }
/** /**
* Gibt zurück, ob der Debug-Modus aktiv ist (aus config.yml) * BungeeCord: Gibt den konfigurierten Server-Namen zurück.
* Entspricht dem Wert aus config.yml → server-name.
*/ */
public boolean isDebug() { return debug; } 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

@@ -26,7 +26,11 @@ public class UpdateChecker {
Bukkit.getScheduler().runTaskAsynchronously(this.plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(this.plugin, () -> {
try (InputStream is = new URL("https://api.spigotmc.org/legacy/update.php?resource=" + this.resourceId).openStream(); Scanner scann = new Scanner(is)) { try (InputStream is = new URL("https://api.spigotmc.org/legacy/update.php?resource=" + this.resourceId).openStream(); Scanner scann = new Scanner(is)) {
if (scann.hasNext()) { if (scann.hasNext()) {
consumer.accept(scann.next()); String latest = scann.next();
plugin.getLogger().info("[UpdateChecker] Spigot-API Rückgabe: '" + latest + "'");
consumer.accept(latest);
} else {
plugin.getLogger().warning("[UpdateChecker] Keine Version von Spigot erhalten!");
} }
} catch (IOException e) { } catch (IOException e) {
plugin.getLogger().info("Unable to check for updates: " + e.getMessage()); plugin.getLogger().info("Unable to check for updates: " + e.getMessage());

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();
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,389 @@
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;
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;
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 Webhook-Events
// ─────────────────────────────────────────────────────────────────────────
public void sendNewTicket(Ticket ticket) {
if (!isEnabled()) return;
String webhookUrl = getWebhookUrl();
if (webhookUrl == null) return;
// 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);
// 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.add(new Field("🏷️ Kategorie", cat.getName(), true));
}
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 (showPos) {
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));
}
// 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);
}
// ─────────────────────────────────────────────────────────────────────────
public void sendTicketClosed(Ticket ticket, String closerName) {
if (!isEnabled()) return;
if (!plugin.getConfig().getBoolean("discord.messages.ticket-closed.enabled", false)) return;
String webhookUrl = getWebhookUrl();
if (webhookUrl == null) return;
// 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);
// 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.add(new Field("🏷️ Kategorie", cat.getName(), true));
}
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);
}
// ─────────────────────────────────────────────────────────────────────────
public void sendTicketForwarded(Ticket ticket, String fromName) {
if (!isEnabled()) return;
if (!plugin.getConfig().getBoolean("discord.messages.ticket-forwarded.enabled", false)) return;
String webhookUrl = getWebhookUrl();
if (webhookUrl == null) return;
// 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);
// 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.add(new Field("🏷️ Kategorie", cat.getName(), true));
}
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);
}
// ─────────────────────────────────────────────────────────────────────────
// 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);
}
private String getWebhookUrl() {
String url = plugin.getConfig().getString("discord.webhook-url", "");
return url.isEmpty() ? null : url;
}
private String buildRolePing() {
String roleId = plugin.getConfig().getString("discord.role-ping-id", "").trim();
return roleId.isEmpty() ? "" : "<@&" + roleId + ">";
}
private String getPriorityEmoji(TicketPriority priority) {
return switch (priority) {
case LOW -> "🟢";
case NORMAL -> "🟡";
case HIGH -> "🟠";
case URGENT -> "🔴";
};
}
private void sendAsync(String webhookUrl, String json) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
URL url = new URL(webhookUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("User-Agent", "TicketSystem-Plugin");
conn.setDoOutput(true);
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
try (OutputStream os = conn.getOutputStream()) {
os.write(json.getBytes(StandardCharsets.UTF_8));
}
int responseCode = conn.getResponseCode();
if (plugin.isDebug()) {
plugin.getLogger().info("[DEBUG][DiscordWebhook] Response: " + responseCode);
if (responseCode != 200 && responseCode != 204) {
plugin.getLogger().info("[DEBUG][DiscordWebhook] Payload: " + json);
}
}
if (responseCode != 200 && responseCode != 204) {
plugin.getLogger().warning("[DiscordWebhook] Unerwarteter Response-Code: " + responseCode);
}
conn.disconnect();
} catch (Exception e) {
plugin.getLogger().warning("[DiscordWebhook] Fehler beim Senden: " + e.getMessage());
if (plugin.isDebug()) e.printStackTrace();
}
});
}
}

View File

@@ -2,187 +2,886 @@ package de.ticketsystem.gui;
import de.ticketsystem.TicketPlugin; import de.ticketsystem.TicketPlugin;
import de.ticketsystem.model.Ticket; import de.ticketsystem.model.Ticket;
import de.ticketsystem.manager.CategoryManager;
import de.ticketsystem.model.ConfigCategory;
import de.ticketsystem.model.TicketComment;
import de.ticketsystem.model.TicketPriority;
import de.ticketsystem.model.TicketStatus; import de.ticketsystem.model.TicketStatus;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.AsyncPlayerChatEvent;
import org.bukkit.inventory.Inventory; import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.ItemMeta;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.ArrayList; import java.util.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public class TicketGUI implements Listener { public class TicketGUI implements Listener {
// ─────────────────────────── Titel-Konstanten ──────────────────────────
private static final String GUI_TITLE = "§8§lTicket-Übersicht"; private static final String GUI_TITLE = "§8§lTicket-Übersicht";
private static final String CLOSED_GUI_TITLE = "§8§lTicket-Archiv";
private static final String PLAYER_GUI_TITLE = "§8§lMeine Tickets";
private static final String DETAIL_GUI_TITLE = "§8§lTicket-Details";
private static final String ARCHIVE_PERMISSION = "ticket.archive";
/** Ticket-Slots pro Seite (Reihen 04, Slots 044) */
private static final int PAGE_SIZE = 45;
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("dd.MM.yyyy HH:mm"); private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("dd.MM.yyyy HH:mm");
private final TicketPlugin plugin; private final TicketPlugin plugin;
// Speichert welcher Spieler welches Ticket an welchem Slot hat // ─────────────────────────── State-Maps ────────────────────────────────
/** Admin-Übersicht: Slot → Ticket */
private final Map<UUID, Map<Integer, Ticket>> playerSlotMap = new HashMap<>(); private final Map<UUID, Map<Integer, Ticket>> playerSlotMap = new HashMap<>();
/** Admin-Archiv: Slot → Ticket */
private final Map<UUID, Map<Integer, Ticket>> playerClosedSlotMap = new HashMap<>();
/** Spieler-GUI: Slot → Ticket */
private final Map<UUID, Map<Integer, Ticket>> playerOwnSlotMap = new HashMap<>();
/** Detail-Ansicht: UUID → Ticket */
private final Map<UUID, Ticket> detailTicketMap = new HashMap<>();
public TicketGUI(TicketPlugin plugin) { /** Aktuelle Seite pro Spieler (Admin, Archiv, Spieler) */
this.plugin = plugin; private final Map<UUID, Integer> adminPage = new HashMap<>();
} private final Map<UUID, Integer> archivePage= new HashMap<>();
private final Map<UUID, Integer> playerPage = new HashMap<>();
// ─────────────────────────── GUI öffnen ──────────────────────────────── /** Kategorie-Filter für die Admin-GUI: null = alle */
private final Map<UUID, ConfigCategory> categoryFilter = new HashMap<>();
public void openGUI(Player player) { /** Wartet auf Chat-Eingabe für Close-Kommentar */
List<Ticket> tickets = plugin.getDatabaseManager().getTicketsByStatus( private final Map<UUID, Integer> awaitingComment = new HashMap<>();
/** Aus Archiv heraus in Detail gegangen */
private final Set<UUID> viewingFromArchive = new HashSet<>();
// ─────────────────────────── Konstruktor ───────────────────────────────
public TicketGUI(TicketPlugin plugin) { this.plugin = plugin; }
// ═══════════════════════════════════════════════════════════════════════
// ADMIN / SUPPORTER GUI (paginiert, mit Kategorie-Filter)
// ═══════════════════════════════════════════════════════════════════════
public void openGUI(Player player) { openGUI(player, adminPage.getOrDefault(player.getUniqueId(), 0)); }
public void openGUI(Player player, int page) {
adminPage.put(player.getUniqueId(), page);
List<Ticket> all = plugin.getDatabaseManager().getTicketsByStatus(
TicketStatus.OPEN, TicketStatus.CLAIMED, TicketStatus.FORWARDED); TicketStatus.OPEN, TicketStatus.CLAIMED, TicketStatus.FORWARDED);
if (tickets.isEmpty()) { // Kategorie-Filter anwenden
player.sendMessage(plugin.formatMessage("messages.no-open-tickets")); ConfigCategory filter = categoryFilter.getOrDefault(player.getUniqueId(), null);
return; if (filter != null && plugin.getConfig().getBoolean("categories-enabled", true)) {
all.removeIf(t -> !t.getCategoryKey().equals(filter.getKey()));
} }
// Inventar-Größe: nächste Vielfaches von 9 (max. 54 Slots) // Priorität-Sortierung (URGENT → HIGH → NORMAL → LOW)
int size = Math.min(54, (int) (Math.ceil(tickets.size() / 9.0) * 9)); if (plugin.getConfig().getBoolean("priorities-enabled", true)) {
if (size < 9) size = 9; all.sort((a, b) -> b.getPriority().ordinal() - a.getPriority().ordinal());
}
Inventory inv = Bukkit.createInventory(null, size, GUI_TITLE); int totalPages = Math.max(1, (int) Math.ceil((double) all.size() / PAGE_SIZE));
page = Math.max(0, Math.min(page, totalPages - 1));
adminPage.put(player.getUniqueId(), page);
Inventory inv = Bukkit.createInventory(null, 54, GUI_TITLE);
Map<Integer, Ticket> slotMap = new HashMap<>(); Map<Integer, Ticket> slotMap = new HashMap<>();
for (int i = 0; i < tickets.size() && i < 54; i++) { int start = page * PAGE_SIZE;
Ticket ticket = tickets.get(i); for (int i = 0; i < PAGE_SIZE && (start + i) < all.size(); i++) {
ItemStack item = buildTicketItem(ticket); Ticket ticket = all.get(start + i);
inv.setItem(i, item); inv.setItem(i, buildAdminListItem(ticket));
slotMap.put(i, ticket); slotMap.put(i, ticket);
} }
// Trennlinie am Ende, wenn Platz fillAdminNavigation(inv, false, player, page, totalPages);
fillEmpty(inv);
playerSlotMap.put(player.getUniqueId(), slotMap); playerSlotMap.put(player.getUniqueId(), slotMap);
player.openInventory(inv); player.openInventory(inv);
} }
// ─────────────────────────── Item bauen ──────────────────────────────── // ═══════════════════════════════════════════════════════════════════════
// ADMIN ARCHIV GUI
// ═══════════════════════════════════════════════════════════════════════
private ItemStack buildTicketItem(Ticket ticket) { public void openClosedGUI(Player player) { openClosedGUI(player, archivePage.getOrDefault(player.getUniqueId(), 0)); }
// Material je nach Status
Material mat; public void openClosedGUI(Player player, int page) {
switch (ticket.getStatus()) { if (!player.hasPermission(ARCHIVE_PERMISSION)) {
case OPEN -> mat = Material.PAPER; player.sendMessage(plugin.color("&cDu hast keine Berechtigung, das Archiv zu öffnen."));
case CLAIMED -> mat = Material.YELLOW_DYE; return;
case FORWARDED -> mat = Material.ORANGE_DYE; }
default -> mat = Material.PAPER; archivePage.put(player.getUniqueId(), page);
List<Ticket> tickets = plugin.getDatabaseManager().getTicketsByStatus(TicketStatus.CLOSED);
int totalPages = Math.max(1, (int) Math.ceil((double) tickets.size() / PAGE_SIZE));
page = Math.max(0, Math.min(page, totalPages - 1));
archivePage.put(player.getUniqueId(), page);
Inventory inv = Bukkit.createInventory(null, 54, CLOSED_GUI_TITLE);
Map<Integer, Ticket> slotMap = new HashMap<>();
int start = page * PAGE_SIZE;
for (int i = 0; i < PAGE_SIZE && (start + i) < tickets.size(); i++) {
Ticket ticket = tickets.get(start + i);
inv.setItem(i, buildAdminListItem(ticket));
slotMap.put(i, ticket);
} }
ItemStack item = new ItemStack(mat); fillAdminNavigation(inv, true, player, page, totalPages);
ItemMeta meta = item.getItemMeta(); playerClosedSlotMap.put(player.getUniqueId(), slotMap);
if (meta == null) return item; player.openInventory(inv);
// Display-Name
meta.setDisplayName("§6§lTicket #" + ticket.getId() + " §r" + ticket.getStatus().getColored());
// Lore aufbauen
List<String> lore = new ArrayList<>();
lore.add("§8§m ");
lore.add("§7Ersteller: §e" + ticket.getCreatorName());
lore.add("§7Anliegen: §f" + ticket.getMessage());
lore.add("§7Erstellt: §e" + DATE_FORMAT.format(ticket.getCreatedAt()));
lore.add("§7Welt: §e" + ticket.getWorldName());
lore.add(String.format("§7Position: §e%.0f, %.0f, %.0f", ticket.getX(), ticket.getY(), ticket.getZ()));
if (ticket.getClaimerName() != null) {
lore.add("§8§m ");
lore.add("§7Geclaimt von: §a" + ticket.getClaimerName());
if (ticket.getClaimedAt() != null)
lore.add("§7Geclaimt am: §a" + DATE_FORMAT.format(ticket.getClaimedAt()));
}
if (ticket.getForwardedToName() != null) {
lore.add("§7Weitergeleitet an: §6" + ticket.getForwardedToName());
} }
lore.add("§8§m "); // ═══════════════════════════════════════════════════════════════════════
if (ticket.getStatus() == TicketStatus.OPEN) { // SPIELER-GUI (paginiert)
lore.add("§a§l» KLICKEN zum Claimen & Teleportieren"); // ═══════════════════════════════════════════════════════════════════════
public void openPlayerGUI(Player player) { openPlayerGUI(player, playerPage.getOrDefault(player.getUniqueId(), 0)); }
public void openPlayerGUI(Player player, int page) {
playerPage.put(player.getUniqueId(), page);
List<Ticket> all = plugin.getDatabaseManager().getTicketsByStatus(
TicketStatus.OPEN, TicketStatus.CLAIMED, TicketStatus.FORWARDED, TicketStatus.CLOSED);
List<Ticket> tickets = new ArrayList<>();
for (Ticket t : all) {
if (t.getCreatorUUID().equals(player.getUniqueId()) && !t.isPlayerDeleted()) tickets.add(t);
}
if (tickets.isEmpty()) {
player.sendMessage(plugin.color("&aDu hast aktuell keine Tickets."));
return;
}
int totalPages = Math.max(1, (int) Math.ceil((double) tickets.size() / PAGE_SIZE));
page = Math.max(0, Math.min(page, totalPages - 1));
playerPage.put(player.getUniqueId(), page);
Inventory inv = Bukkit.createInventory(null, 54, PLAYER_GUI_TITLE);
Map<Integer, Ticket> slotMap = new HashMap<>();
int start = page * PAGE_SIZE;
for (int i = 0; i < PAGE_SIZE && (start + i) < tickets.size(); i++) {
Ticket ticket = tickets.get(start + i);
inv.setItem(i, buildPlayerTicketItem(ticket));
slotMap.put(i, ticket);
}
// Nav-Leiste für Spieler-GUI (nur Prev/Next, kein Filter/Archiv)
fillPlayerNavigation(inv, page, totalPages);
playerOwnSlotMap.put(player.getUniqueId(), slotMap);
player.openInventory(inv);
}
// ═══════════════════════════════════════════════════════════════════════
// ADMIN DETAIL-GUI
// ═══════════════════════════════════════════════════════════════════════
public void openDetailGUI(Player player, Ticket ticket) {
Inventory inv = Bukkit.createInventory(null, 27, DETAIL_GUI_TITLE);
// Slot 4: Ticket-Info
inv.setItem(4, buildDetailInfoItem(ticket));
// ── 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 { } else {
lore.add("§e§l» KLICKEN zum Teleportieren"); 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)")));
} }
meta.setLore(lore); // Slot 12: Claimen / Löschen / Grau
item.setItemMeta(meta); if (ticket.getStatus() == TicketStatus.OPEN) {
return item; inv.setItem(12, buildActionItem(Material.LIME_WOOL, "§a§lTicket annehmen",
List.of("§7Nimmt dieses Ticket an", "§7und markiert es als bearbeitet.")));
} else if (ticket.getStatus() == TicketStatus.CLOSED && player.hasPermission(ARCHIVE_PERMISSION)) {
inv.setItem(12, buildActionItem(Material.BARRIER, "§4§lTicket permanent löschen",
List.of("§7Löscht dieses Ticket", "§7unwiderruflich aus der Datenbank.",
"§8§m ", "§c§lACHTUNG: §cNicht rückgängig zu machen!")));
} else {
inv.setItem(12, buildActionItem(Material.GRAY_WOOL, "§8Bereits angenommen",
List.of("§7Dieses Ticket wurde bereits", "§7angenommen.")));
} }
private void fillEmpty(Inventory inv) { // Slot 14: Schließen
ItemStack glass = new ItemStack(Material.GRAY_STAINED_GLASS_PANE); if (ticket.getStatus() != TicketStatus.CLOSED) {
ItemMeta meta = glass.getItemMeta(); inv.setItem(14, buildActionItem(Material.RED_WOOL, "§c§lTicket schließen",
if (meta != null) { meta.setDisplayName(" "); glass.setItemMeta(meta); } List.of("§7Schließt das Ticket.", "§8§m ", "§eKlick für Kommentar-Eingabe.")));
for (int i = 0; i < inv.getSize(); i++) { } else {
if (inv.getItem(i) == null) inv.setItem(i, glass); inv.setItem(14, buildActionItem(Material.GRAY_WOOL, "§8Bereits geschlossen",
} List.of("§7Dieses Ticket ist bereits", "§7geschlossen.")));
} }
// ─────────────────────────── Klick-Event ─────────────────────────────── // Slot 22: Kommentare anzeigen
inv.setItem(22, buildActionItem(Material.BOOK, "§e§lKommentare anzeigen",
List.of("§7Zeigt alle Nachrichten/Antworten", "§7zu diesem Ticket im Chat.")));
// Slot 20: Priorität ändern (nur wenn priorities-enabled und nicht geschlossen)
if (plugin.getConfig().getBoolean("priorities-enabled", true)
&& player.hasPermission("ticket.support")
&& ticket.getStatus() != TicketStatus.CLOSED) {
TicketPriority cur = ticket.getPriority();
List<String> prioLore = new ArrayList<>();
prioLore.add("§7Aktuell: " + cur.getColored());
prioLore.add("§8Klicken zum Wechseln");
prioLore.add("§8§m ");
for (TicketPriority p : TicketPriority.values())
prioLore.add((p == cur ? "§a» " : "§7 ") + p.getColored());
inv.setItem(20, buildActionItem(cur.getGuiMaterial(), "§6§lPriorität ändern", prioLore));
}
// Slot 16: Zurück
inv.setItem(16, buildActionItem(Material.ARROW, "§7§lZurück",
List.of("§7Zurück zur Ticket-Übersicht.")));
fillEmpty(inv);
detailTicketMap.put(player.getUniqueId(), ticket);
player.openInventory(inv);
}
// ═══════════════════════════════════════════════════════════════════════
// CLICK-EVENTS
// ═══════════════════════════════════════════════════════════════════════
@EventHandler @EventHandler
public void onInventoryClick(InventoryClickEvent event) { public void onInventoryClick(InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player player)) return; if (!(event.getWhoClicked() instanceof Player player)) return;
if (!event.getView().getTitle().equals(GUI_TITLE)) return; String title = event.getView().getTitle();
if (!title.equals(GUI_TITLE) && !title.equals(CLOSED_GUI_TITLE)
&& !title.equals(PLAYER_GUI_TITLE) && !title.equals(DETAIL_GUI_TITLE)) return;
event.setCancelled(true); event.setCancelled(true);
int slot = event.getRawSlot();
if (slot < 0) return;
// ── Admin Haupt-Übersicht ───────────────────────────────────────────
if (title.equals(GUI_TITLE)) {
handleAdminNavClick(player, slot, false);
if (slot < PAGE_SIZE) {
Map<Integer, Ticket> slotMap = playerSlotMap.get(player.getUniqueId()); Map<Integer, Ticket> slotMap = playerSlotMap.get(player.getUniqueId());
if (slotMap == null) return; if (slotMap == null) return;
Ticket ticket = slotMap.get(slot);
if (ticket != null) {
viewingFromArchive.remove(player.getUniqueId());
player.closeInventory();
openTicketDetailAsync(player, ticket);
}
}
return;
}
int slot = event.getRawSlot(); // ── Admin Archiv ───────────────────────────────────────────────────
if (title.equals(CLOSED_GUI_TITLE)) {
handleArchiveNavClick(player, slot);
if (slot < PAGE_SIZE) {
Map<Integer, Ticket> slotMap = playerClosedSlotMap.get(player.getUniqueId());
if (slotMap == null) return;
Ticket ticket = slotMap.get(slot);
if (ticket != null) {
viewingFromArchive.add(player.getUniqueId());
player.closeInventory();
openTicketDetailAsync(player, ticket);
}
}
return;
}
// ── Spieler-GUI ────────────────────────────────────────────────────
if (title.equals(PLAYER_GUI_TITLE)) {
int curPage = playerPage.getOrDefault(player.getUniqueId(), 0);
if (slot == 45) { openPlayerGUI(player, curPage - 1); return; }
if (slot == 53) { openPlayerGUI(player, curPage + 1); return; }
if (slot < PAGE_SIZE) {
Map<Integer, Ticket> slotMap = playerOwnSlotMap.get(player.getUniqueId());
if (slotMap == null) return;
Ticket ticket = slotMap.get(slot); Ticket ticket = slotMap.get(slot);
if (ticket == null) return; if (ticket == null) return;
player.closeInventory(); player.closeInventory();
if (ticket.getStatus() == TicketStatus.OPEN || ticket.getStatus() == TicketStatus.CLOSED) {
// Asynchron aus DB neu laden (aktuelle Daten) boolean success = plugin.getDatabaseManager().markAsPlayerDeleted(ticket.getId());
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTask(plugin, () -> {
Ticket fresh = plugin.getDatabaseManager().getTicketById(ticket.getId()); if (success) {
if (fresh == null) { player.sendMessage(plugin.color("&aDein Ticket &e#" + ticket.getId() + " &awurde aus deiner Übersicht entfernt."));
player.sendMessage(plugin.formatMessage("messages.ticket-not-found")); openPlayerGUI(player);
} else {
player.sendMessage(plugin.color("&cFehler beim Entfernen des Tickets."));
}
});
} else {
player.sendMessage(plugin.color("&cDu kannst dieses Ticket nicht löschen, da es bereits von einem Supporter bearbeitet wird."));
}
}
return; return;
} }
Bukkit.getScheduler().runTask(plugin, () -> handleTicketClick(player, fresh)); // ── Admin Detail-GUI ───────────────────────────────────────────────
if (title.equals(DETAIL_GUI_TITLE)) {
Ticket ticket = detailTicketMap.get(player.getUniqueId());
if (ticket == null) return;
player.closeInventory();
switch (slot) {
case 10 -> handleDetailTeleport(player, ticket);
case 12 -> {
if (ticket.getStatus() == TicketStatus.CLOSED && player.hasPermission(ARCHIVE_PERMISSION)) {
handleDetailPermanentDelete(player, ticket);
} else {
handleDetailClaim(player, ticket);
}
}
case 14 -> handleDetailClose(player, ticket);
case 20 -> handleDetailCyclePriority(player, ticket);
case 22 -> handleShowComments(player, ticket);
case 16 -> {
if (viewingFromArchive.remove(player.getUniqueId())) openClosedGUI(player);
else openGUI(player);
}
}
}
}
// ─────────────────────────── Navigation-Handler ─────────────────────────
private void handleAdminNavClick(Player player, int slot, boolean isArchive) {
int curPage = adminPage.getOrDefault(player.getUniqueId(), 0);
switch (slot) {
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 -> {
if (plugin.getConfig().getBoolean("categories-enabled", true)) {
cycleCategoryFilter(player);
openGUI(player, 0);
}
}
}
}
private void handleArchiveNavClick(Player player, int slot) {
int curPage = archivePage.getOrDefault(player.getUniqueId(), 0);
switch (slot) {
case 45 -> openClosedGUI(player, curPage - 1);
case 53 -> openClosedGUI(player, curPage + 1);
case 49 -> openGUI(player);
}
}
private void cycleCategoryFilter(Player player) {
CategoryManager cm = plugin.getCategoryManager();
List<ConfigCategory> all = cm.getAll();
ConfigCategory current = categoryFilter.getOrDefault(player.getUniqueId(), null);
if (current == null) {
if (!all.isEmpty()) categoryFilter.put(player.getUniqueId(), all.get(0));
} else {
int idx = all.indexOf(current);
int next = idx + 1;
if (next >= all.size()) categoryFilter.remove(player.getUniqueId());
else categoryFilter.put(player.getUniqueId(), all.get(next));
}
ConfigCategory newFilter = categoryFilter.getOrDefault(player.getUniqueId(), null);
String filterName = newFilter != null ? newFilter.getColored() : "§7Alle";
player.sendMessage(plugin.color("&7Filter: " + filterName));
}
// ─────────────────────────── Detail-Aktionen ────────────────────────────
private void openTicketDetailAsync(Player player, Ticket currentTicket) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
Ticket fresh = plugin.getDatabaseManager().getTicketById(currentTicket.getId());
Bukkit.getScheduler().runTask(plugin, () -> {
if (fresh == null) { player.sendMessage(plugin.formatMessage("messages.ticket-not-found")); return; }
openDetailGUI(player, fresh);
});
}); });
} }
private void handleTicketClick(Player player, Ticket ticket) { // ── BUG FIX: handleDetailTeleport ────────────────────────────────────────
// Versuche zu claimen, wenn noch OPEN // Vorher: Teleport wurde immer ausgeführt auch bei aktivem BungeeCord.
if (ticket.getStatus() == TicketStatus.OPEN) { // ticket.getLocation() gibt null zurück wenn die World auf diesem
boolean success = plugin.getDatabaseManager().claimTicket( // Server nicht existiert → NullPointerException oder falscher Teleport.
ticket.getId(), player.getUniqueId(), player.getName()); //
// Fix: Bei bungeecord: true + bungee-teleport-enabled: true →
if (success) { // 1. Zielposition in DB speichern (ticket_pending_teleport)
ticket.setStatus(TicketStatus.CLAIMED); // 2. Spieler via Plugin Messaging Channel auf Ziel-Server schicken
ticket.setClaimerUUID(player.getUniqueId()); // 3. PlayerJoinListener teleportiert ihn dort zur Position
ticket.setClaimerName(player.getName()); // Bei bungeecord: true + bungee-teleport-enabled: false → gesperrt.
// Bei bungeecord: false → normaler lokaler Teleport wie bisher.
player.sendMessage(plugin.formatMessage("messages.ticket-claimed") //
.replace("{id}", String.valueOf(ticket.getId())) // Hinweis: Ist der Admin bereits auf dem Ziel-Server, wird direkt teleportiert.
.replace("{player}", ticket.getCreatorName())); // ─────────────────────────────────────────────────────────────────────────
private void handleDetailTeleport(Player player, Ticket ticket) {
plugin.getTicketManager().notifyCreatorClaimed(ticket); if (!plugin.isBungeeCordEnabled()) {
} else { // ── Standalone-Modus: direkt teleportieren ──
player.sendMessage(plugin.formatMessage("messages.already-claimed"));
}
}
// Teleportation zur Ticket-Position
if (ticket.getLocation() != null) { if (ticket.getLocation() != null) {
player.teleport(ticket.getLocation()); player.teleport(ticket.getLocation());
player.sendMessage(plugin.color("&7Du wurdest zu Ticket &e#" + ticket.getId() + " &7teleportiert.")); player.sendMessage(plugin.color("&7Du wurdest zu Ticket &e#" + ticket.getId() + " &7teleportiert."));
} else { } else {
player.sendMessage(plugin.color("&cDie Welt des Tickets ist nicht geladen!")); 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"));
return;
}
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
boolean success = plugin.getDatabaseManager().claimTicket(ticket.getId(), player.getUniqueId(), player.getName());
Bukkit.getScheduler().runTask(plugin, () -> {
if (!success) { player.sendMessage(plugin.formatMessage("messages.already-claimed")); return; }
player.sendMessage(plugin.formatMessage("messages.ticket-claimed")
.replace("{id}", String.valueOf(ticket.getId()))
.replace("{player}", ticket.getCreatorName()));
ticket.setClaimerUUID(player.getUniqueId());
ticket.setClaimerName(player.getName());
plugin.getTicketManager().notifyCreatorClaimed(ticket);
// Teleport nach dem Claim entfernt Teleport nur noch über das separate GUI-Item möglich.
});
});
}
private void handleDetailPermanentDelete(Player player, Ticket ticket) {
if (!player.hasPermission(ARCHIVE_PERMISSION)) {
player.sendMessage(plugin.color("&cDu hast keine Berechtigung, Tickets permanent zu löschen."));
return;
}
if (ticket.getStatus() != TicketStatus.CLOSED) {
player.sendMessage(plugin.color("&cNur geschlossene Tickets können permanent gelöscht werden."));
return;
}
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
boolean success = plugin.getDatabaseManager().deleteTicket(ticket.getId());
Bukkit.getScheduler().runTask(plugin, () -> {
if (success) {
player.sendMessage(plugin.color("&aTicket &e#" + ticket.getId() + " &awurde permanent gelöscht."));
viewingFromArchive.remove(player.getUniqueId());
openClosedGUI(player);
} else {
player.sendMessage(plugin.color("&cFehler beim Löschen des Tickets."));
openClosedGUI(player);
}
});
});
}
private void handleDetailClose(Player player, Ticket ticket) {
if (ticket.getStatus() == TicketStatus.CLOSED) {
player.sendMessage(plugin.color("&cDieses Ticket ist bereits geschlossen."));
return;
}
awaitingComment.put(player.getUniqueId(), ticket.getId());
player.sendMessage(plugin.color("&8&m "));
player.sendMessage(plugin.color("&6Ticket #" + ticket.getId() + " schließen"));
player.sendMessage(plugin.color("&7Gib einen Kommentar ein (&e- &7für keinen)."));
player.sendMessage(plugin.color("&7Abbrechen mit &ccancel"));
player.sendMessage(plugin.color("&8&m "));
}
private void handleDetailCyclePriority(Player player, Ticket ticket) {
if (!player.hasPermission("ticket.support") && !player.hasPermission("ticket.admin")) {
player.sendMessage(plugin.color("&cDu hast keine Berechtigung, die Priorität zu ändern."));
return;
}
if (!plugin.getConfig().getBoolean("priorities-enabled", true)) return;
if (ticket.getStatus() == TicketStatus.CLOSED) {
player.sendMessage(plugin.color("&cDie Priorität geschlossener Tickets kann nicht geändert werden."));
return;
}
TicketPriority[] values = TicketPriority.values();
TicketPriority next = values[(ticket.getPriority().ordinal() + 1) % values.length];
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
boolean success = plugin.getDatabaseManager().setTicketPriority(ticket.getId(), next);
Bukkit.getScheduler().runTask(plugin, () -> {
if (success) {
ticket.setPriority(next);
player.sendMessage(plugin.color("&aPriorität auf " + next.getColored() + " &agesetzt."));
openDetailGUI(player, ticket);
} else {
player.sendMessage(plugin.color("&cFehler beim Ändern der Priorität."));
openDetailGUI(player, ticket);
}
});
});
}
private void handleShowComments(Player player, Ticket ticket) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
List<TicketComment> comments = plugin.getDatabaseManager().getComments(ticket.getId());
Bukkit.getScheduler().runTask(plugin, () -> {
player.sendMessage(plugin.color("&8&m "));
player.sendMessage(plugin.color("&6Kommentare zu Ticket #" + ticket.getId()));
if (comments.isEmpty()) {
player.sendMessage(plugin.color("&7Noch keine Kommentare vorhanden."));
} else {
for (TicketComment c : comments) {
String time = DATE_FORMAT.format(c.getCreatedAt());
player.sendMessage(plugin.color("&e" + c.getAuthorName() + " &7(" + time + ")&8: &f" + c.getMessage()));
}
}
player.sendMessage(plugin.color("&8&m "));
openDetailGUI(player, ticket);
});
});
}
// ─────────────────────────── Chat-Events ────────────────────────────────
@EventHandler(priority = EventPriority.LOWEST)
public void onPlayerChat(AsyncPlayerChatEvent event) {
Player player = event.getPlayer();
if (!awaitingComment.containsKey(player.getUniqueId())) return;
event.setCancelled(true);
int ticketId = awaitingComment.remove(player.getUniqueId());
String input = event.getMessage().trim();
if (input.equalsIgnoreCase("cancel")) {
Bukkit.getScheduler().runTask(plugin, () -> player.sendMessage(plugin.color("&cAbgebrochen.")));
return;
}
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));
if (ticket != null) {
ticket.setCloseComment(comment);
plugin.getTicketManager().notifyCreatorClosed(ticket, player.getName());
}
});
}
});
}
// ─────────────────────────── Item-Builder ──────────────────────────────
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);
if (page > 0) {
inv.setItem(45, buildActionItem(Material.ARROW, "§7§l◄ Zurück",
List.of("§7Seite " + page + " von " + totalPages)));
}
if (page < totalPages - 1) {
inv.setItem(53, buildActionItem(Material.ARROW, "§7§lWeiter ►",
List.of("§7Seite " + (page + 2) + " von " + totalPages)));
}
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.")));
}
if (plugin.getConfig().getBoolean("categories-enabled", true)) {
ConfigCategory currentFilter = categoryFilter.getOrDefault(player.getUniqueId(), null);
String filterLabel = currentFilter != null ? currentFilter.getColored() : "§7Alle";
List<String> filterLore = new ArrayList<>();
filterLore.add("§7Aktuell: " + filterLabel);
filterLore.add("§8Klicken zum Wechseln");
filterLore.add("§8§m ");
for (ConfigCategory cat : plugin.getCategoryManager().getAll()) {
filterLore.add((cat.equals(currentFilter) ? "§a» " : "§7 ") + cat.getColored());
}
filterLore.add((currentFilter == null ? "§a» " : "§7 ") + "§7Alle (kein Filter)");
inv.setItem(47, buildActionItem(Material.HOPPER, "§e§lKategorie-Filter", filterLore));
}
} else {
inv.setItem(49, buildActionItem(Material.ARROW, "§7§lZurück zur Übersicht",
List.of("§7Zeigt alle offenen Tickets.")));
}
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")));
}
private void fillPlayerNavigation(Inventory inv, int page, int totalPages) {
ItemStack glass = makeGlass();
for (int i = 45; i < 54; i++) inv.setItem(i, glass);
if (page > 0) inv.setItem(45, buildActionItem(Material.ARROW, "§7§l◄ Zurück", List.of("§7Seite " + page + " von " + totalPages)));
if (page < totalPages - 1) inv.setItem(53, buildActionItem(Material.ARROW, "§7§lWeiter ►", List.of("§7Seite " + (page + 2) + " von " + totalPages)));
inv.setItem(49, buildActionItem(Material.PAPER, "§8Seite " + (page + 1) + "/" + totalPages, List.of()));
}
private ItemStack buildAdminListItem(Ticket ticket) {
Material mat;
if (plugin.getConfig().getBoolean("categories-enabled", true)) {
mat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey()).getMaterial();
} else {
mat = switch (ticket.getStatus()) {
case OPEN -> Material.PAPER;
case CLAIMED -> Material.YELLOW_DYE;
case FORWARDED -> Material.ORANGE_DYE;
case CLOSED -> Material.GRAY_DYE;
};
}
ItemStack item = new ItemStack(mat);
ItemMeta meta = item.getItemMeta();
if (meta == null) return item;
String priorityPrefix = plugin.getConfig().getBoolean("priorities-enabled", true)
? ticket.getPriority().getColored() + " §8| " : "";
meta.setDisplayName(priorityPrefix + "§6§lTicket #" + ticket.getId() + " §r" + ticket.getStatus().getColored());
List<String> lore = new ArrayList<>();
lore.add("§8§m ");
lore.add("§7Ersteller: §e" + ticket.getCreatorName());
lore.add("§7Anliegen: §f" + ticket.getMessage());
lore.add("§7Erstellt: §e" + DATE_FORMAT.format(ticket.getCreatedAt()));
if (plugin.getConfig().getBoolean("categories-enabled", true)) {
ConfigCategory _cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey());
lore.add("§7Kategorie: " + _cat.getColored());
}
if (plugin.getConfig().getBoolean("priorities-enabled", true))
lore.add("§7Priorität: " + ticket.getPriority().getColored());
if (ticket.getStatus() == TicketStatus.CLOSED && ticket.getCloseComment() != null && !ticket.getCloseComment().isEmpty())
lore.add("§7Kommentar: §f" + ticket.getCloseComment());
if (ticket.isPlayerDeleted()) lore.add("§cSpieler hat Ticket gelöscht.");
lore.add("§8§m ");
lore.add("§e§l» KLICKEN für Details");
meta.setLore(lore);
item.setItemMeta(meta);
return item;
}
private ItemStack buildDetailInfoItem(Ticket ticket) {
Material mat = switch (ticket.getStatus()) {
case OPEN -> Material.PAPER;
case CLAIMED -> Material.YELLOW_DYE;
case FORWARDED -> Material.ORANGE_DYE;
case CLOSED -> Material.GRAY_DYE;
};
ItemStack item = new ItemStack(mat);
ItemMeta meta = item.getItemMeta();
if (meta == null) return item;
meta.setDisplayName("§6§lTicket #" + ticket.getId() + " §r" + ticket.getStatus().getColored());
List<String> lore = new ArrayList<>();
lore.add("§8§m ");
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)) {
ConfigCategory _cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey());
lore.add("§7Kategorie: " + _cat.getColored());
}
if (plugin.getConfig().getBoolean("priorities-enabled", true))
lore.add("§7Priorität: " + ticket.getPriority().getColored());
if (ticket.getClaimerName() != null) {
lore.add("§8§m ");
lore.add("§7Angenommen von: §a" + ticket.getClaimerName());
if (ticket.getClaimedAt() != null) lore.add("§7Angenommen am: §a" + DATE_FORMAT.format(ticket.getClaimedAt()));
}
if (ticket.getStatus() == TicketStatus.CLOSED) {
if (ticket.getClosedAt() != null) lore.add("§7Geschlossen am: §c" + DATE_FORMAT.format(ticket.getClosedAt()));
if (ticket.getCloseComment() != null && !ticket.getCloseComment().isEmpty())
lore.add("§7Kommentar: §f" + ticket.getCloseComment());
if (plugin.getConfig().getBoolean("rating-enabled", true)) {
String rating = ticket.getPlayerRating();
String ratingStr = rating == null ? "§7Keine Bewertung" :
"THUMBS_UP".equals(rating) ? "§a👍 Positiv" : "§c👎 Negativ";
lore.add("§7Bewertung: " + ratingStr);
}
}
lore.add("§8§m ");
meta.setLore(lore);
item.setItemMeta(meta);
return item;
}
private ItemStack buildPlayerTicketItem(Ticket ticket) {
Material mat = switch (ticket.getStatus()) {
case OPEN -> Material.PAPER;
case CLAIMED -> Material.YELLOW_DYE;
case FORWARDED -> Material.ORANGE_DYE;
case CLOSED -> Material.GRAY_DYE;
};
ItemStack item = new ItemStack(mat);
ItemMeta meta = item.getItemMeta();
if (meta == null) return item;
meta.setDisplayName("§6§lTicket #" + ticket.getId() + " §r" + ticket.getStatus().getColored());
List<String> lore = new ArrayList<>();
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)) {
ConfigCategory _cat = plugin.getCategoryManager().fromKey(ticket.getCategoryKey());
lore.add("§7Kategorie: " + _cat.getColored());
}
if (ticket.getStatus() == TicketStatus.CLOSED) {
if (ticket.getCloseComment() != null && !ticket.getCloseComment().isEmpty()) {
lore.add("§8§m ");
lore.add("§7Kommentar des Supports:");
lore.add("§f" + ticket.getCloseComment());
}
if (plugin.getConfig().getBoolean("rating-enabled", true)) {
String rating = ticket.getPlayerRating();
if (rating == null) lore.add("§e» /ticket rate " + ticket.getId() + " good/bad");
else lore.add("§7Bewertet: " + ("THUMBS_UP".equals(rating) ? "§a👍" : "§c👎"));
}
}
lore.add("§8§m ");
switch (ticket.getStatus()) {
case OPEN, CLOSED -> { lore.add("§c§l» KLICKEN zum Löschen"); lore.add("§7Entferne dieses Ticket aus deiner Übersicht."); }
default -> { lore.add("§e» Ticket wird bearbeitet..."); lore.add("§7Kann nicht mehr gelöscht werden."); }
}
meta.setLore(lore);
item.setItemMeta(meta);
return item;
}
private ItemStack buildActionItem(Material material, String displayName, List<String> lore) {
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
if (meta == null) return item;
meta.setDisplayName(displayName);
meta.setLore(lore);
item.setItemMeta(meta);
return item;
}
private ItemStack makeGlass() {
ItemStack glass = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
ItemMeta meta = glass.getItemMeta();
if (meta != null) { meta.setDisplayName(" "); glass.setItemMeta(meta); }
return glass;
}
private void fillEmpty(Inventory inv) {
ItemStack glass = makeGlass();
for (int i = 0; i < inv.getSize(); i++) { if (inv.getItem(i) == null) inv.setItem(i, glass); }
} }
} }

View File

@@ -1,7 +1,15 @@
package de.ticketsystem.listeners; package de.ticketsystem.listeners;
import java.util.List;
import de.ticketsystem.TicketPlugin; import de.ticketsystem.TicketPlugin;
import de.ticketsystem.database.DatabaseManager;
import de.ticketsystem.model.Ticket;
import de.ticketsystem.model.TicketStatus;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
@@ -19,21 +27,113 @@ public class PlayerJoinListener implements Listener {
public void onPlayerJoin(PlayerJoinEvent event) { public void onPlayerJoin(PlayerJoinEvent event) {
Player player = event.getPlayer(); Player player = event.getPlayer();
// Nur Supporter und Admins erhalten die Join-Benachrichtigung // ── Supporter/Admin: offene Tickets anzeigen ──────────────────────
if (!player.hasPermission("ticket.support") && !player.hasPermission("ticket.admin")) return; if (player.hasPermission("ticket.support") || player.hasPermission("ticket.admin")) {
// Verzögerung von 2 Sekunden damit die Join-Sequenz abgeschlossen ist
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
int count = plugin.getDatabaseManager().countOpenTickets(); int count = plugin.getDatabaseManager().countOpenTickets();
if (count > 0) { if (count > 0) {
Bukkit.getScheduler().runTaskLater(plugin, () -> { Bukkit.getScheduler().runTaskLater(plugin, () -> {
String msg = plugin.formatMessage("messages.join-open-tickets") String msg = plugin.formatMessage("messages.join-open-tickets")
.replace("{count}", String.valueOf(count)); .replace("{count}", String.valueOf(count));
player.sendMessage(msg); player.sendMessage(msg);
player.sendMessage(plugin.color("&7» Tippe &e/ticket list &7für die Übersicht.")); player.sendMessage(plugin.color("&7» Tippe &e/ticket list &7für die Übersicht."));
}, 40L); // 40 Ticks = 2 Sekunden }, 40L);
} }
}); });
} }
// ── 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, () -> {
if (!player.isOnline()) return;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
List<String> pending = plugin.getDatabaseManager().getPendingNotifications(player.getUniqueId());
if (pending.isEmpty()) return;
Bukkit.getScheduler().runTask(plugin, () -> {
if (!player.isOnline()) return;
player.sendMessage(plugin.color("&8&m "));
player.sendMessage(plugin.color("&6Ticket-Benachrichtigungen &7(während du offline warst):"));
for (String msg : pending) {
player.sendMessage(plugin.color(msg));
}
player.sendMessage(plugin.color("&8&m "));
});
plugin.getDatabaseManager().clearPendingNotifications(player.getUniqueId());
});
}, 60L);
// ── [NEU] Spieler: Ticket-claimed-Benachrichtigung für Offline-Zeit ──
// Läuft mit 60 Ticks Verzögerung (3 Sek) damit der Spieler zuerst normal spawnt
Bukkit.getScheduler().runTaskLater(plugin, () -> {
if (!player.isOnline()) return;
plugin.getTicketManager().notifyClaimedWhileOffline(player);
}, 60L);
// ── 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;
// DB-Feld prüfen funktioniert serverübergreifend
if (t.isCloseNotified()) continue;
Bukkit.getScheduler().runTask(plugin, () ->
plugin.getTicketManager().notifyCreatorClosed(t));
}
});
// ── Update-Hinweis für OPs/Admins ────────────────────────────────
if (player.isOp() || player.hasPermission("ticket.admin")) {
Bukkit.getScheduler().runTaskLater(plugin, () -> {
int resourceId = 132757;
new de.ticketsystem.UpdateChecker(plugin, resourceId).getVersion(version -> {
String current = plugin.getDescription().getVersion();
if (!current.equals(version)) {
String bar = ChatColor.GOLD + "====================================================";
player.sendMessage(bar);
player.sendMessage(ChatColor.GOLD + "[TicketSystem] "
+ ChatColor.YELLOW + "NEUES UPDATE VERFÜGBAR: v" + version);
player.sendMessage(ChatColor.GOLD + "[TicketSystem] "
+ ChatColor.YELLOW + "Download: https://www.spigotmc.org/resources/132757");
player.sendMessage(bar);
}
});
}, 20L);
}
}
} }

View File

@@ -0,0 +1,177 @@
package de.ticketsystem.manager;
import de.ticketsystem.TicketPlugin;
import de.ticketsystem.model.ConfigCategory;
import org.bukkit.Material;
import org.bukkit.configuration.ConfigurationSection;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Loads and manages ticket categories defined in config.yml under the "categories" section.
*
* Example config.yml layout:
*
* categories:
* general:
* name: "Allgemein"
* color: "&7"
* material: "PAPER"
* aliases:
* - "allgemein"
* - "general"
* bug:
* name: "Bug"
* color: "&c"
* material: "REDSTONE"
* aliases:
* - "bug"
* - "fehler"
*/
public class CategoryManager {
private final TicketPlugin plugin;
/** Ordered map: key → ConfigCategory */
private final Map<String, ConfigCategory> categories = new LinkedHashMap<>();
/** Alias → key mapping for fast resolve() lookups */
private final Map<String, String> aliasMap = new LinkedHashMap<>();
public CategoryManager(TicketPlugin plugin) {
this.plugin = plugin;
load();
}
// ─────────────────────────── Loading ───────────────────────────────────
private void load() {
categories.clear();
aliasMap.clear();
ConfigurationSection section = plugin.getConfig().getConfigurationSection("categories");
if (section == null || section.getKeys(false).isEmpty()) {
// Fallback: create built-in defaults so the plugin always works
loadDefaults();
return;
}
for (String key : section.getKeys(false)) {
ConfigurationSection cat = section.getConfigurationSection(key);
if (cat == null) continue;
String name = cat.getString("name", capitalize(key));
String color = cat.getString("color", "&7");
String matStr = cat.getString("material", "PAPER").toUpperCase();
Material material;
try {
material = Material.valueOf(matStr);
} catch (IllegalArgumentException e) {
plugin.getLogger().warning("[CategoryManager] Unbekanntes Material '" + matStr
+ "' für Kategorie '" + key + "'. Fallback: PAPER");
material = Material.PAPER;
}
ConfigCategory category = new ConfigCategory(key, name, color, material);
categories.put(key.toLowerCase(), category);
// Register key itself as alias
aliasMap.put(key.toLowerCase(), key.toLowerCase());
// Register additional aliases
List<String> aliases = cat.getStringList("aliases");
for (String alias : aliases) {
aliasMap.put(alias.toLowerCase(), key.toLowerCase());
}
}
if (categories.isEmpty()) {
plugin.getLogger().warning("[CategoryManager] Keine gültigen Kategorien in der config.yml gefunden. Lade Standardkategorien.");
loadDefaults();
} else {
plugin.getLogger().info("[CategoryManager] " + categories.size() + " Kategorie(n) geladen: " + String.join(", ", categories.keySet()));
}
}
/** Built-in fallback categories — mirrors the old TicketCategory enum */
private void loadDefaults() {
addDefault("general", "Allgemein", "&7", Material.PAPER, "allgemein", "general");
addDefault("bug", "Bug", "&c", Material.REDSTONE, "bug", "fehler");
addDefault("question", "Frage", "&e", Material.BOOK, "frage", "question");
addDefault("complaint", "Beschwerde", "&6", Material.WRITABLE_BOOK, "beschwerde", "complaint");
addDefault("other", "Sonstiges", "&8", Material.FEATHER, "sonstiges", "other");
plugin.getLogger().info("[CategoryManager] Standard-Kategorien geladen (5).");
}
private void addDefault(String key, String name, String color, Material mat, String... aliases) {
ConfigCategory cat = new ConfigCategory(key, name, color, mat);
categories.put(key, cat);
aliasMap.put(key, key);
for (String alias : aliases) aliasMap.put(alias.toLowerCase(), key);
}
// ─────────────────────────── Public API ────────────────────────────────
/**
* Returns all loaded categories in config order.
*/
public List<ConfigCategory> getAll() {
return Collections.unmodifiableList(new ArrayList<>(categories.values()));
}
/**
* Returns the first category (default), or a hard-coded fallback if empty.
*/
public ConfigCategory getDefault() {
if (categories.isEmpty()) return new ConfigCategory("general", "Allgemein", "&7", Material.PAPER);
return categories.values().iterator().next();
}
/**
* Looks up a category by exact key (case-insensitive).
* Returns null if not found.
*/
public ConfigCategory fromKey(String key) {
if (key == null) return getDefault();
ConfigCategory cat = categories.get(key.toLowerCase());
return cat != null ? cat : getDefault();
}
/**
* Resolves a user-supplied string (key or alias) to a ConfigCategory.
* Returns null if no match is found (so callers can show an error).
*/
public ConfigCategory resolve(String input) {
if (input == null) return null;
String key = aliasMap.get(input.toLowerCase());
return key != null ? categories.get(key) : null;
}
/**
* Returns a human-readable comma-separated list of all category keys,
* e.g. "general, bug, question, complaint, other"
*/
public String getAvailableNames() {
return String.join(", ", categories.keySet());
}
/**
* Reloads categories from the (already reloaded) config.
* Call this after plugin.reloadConfig().
*/
public void reload() {
load();
}
// ─────────────────────────── Helpers ───────────────────────────────────
private static String capitalize(String s) {
if (s == null || s.isEmpty()) return s;
return Character.toUpperCase(s.charAt(0)) + s.substring(1).toLowerCase();
}
}

View File

@@ -1,6 +1,7 @@
package de.ticketsystem.manager; package de.ticketsystem.manager;
import de.ticketsystem.TicketPlugin; import de.ticketsystem.TicketPlugin;
import de.ticketsystem.model.ConfigCategory;
import de.ticketsystem.model.Ticket; import de.ticketsystem.model.Ticket;
import de.ticketsystem.model.TicketStatus; import de.ticketsystem.model.TicketStatus;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
@@ -14,7 +15,7 @@ public class TicketManager {
private final TicketPlugin plugin; private final TicketPlugin plugin;
// Cooldown Map: UUID → Zeit in Millis, wann das letzte Ticket erstellt wurde /** Cooldown Map: UUID → Zeitstempel letztes Ticket */
private final Map<UUID, Long> cooldowns = new HashMap<>(); private final Map<UUID, Long> cooldowns = new HashMap<>();
public TicketManager(TicketPlugin plugin) { public TicketManager(TicketPlugin plugin) {
@@ -35,62 +36,308 @@ public class TicketManager {
return Math.max(0, (cooldownMillis - elapsed) / 1000); return Math.max(0, (cooldownMillis - elapsed) / 1000);
} }
public void setCooldown(UUID uuid) { public void setCooldown(UUID uuid) { cooldowns.put(uuid, System.currentTimeMillis()); }
cooldowns.put(uuid, System.currentTimeMillis());
}
// ─────────────────────────── Benachrichtigungen ──────────────────────── // ─────────────────────────── Benachrichtigungen ────────────────────────
/** /**
* Benachrichtigt alle Online-Supporter und Admins über ein neues Ticket. * 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) { public void notifyTeam(Ticket ticket) {
String msg = plugin.formatMessage("messages.new-ticket-notify") String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unbekannt";
.replace("{player}", ticket.getCreatorName()) String message = ticket.getMessage() != null ? ticket.getMessage() : "";
.replace("{message}", ticket.getMessage())
.replace("{id}", String.valueOf(ticket.getId()));
// Kategorie & Priorität optional anzeigen
String categoryInfo = "";
String priorityInfo = "";
if (plugin.getConfig().getBoolean("categories-enabled", true)) {
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 + 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()) { for (Player p : Bukkit.getOnlinePlayers()) {
if (p.hasPermission("ticket.support") || p.hasPermission("ticket.admin")) { if (p.hasPermission("ticket.support") || p.hasPermission("ticket.admin")) {
p.sendMessage(msg); p.sendMessage(msg);
p.sendMessage(guiHint);
}
}
}
// Klickbaren Hinweis senden (Bukkit Chat-Component) plugin.getDiscordWebhook().sendNewTicket(ticket);
p.sendMessage(plugin.color("&7» Klicke &e/ticket list &7um die GUI zu öffnen."));
}
}
} }
/** /**
* Benachrichtigt den Ersteller des Tickets, wenn es geclaimt wurde. * 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) { public void notifyCreatorClaimed(Ticket ticket) {
Player creator = Bukkit.getPlayer(ticket.getCreatorUUID()); String claimerName = resolveClaimerName(ticket);
if (creator != null && creator.isOnline()) {
String msg = plugin.formatMessage("messages.ticket-claimed-notify") String msg = plugin.formatMessage("messages.ticket-claimed-notify")
.replace("{id}", String.valueOf(ticket.getId())) .replace("{id}", String.valueOf(ticket.getId()))
.replace("{claimer}", ticket.getClaimerName()); .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());
} }
/**
* Wird beim Server-Join aufgerufen informiert den Spieler über Tickets,
* die geclaimt oder weitergeleitet wurden während er offline war.
*/
public void notifyClaimedWhileOffline(Player player) {
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;
String claimerName = t.getClaimerName() != null ? t.getClaimerName() : "Support";
final String name = claimerName;
Bukkit.getScheduler().runTask(plugin, () -> {
if (!player.isOnline()) return;
if (t.getStatus() == TicketStatus.CLAIMED) {
String msg = plugin.formatMessage("messages.ticket-claimed-notify")
.replace("{id}", String.valueOf(t.getId()))
.replace("{claimer}", name);
player.sendMessage(msg);
} else {
String forwardedTo = t.getForwardedToName() != null ? t.getForwardedToName() : "einen Supporter";
String msg = plugin.formatMessage("messages.ticket-forwarded-creator-notify")
.replace("{id}", String.valueOf(t.getId()))
.replace("{supporter}", forwardedTo);
player.sendMessage(msg);
}
});
plugin.getDatabaseManager().markClaimerNotified(t.getId());
}
});
}
/**
* Benachrichtigt den Ersteller, wenn sein Ticket weitergeleitet wurde.
* BungeeCord: Cross-Server-Zustellung.
*/
public void notifyCreatorForwarded(Ticket ticket) {
String forwardedTo = ticket.getForwardedToName() != null ? ticket.getForwardedToName() : "einen Supporter";
String msg = plugin.formatMessage("messages.ticket-forwarded-creator-notify")
.replace("{id}", String.valueOf(ticket.getId()))
.replace("{supporter}", forwardedTo);
deliverToPlayer(ticket.getCreatorUUID(), ticket.getCreatorName(), msg);
// Auch bei Weiterleitung notified setzen
plugin.getDatabaseManager().markClaimerNotified(ticket.getId());
} }
/** /**
* Sendet dem weitergeleiteten Supporter eine Benachrichtigung. * Sendet dem weitergeleiteten Supporter eine Benachrichtigung.
* BungeeCord: Zustellung auch wenn der Supporter auf einem anderen Server ist.
*/ */
public void notifyForwardedTo(Ticket ticket) { public void notifyForwardedTo(Ticket ticket, String fromName) {
Player target = Bukkit.getPlayer(ticket.getForwardedToUUID()); if (ticket.getForwardedToUUID() == null) return;
if (target != null && target.isOnline()) {
String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unbekannt";
String msg = plugin.formatMessage("messages.ticket-forwarded-notify") String msg = plugin.formatMessage("messages.ticket-forwarded-notify")
.replace("{player}", ticket.getCreatorName()) .replace("{player}", creatorName)
.replace("{id}", String.valueOf(ticket.getId())); .replace("{id}", String.valueOf(ticket.getId()));
target.sendMessage(msg);
deliverToPlayer(ticket.getForwardedToUUID(), ticket.getForwardedToName(), msg);
plugin.getDiscordWebhook().sendTicketForwarded(ticket, fromName);
} }
/**
* 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) {
// 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()));
String comment = (ticket.getCloseComment() != null && !ticket.getCloseComment().isEmpty())
? ticket.getCloseComment() : "";
// Hauptnachricht
String msg = plugin.formatMessage("messages.ticket-closed-notify")
.replace("{id}", String.valueOf(ticket.getId()))
.replace("{comment}", comment);
// Bewertungsaufforderung
String ratingMsg = null;
if (plugin.getConfig().getBoolean("rating-enabled", true)) {
ratingMsg = plugin.color(
"&8&m &r\n" +
"&6Wie zufrieden bist du mit dem Support?\n" +
"&a/ticket rate " + ticket.getId() + " good &7 👍 Gut\n" +
"&c/ticket rate " + ticket.getId() + " bad &7 👎 Schlecht\n" +
"&8&m ");
}
// Prüfen ob Ersteller lokal online ist
Player creator = Bukkit.getPlayer(ticket.getCreatorUUID());
if (creator != null && creator.isOnline()) {
// ─ Lokal online: direkt zustellen ────────────────────────────
creator.sendMessage(msg);
if (!comment.isEmpty())
creator.sendMessage(plugin.color("&7Kommentar des Supports: &f" + comment));
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 {
// ─ 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)
? " &7Bewertung: &e/ticket rate " + ticket.getId() + " good/bad" : "");
Bukkit.getScheduler().runTaskAsynchronously(plugin, () ->
plugin.getDatabaseManager().addPendingNotification(ticket.getCreatorUUID(), pendingMsg));
} }
// ─────────────────────────── Hilfsmethoden ───────────────────────────── // ─────────────────────────── Hilfsmethoden ─────────────────────────────
/** private String resolveClaimerName(Ticket ticket) {
* Prüft, ob ein Spieler zu viele offene Tickets hat. if (ticket.getClaimerName() != null) return ticket.getClaimerName();
*/ if (ticket.getClaimerUUID() != null) {
String name = Bukkit.getOfflinePlayer(ticket.getClaimerUUID()).getName();
if (name != null) return name;
}
return "Support";
}
public boolean hasReachedTicketLimit(UUID uuid) { public boolean hasReachedTicketLimit(UUID uuid) {
int max = plugin.getConfig().getInt("max-open-tickets-per-player", 2); int max = plugin.getConfig().getInt("max-open-tickets-per-player", 2);
if (max <= 0) return false; if (max <= 0) return false;
@@ -101,16 +348,29 @@ public class TicketManager {
player.sendMessage(plugin.color("&8&m ")); player.sendMessage(plugin.color("&8&m "));
player.sendMessage(plugin.color("&6TicketSystem &7 Befehle")); player.sendMessage(plugin.color("&6TicketSystem &7 Befehle"));
player.sendMessage(plugin.color("&8&m ")); player.sendMessage(plugin.color("&8&m "));
player.sendMessage(plugin.color("&e/ticket create <Text> &7 Neues Ticket erstellen")); player.sendMessage(plugin.color("&e/ticket create [Kategorie] <Text> &7 Neues Ticket erstellen"));
player.sendMessage(plugin.color("&e/ticket list &7 Deine Tickets ansehen (GUI)"));
player.sendMessage(plugin.color("&e/ticket comment <ID> <Text> &7 Nachricht zu einem Ticket"));
if (plugin.getConfig().getBoolean("rating-enabled", true))
player.sendMessage(plugin.color("&e/ticket rate <ID> <good|bad> &7 Support bewerten"));
if (player.hasPermission("ticket.support") || player.hasPermission("ticket.admin")) { if (player.hasPermission("ticket.support") || player.hasPermission("ticket.admin")) {
player.sendMessage(plugin.color("&e/ticket list &7 Ticket-Übersicht (GUI)"));
player.sendMessage(plugin.color("&e/ticket claim <ID> &7 Ticket annehmen")); player.sendMessage(plugin.color("&e/ticket claim <ID> &7 Ticket annehmen"));
player.sendMessage(plugin.color("&e/ticket close <ID> &7 Ticket schließen")); player.sendMessage(plugin.color("&e/ticket close <ID> [Kommentar] &7 Ticket schließen"));
} }
if (player.hasPermission("ticket.admin")) { if (player.hasPermission("ticket.admin")) {
player.sendMessage(plugin.color("&e/ticket forward <ID> <Spieler> &7 Ticket weiterleiten")); player.sendMessage(plugin.color("&e/ticket forward <ID> <Spieler> &7 Ticket weiterleiten"));
player.sendMessage(plugin.color("&e/ticket blacklist <add|remove|list> [Spieler] [Grund] &7 Blacklist verwalten"));
player.sendMessage(plugin.color("&e/ticket reload &7 Konfiguration neu laden")); player.sendMessage(plugin.color("&e/ticket reload &7 Konfiguration neu laden"));
player.sendMessage(plugin.color("&e/ticket stats &7 Statistiken anzeigen"));
} }
player.sendMessage(plugin.color("&8&m ")); player.sendMessage(plugin.color("&8&m "));
// BungeeCord-Status anzeigen
if (player.hasPermission("ticket.admin") && plugin.isBungeeCordEnabled()) {
player.sendMessage(plugin.color("&8[BungeeCord] &7Server: &b" + plugin.getServerName()
+ " &8| Cross-Server-Benachrichtigungen &aaktiv"));
}
} }
} }

View File

@@ -0,0 +1,53 @@
package de.ticketsystem.model;
import org.bukkit.Material;
/**
* Eine aus der config.yml geladene Ticket-Kategorie.
* Ersetzt das hardcodierte TicketCategory-Enum vollständig.
*
* Konfigurationsbeispiel (config.yml):
*
* categories:
* bug:
* name: "Bug"
* color: "&c"
* material: "REDSTONE"
* aliases:
* - "bug"
* - "fehler"
*/
public class ConfigCategory {
/** Interner Schlüssel aus der Config (z.B. "bug", "general") immer Kleinbuchstaben */
private final String key;
/** Anzeigename (z.B. "Bug", "Allgemein") */
private final String name;
/** Minecraft-Farbcode (z.B. "&c") */
private final String color;
/** GUI-Item-Material */
private final Material material;
public ConfigCategory(String key, String name, String color, Material material) {
this.key = key.toLowerCase();
this.name = name;
this.color = color;
this.material = material;
}
public String getKey() { return key; }
public String getName() { return name; }
public String getColor() { return color; }
public Material getMaterial() { return material; }
/** Gibt den farbigen Anzeigenamen zurück, z.B. "§cBug" */
public String getColored() {
return org.bukkit.ChatColor.translateAlternateColorCodes('&', color + name);
}
@Override
public String toString() { return key; }
}

View File

@@ -3,22 +3,35 @@ package de.ticketsystem.model;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.configuration.serialization.ConfigurationSerializable;
import org.bukkit.configuration.serialization.SerializableAs;
import org.bukkit.configuration.serialization.ConfigurationSerialization;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
public class Ticket {
@SerializableAs("Ticket")
public class Ticket implements ConfigurationSerializable {
private int id; private int id;
private UUID creatorUUID; private UUID creatorUUID;
private String creatorName; private String creatorName;
private String message; private String message;
// Location-Felder (werden separat gespeichert)
private String worldName; private String worldName;
private double x, y, z; private double x, y, z;
private float yaw, pitch; private float yaw, pitch;
/**
* Name des Servers auf dem das Ticket erstellt wurde (BungeeCord-Netzwerk).
* Entspricht dem Wert aus config.yml → server-name.
* Standardwert: "unknown"
*/
private String serverName = "unknown";
private TicketStatus status; private TicketStatus status;
private UUID claimerUUID; private UUID claimerUUID;
private String claimerName; private String claimerName;
@@ -27,6 +40,24 @@ public class Ticket {
private Timestamp createdAt; private Timestamp createdAt;
private Timestamp claimedAt; private Timestamp claimedAt;
private Timestamp closedAt; private Timestamp closedAt;
private String closeComment;
private boolean playerDeleted = false;
/** Kategorie-Key aus config.yml, z.B. "bug", "general" */
private String categoryKey = "general";
private TicketPriority priority = TicketPriority.NORMAL;
/** null = nicht bewertet | "THUMBS_UP" | "THUMBS_DOWN" */
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() {}
@@ -35,74 +66,144 @@ public class Ticket {
this.creatorName = creatorName; this.creatorName = creatorName;
this.message = message; this.message = message;
this.worldName = location.getWorld().getName(); this.worldName = location.getWorld().getName();
this.x = location.getX(); this.x = location.getX(); this.y = location.getY(); this.z = location.getZ();
this.y = location.getY(); this.yaw = location.getYaw(); this.pitch = location.getPitch();
this.z = location.getZ();
this.yaw = location.getYaw();
this.pitch = location.getPitch();
this.status = TicketStatus.OPEN; this.status = TicketStatus.OPEN;
this.createdAt = new Timestamp(System.currentTimeMillis()); this.createdAt = new Timestamp(System.currentTimeMillis());
} }
public Location getLocation() { public Ticket(Map<String, Object> map) {
World world = Bukkit.getWorld(worldName); this.id = (int) map.get("id");
if (world == null) return null; Object cObj = map.get("creatorUUID");
return new Location(world, x, y, z, yaw, pitch); this.creatorUUID = cObj instanceof UUID ? (UUID) cObj : UUID.fromString((String) cObj);
this.creatorName = (String) map.get("creatorName");
this.message = (String) map.get("message");
this.worldName = (String) map.get("world");
this.x = toDouble(map.get("x")); this.y = toDouble(map.get("y")); this.z = toDouble(map.get("z"));
this.yaw = toFloat(map.get("yaw")); this.pitch = toFloat(map.get("pitch"));
this.status = TicketStatus.valueOf((String) map.get("status"));
if (map.get("createdAt") != null) this.createdAt = new Timestamp(toLong(map.get("createdAt")));
if (map.get("claimedAt") != null) this.claimedAt = new Timestamp(toLong(map.get("claimedAt")));
if (map.get("closedAt") != null) this.closedAt = new Timestamp(toLong(map.get("closedAt")));
this.closeComment = (String) map.get("closeComment");
if (map.containsKey("claimerUUID") && map.get("claimerUUID") != null) {
Object o = map.get("claimerUUID");
this.claimerUUID = o instanceof UUID ? (UUID) o : UUID.fromString((String) o);
this.claimerName = (String) map.get("claimerName");
}
if (map.containsKey("forwardedToUUID") && map.get("forwardedToUUID") != null) {
Object o = map.get("forwardedToUUID");
this.forwardedToUUID = o instanceof UUID ? (UUID) o : UUID.fromString((String) o);
this.forwardedToName = (String) map.get("forwardedToName");
}
if (map.containsKey("playerDeleted")) this.playerDeleted = (boolean) map.get("playerDeleted");
if (map.containsKey("category")) this.categoryKey = (String) map.get("category");
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");
} }
// ─────────────────────────── Getter & Setter ──────────────────────────── @Override
public Map<String, Object> serialize() {
Map<String, Object> map = new HashMap<>();
map.put("id", id);
map.put("creatorUUID", creatorUUID.toString());
map.put("creatorName", creatorName);
map.put("message", message);
map.put("world", worldName);
map.put("x", x); map.put("y", y); map.put("z", z);
map.put("yaw", yaw); map.put("pitch", pitch);
map.put("status", status.name());
if (createdAt != null) map.put("createdAt", createdAt.getTime());
if (claimedAt != null) map.put("claimedAt", claimedAt.getTime());
if (closedAt != null) map.put("closedAt", closedAt.getTime());
if (closeComment != null) map.put("closeComment", closeComment);
if (claimerUUID != null) { map.put("claimerUUID", claimerUUID.toString()); map.put("claimerName", claimerName); }
if (forwardedToUUID != null) { map.put("forwardedToUUID", forwardedToUUID.toString()); map.put("forwardedToName", forwardedToName); }
map.put("playerDeleted", playerDeleted);
map.put("category", categoryKey);
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;
}
public static void register() { ConfigurationSerialization.registerClass(Ticket.class, "Ticket"); }
public Location getLocation() {
World world = Bukkit.getWorld(worldName);
return world == null ? null : new Location(world, x, y, z, yaw, pitch);
}
private static double toDouble(Object o) { return o instanceof Double d ? d : ((Number) o).doubleValue(); }
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 int getId() { return id; }
public void setId(int id) { this.id = id; } public void setId(int id) { this.id = id; }
public UUID getCreatorUUID() { return creatorUUID; } public UUID getCreatorUUID() { return creatorUUID; }
public void setCreatorUUID(UUID creatorUUID) { this.creatorUUID = creatorUUID; } public void setCreatorUUID(UUID v) { this.creatorUUID = v; }
public String getCreatorName() { return creatorName; } public String getCreatorName() { return creatorName; }
public void setCreatorName(String creatorName) { this.creatorName = creatorName; } public void setCreatorName(String v) { this.creatorName = v; }
public String getMessage() { return message; } public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; } public void setMessage(String v) { this.message = v; }
public String getWorldName() { return worldName; } public String getWorldName() { return worldName; }
public void setWorldName(String worldName) { this.worldName = worldName; } public void setWorldName(String v) { this.worldName = v; }
public double getX() { return x; } public double getX() { return x; }
public void setX(double x) { this.x = x; } public void setX(double v) { this.x = v; }
public double getY() { return y; } public double getY() { return y; }
public void setY(double y) { this.y = y; } public void setY(double v) { this.y = v; }
public double getZ() { return z; } public double getZ() { return z; }
public void setZ(double z) { this.z = z; } public void setZ(double v) { this.z = v; }
public float getYaw() { return yaw; } public float getYaw() { return yaw; }
public void setYaw(float yaw) { this.yaw = yaw; } public void setYaw(float v) { this.yaw = v; }
public float getPitch() { return pitch; } public float getPitch() { return pitch; }
public void setPitch(float pitch) { this.pitch = pitch; } public void setPitch(float v) { this.pitch = v; }
public TicketStatus getStatus() { return status; } public TicketStatus getStatus() { return status; }
public void setStatus(TicketStatus status) { this.status = status; } public void setStatus(TicketStatus v) { this.status = v; }
public UUID getClaimerUUID() { return claimerUUID; } public UUID getClaimerUUID() { return claimerUUID; }
public void setClaimerUUID(UUID claimerUUID) { this.claimerUUID = claimerUUID; } public void setClaimerUUID(UUID v) { this.claimerUUID = v; }
public String getClaimerName() { return claimerName; } public String getClaimerName() { return claimerName; }
public void setClaimerName(String claimerName) { this.claimerName = claimerName; } public void setClaimerName(String v) { this.claimerName = v; }
public UUID getForwardedToUUID() { return forwardedToUUID; } public UUID getForwardedToUUID() { return forwardedToUUID; }
public void setForwardedToUUID(UUID forwardedToUUID) { this.forwardedToUUID = forwardedToUUID; } public void setForwardedToUUID(UUID v) { this.forwardedToUUID = v; }
public String getForwardedToName() { return forwardedToName; } public String getForwardedToName() { return forwardedToName; }
public void setForwardedToName(String forwardedToName) { this.forwardedToName = forwardedToName; } public void setForwardedToName(String v) { this.forwardedToName = v; }
public Timestamp getCreatedAt() { return createdAt; } public Timestamp getCreatedAt() { return createdAt; }
public void setCreatedAt(Timestamp createdAt) { this.createdAt = createdAt; } public void setCreatedAt(Timestamp v) { this.createdAt = v; }
public Timestamp getClaimedAt() { return claimedAt; } public Timestamp getClaimedAt() { return claimedAt; }
public void setClaimedAt(Timestamp claimedAt) { this.claimedAt = claimedAt; } public void setClaimedAt(Timestamp v) { this.claimedAt = v; }
public Timestamp getClosedAt() { return closedAt; } public Timestamp getClosedAt() { return closedAt; }
public void setClosedAt(Timestamp closedAt) { this.closedAt = closedAt; } public void setClosedAt(Timestamp v) { this.closedAt = v; }
public String getCloseComment() { return closeComment; }
public void setCloseComment(String v) { this.closeComment = v; }
public boolean isPlayerDeleted() { return playerDeleted; }
public void setPlayerDeleted(boolean v) { this.playerDeleted = v; }
public String getCategoryKey() { return categoryKey; }
public void setCategoryKey(String v) { this.categoryKey = v != null ? v.toLowerCase() : "general"; }
public TicketPriority getPriority() { return priority; }
public void setPriority(TicketPriority v) { this.priority = v; }
public String getPlayerRating() { return playerRating; }
public void setPlayerRating(String v) { this.playerRating = v; }
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

@@ -0,0 +1,33 @@
package de.ticketsystem.model;
import org.bukkit.Material;
public enum TicketCategory {
GENERAL ("Allgemein", "§7", Material.PAPER),
BUG ("Bug", "§c", Material.REDSTONE),
QUESTION ("Frage", "§e", Material.BOOK),
COMPLAINT ("Beschwerde", "§6", Material.WRITABLE_BOOK),
OTHER ("Sonstiges", "§8", Material.FEATHER);
private final String displayName;
private final String color;
private final Material guiMaterial;
TicketCategory(String displayName, String color, Material guiMaterial) {
this.displayName = displayName;
this.color = color;
this.guiMaterial = guiMaterial;
}
public String getDisplayName() { return displayName; }
public String getColor() { return color; }
public String getColored() { return color + displayName; }
public Material getGuiMaterial() { return guiMaterial; }
/** Safely parse from stored string, fall back to GENERAL. */
public static TicketCategory fromString(String s) {
if (s == null) return GENERAL;
try { return valueOf(s.toUpperCase()); }
catch (IllegalArgumentException e) { return GENERAL; }
}
}

View File

@@ -0,0 +1,47 @@
package de.ticketsystem.model;
import java.sql.Timestamp;
import java.util.UUID;
/**
* Represents a player comment/reply on a ticket.
*/
public class TicketComment {
private int id;
private int ticketId;
private UUID authorUUID;
private String authorName;
private String message;
private Timestamp createdAt;
public TicketComment() {}
public TicketComment(int ticketId, UUID authorUUID, String authorName, String message) {
this.ticketId = ticketId;
this.authorUUID = authorUUID;
this.authorName = authorName;
this.message = message;
this.createdAt = new Timestamp(System.currentTimeMillis());
}
// ─────────────── Getters / Setters ────────────────────────────────────
public int getId() { return id; }
public void setId(int id) { this.id = id; }
public int getTicketId() { return ticketId; }
public void setTicketId(int ticketId) { this.ticketId = ticketId; }
public UUID getAuthorUUID() { return authorUUID; }
public void setAuthorUUID(UUID authorUUID) { this.authorUUID = authorUUID; }
public String getAuthorName() { return authorName; }
public void setAuthorName(String authorName){ this.authorName = authorName; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public Timestamp getCreatedAt() { return createdAt; }
public void setCreatedAt(Timestamp ts) { this.createdAt = ts; }
}

View File

@@ -0,0 +1,31 @@
package de.ticketsystem.model;
import org.bukkit.Material;
public enum TicketPriority {
LOW ("Niedrig", "§a", Material.GREEN_WOOL),
NORMAL ("Normal", "§e", Material.YELLOW_WOOL),
HIGH ("Hoch", "§6", Material.ORANGE_WOOL),
URGENT ("Dringend","§c", Material.RED_WOOL);
private final String displayName;
private final String color;
private final Material guiMaterial;
TicketPriority(String displayName, String color, Material guiMaterial) {
this.displayName = displayName;
this.color = color;
this.guiMaterial = guiMaterial;
}
public String getDisplayName() { return displayName; }
public String getColor() { return color; }
public String getColored() { return color + displayName; }
public Material getGuiMaterial() { return guiMaterial; }
public static TicketPriority fromString(String s) {
if (s == null) return NORMAL;
try { return valueOf(s.toUpperCase()); }
catch (IllegalArgumentException e) { return NORMAL; }
}
}

View File

@@ -17,6 +17,26 @@ version: "2.0"
# Debug-Modus (true = Logs in der Konsole) # Debug-Modus (true = Logs in der Konsole)
debug: false debug: false
# ----------------------------------------------------
# BUNGEECORD (Cross-Server-Unterstützung)
# ----------------------------------------------------
# VORAUSSETZUNGEN:
# 1. In spigot.yml auf JEDEM Server: bungeecord: true
# 2. MySQL muss aktiviert sein (use-mysql: true)
# 3. Plugin auf JEDEM Spigot-Server installieren
# 4. Alle Server müssen dieselbe MySQL-Datenbank verwenden
#
# false = Normaler Single-Server-Modus (Standard)
# true = Cross-Server Benachrichtigungen aktiv
# ----------------------------------------------------
bungeecord: false
bungee-teleport-enabled: true
# Name dieses Servers im BungeeCord-Netzwerk.
# Wird in Tickets, GUI und Discord-Embeds angezeigt.
# Auf jedem Server ANDERS einstellen! (z.B. "survival", "creative", "skyblock")
server-name: "survival"
# ---------------------------------------------------- # ----------------------------------------------------
# SPEICHERPFAD & ARCHIV # SPEICHERPFAD & ARCHIV
# ---------------------------------------------------- # ----------------------------------------------------
@@ -59,6 +79,128 @@ max-open-tickets-per-player: 2 # Maximale offene Tickets pro Spieler (0 = unbeg
# ---------------------------------------------------- # ----------------------------------------------------
auto-archive-interval-hours: 24 # Intervall in Stunden (0 = aus) auto-archive-interval-hours: 24 # Intervall in Stunden (0 = aus)
# ----------------------------------------------------
# OPTIONALE FEATURES
# ----------------------------------------------------
# Kategorie-System (true = aktiviert)
# Spieler können beim Erstellen eine Kategorie wählen: /ticket create [kategorie] [priorität] <text>
categories-enabled: true
# Prioritäten-System (true = aktiviert)
# Spieler können beim Erstellen eine Priorität wählen: /ticket create [kategorie] [priorität] <text>
# Admins/Supporter können die Priorität nachträglich ändern: /ticket setpriority <id> <low|normal|high|urgent>
priorities-enabled: true
# Bewertungs-System (true = aktiviert)
# Spieler können nach dem Schließen den Support bewerten: /ticket rate <id> good|bad
# Ergebnisse sind in /ticket stats sichtbar
rating-enabled: true
# ----------------------------------------------------
# KATEGORIEN (nur aktiv wenn categories-enabled: true)
# ----------------------------------------------------
# Jede Kategorie hat:
# name: Anzeigename im Chat und in der GUI
# color: Farbcode mit & (Minecraft Farbcodes)
# material: Minecraft-Material für das GUI-Item (Großbuchstaben, z.B. PAPER, REDSTONE, BOOK)
# aliases: Alternative Eingaben beim /ticket create Befehl (Kleinbuchstaben!)
#
# Das erste eingetragene Item ist die Standard-Kategorie für Tickets ohne Angabe.
# Du kannst beliebig viele Kategorien hinzufügen oder entfernen.
# ----------------------------------------------------
categories:
general:
name: "Allgemein"
color: "&7"
material: "PAPER"
aliases:
- "allgemein"
- "general"
- "default"
bug:
name: "Bug"
color: "&c"
material: "REDSTONE"
aliases:
- "bug"
- "fehler"
- "error"
question:
name: "Frage"
color: "&e"
material: "BOOK"
aliases:
- "frage"
- "question"
- "help"
- "hilfe"
complaint:
name: "Beschwerde"
color: "&6"
material: "WRITABLE_BOOK"
aliases:
- "beschwerde"
- "complaint"
- "report"
- "melden"
other:
name: "Sonstiges"
color: "&8"
material: "FEATHER"
aliases:
- "sonstiges"
- "other"
- "misc"
# ----------------------------------------------------
# DISCORD WEBHOOK (Optional)
# ----------------------------------------------------
discord:
# Auf true setzen um Discord-Benachrichtigungen zu aktivieren
enabled: false
# Webhook-URL aus Discord (Kanaleinstellungen → Integrationen → Webhook erstellen)
webhook-url: ""
# Rollen-Ping: Discord-Rollen-ID (Rechtsklick auf Rolle → ID kopieren)
# Leer lassen ("") = kein Ping
role-ping-id: ""
messages:
# ── Neues Ticket ────────────────────────────────────────────────────────
new-ticket:
title: "🎫 Neues Ticket erstellt"
color: "3066993" # Grün
footer: "TicketSystem"
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 ──────────────────────────────────────────────────
ticket-closed:
enabled: false # Webhook-Nachricht beim Schließen senden
title: "🔒 Ticket geschlossen"
color: "15158332" # Rot
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 ───────────────────────────────────────────────
ticket-forwarded:
enabled: false # Webhook-Nachricht beim Weiterleiten senden
title: "🔀 Ticket weitergeleitet"
color: "15105570" # Orange
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
# ---------------------------------------------------- # ----------------------------------------------------
# SYSTEM-NACHRICHTEN (mit &-Farbcodes) # SYSTEM-NACHRICHTEN (mit &-Farbcodes)
# ---------------------------------------------------- # ----------------------------------------------------
@@ -82,13 +224,42 @@ messages:
ticket-claimed-notify: "&aDein Ticket &e#{id} &awurde von &e{claimer} &aangenommen." ticket-claimed-notify: "&aDein Ticket &e#{id} &awurde von &e{claimer} &aangenommen."
ticket-closed: "&aTicket &e#{id} &awurde geschlossen." ticket-closed: "&aTicket &e#{id} &awurde geschlossen."
ticket-forwarded: "&aTicket &e#{id} &awurde an &e{player} &aweitergeleitet." ticket-forwarded: "&aTicket &e#{id} &awurde an &e{player} &aweitergeleitet."
ticket-forwarded-notify: "&eDu hast ein Ticket von &6{player} &eweitergeleitet bekommen." ticket-forwarded-notify: "&eDu hast ein Ticket von &6{player} &eweitergeleitet bekommen. &7(ID: {id})"
# --- BENACHRICHTIGUNGEN FÜR DEN TICKET-ERSTELLER ---
ticket-closed-notify: "&aDein Ticket &e#{id} &awurde geschlossen."
ticket-forwarded-creator-notify: "&eDein Ticket &6#{id} &ewurde an &b{supporter} &eweitergeleitet."
# --- KATEGORIEN ---
# {category} wird durch den Anzeigenamen der gewählten Kategorie ersetzt
ticket-created-category: "&aTicket &e#{id} &aerstellt! &7Kategorie: {category}"
category-invalid: "&cUnbekannte Kategorie: &e{input}&c. Verfügbare Kategorien: &e{categories}"
# --- KOMMENTARE ---
comment-saved: "&aDein Kommentar zu Ticket &e#{id} &awurde gespeichert."
comment-notify: "&e[Ticket #{id}] &f{author} &7kommentiert: &f{message}"
comment-no-permission: "&cDu kannst nur deine eigenen Tickets kommentieren."
# --- BEWERTUNGEN ---
rating-saved-good: "&aDanke für deine Bewertung! &a👍 Positiv"
rating-saved-bad: "&aDanke für deine Bewertung! &c👎 Negativ"
rating-already-rated: "&cDu hast dieses Ticket bereits bewertet."
rating-not-yours: "&cDu kannst nur deine eigenen Tickets bewerten."
rating-disabled: "&cBewertungen sind aktuell deaktiviert."
rating-prompt: "&6Wie zufrieden bist du mit dem Support?\n&a/ticket rate {id} good &7 👍 Gut\n&c/ticket rate {id} bad &7 👎 Schlecht"
# --- BLACKLIST ---
blacklist-added: "&a{player} &awurde zur Ticket-Blacklist hinzugefügt. &7Grund: &e{reason}"
blacklist-removed: "&a{player} &awurde von der Blacklist entfernt."
blacklist-already: "&cSpieler ist bereits auf der Blacklist."
blacklist-not-found: "&cSpieler war nicht auf der Blacklist."
blacklist-blocked: "&cDu wurdest vom Ticket-System gesperrt und kannst keine Tickets erstellen."
# --- FEHLER & HINWEISE --- # --- FEHLER & HINWEISE ---
no-permission: "&cDu hast keine Berechtigung!" no-permission: "&cDu hast keine Berechtigung!"
no-open-tickets: "&aAktuell gibt es keine offenen Tickets." no-open-tickets: "&aAktuell gibt es keine offenen Tickets."
join-open-tickets: "&eEs gibt noch &6{count} &eoffene Ticket(s)!" join-open-tickets: "&eEs gibt noch &6{count} &eoffene Ticket(s)!"
new-ticket-notify: "&e{player} &ahat ein neues Ticket erstellt: &7{message}" new-ticket-notify: "&e{player} &ahat ein neues Ticket erstellt: &7{message} &7(ID: &e{id}&7)"
already-claimed: "&cDieses Ticket wurde bereits geclaimt!" already-claimed: "&cDieses Ticket wurde bereits geclaimt!"
ticket-not-found: "&cTicket nicht gefunden!" ticket-not-found: "&cTicket nicht gefunden!"
cooldown: "&cBitte warte &e{seconds} Sekunden &cbevor du ein neues Ticket erstellst." cooldown: "&cBitte warte &e{seconds} Sekunden &cbevor du ein neues Ticket erstellst."

View File

@@ -1,25 +1,62 @@
name: TicketSystem name: TicketSystem
version: 1.0.1 version: 1.0.5
main: de.ticketsystem.TicketPlugin main: de.ticketsystem.TicketPlugin
api-version: 1.20 api-version: 1.20
author: M_Viper author: M_Viper
description: Ingame Support Ticket System with MySQL description: Ingame Support Ticket System with MySQL
# ── BungeeCord Plugin-Messaging-Kanäle ───────────────────────────────────────
# PFLICHTFELD für Cross-Server-Benachrichtigungen!
channels:
- BungeeCord
- ticketsystem:notify
commands: commands:
ticket: ticket:
description: Ticket System Hauptbefehl description: TicketSystem Hauptbefehl
usage: /ticket <create|list|claim|close|forward|reload> usage: |
/ticket create [Kategorie] <Text>
/ticket list
/ticket comment <ID> <Nachricht>
/ticket rate <ID> <good|bad>
/ticket claim <ID>
/ticket close <ID> [Kommentar]
/ticket forward <ID> <Spieler>
/ticket blacklist <add|remove|list> [Spieler] [Grund]
/ticket stats
/ticket archive
/ticket reload
aliases: [t, support] aliases: [t, support]
permissions: permissions:
# ── Spieler-Permissions ───────────────────────────────────────────────────
ticket.create: ticket.create:
description: Spieler kann Tickets erstellen description: Spieler kann Tickets erstellen und kommentieren
default: true default: true
# ── Supporter-Permissions ─────────────────────────────────────────────────
ticket.support: ticket.support:
description: Supporter kann Tickets einsehen und claimen description: Supporter kann Tickets einsehen, claimen und schließen
default: false default: false
ticket.archive:
description: Zugriff auf das Ticket-Archiv (öffnen, einsehen, permanent löschen)
default: false
# ── Admin-Permissions ────────────────────────────────────────────────────
ticket.admin: ticket.admin:
description: Admin hat vollen Zugriff inkl. Weiterleitung und Reload description: >
Admin hat vollen Zugriff: Weiterleiten, Blacklist verwalten,
Statistiken, Reload, Archiv, Export/Import, Migration
default: op default: op
children:
ticket.support: true
ticket.blacklist: true
ticket.blacklist:
description: Kann Spieler zur Ticket-Blacklist hinzufügen und entfernen
default: false