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
[![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>
**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.
---
## 📋 Ü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 |
|---|---|
| 🗄️ **MySQL & Datei-Speicherung** | YAML/JSON oder MySQL/MariaDB jederzeit umschaltbar, Migration & Backup inklusive |
| 🔄 **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 |
1. TicketSystem.jar in den plugins-Ordner legen und Server starten
2. config.yml anpassen (Speicherorte, Nachrichten, Limits, Farben, MySQL-Daten etc.)
3. /ticket-Befehle nutzen
---
## 📦 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**
```
1. Lade die neueste TicketSystem.jar von den Releases herunter
2. Verschiebe die .jar in den /plugins Ordner deines Servers
3. Starte den Server neu (kein /reload verwenden!)
4. Die Konfigurationsdateien werden automatisch generiert
```
| Befehl | Beschreibung | Nutzergruppe |
|-------------------------------------|---------------------------------------------------|----------------------|
| /ticket | Hilfe & Übersicht | Spieler, Support |
| /ticket create [Kategorie] [Priorität] <Text> | Ticket erstellen | Spieler |
| /ticket list | Eigene Tickets in der GUI anzeigen | Spieler |
| /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**
```
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
```
### Rechte
**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>
<summary><b>📄 Beispiel: config.yml (klicken zum Ausklappen)</b></summary>
Beim Erstellen eines Tickets können Kategorie und Priorität optional angegeben werden. Die folgende Tabelle zeigt die Möglichkeiten und Beispiele:
```yaml
# TicketSystem - Hauptkonfiguration
# © 2026 Viper Plugins
| Befehl | Kategorie | Priorität |
|----------------------------------------|-------------|-----------|
| /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"
debug: false
**Verfügbare Prioritäten:**
- low
- normal
- high
- urgent
# Speicherung
data-file: "data.yml"
archive-file: "archive.yml"
use-mysql: false
use-json: false
(auch deutsch: niedrig, hoch, dringend)
# MySQL (nur wenn use-mysql: true)
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>
**Kategorien und Aliases** sind frei in der config.yml konfigurierbar.
---
## 💬 Befehle & Permissions
## Discord-Webhook
### Spieler-Befehle
| Befehl | Beschreibung | Permission |
|---|---|---|
| `/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` |
- Embeds mit Kategorie & Priorität
- Rollen-Ping pro Nachrichtentyp
- Drei Ereignisse: neues Ticket, Ticket geschlossen, Ticket weitergeleitet
### Admin-Befehle
| 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` |
Konfiguration in config.yml:
### Permissions-Übersicht
```
ticket.use → Ticket erstellen und eigene Tickets verwalten (Standard für alle Spieler)
ticket.admin → Zugriff auf alle Admin- und Management-Funktionen
```
discord:
enabled: true
webhook-url: "https://discord.com/api/webhooks/..."
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>
<summary><b>Kann ich zwischen MySQL und Datei-Speicherung wechseln?</b></summary>
TicketSystem bietet volle Unterstützung für BungeeCord-Netzwerke:
Ja! Einfach per `/ticket migrate tomysql` oder `/ticket migrate tofile`. Das Plugin migriert alle Daten automatisch und sicher kein Datenverlust.
</details>
- Tickets von jedem Server im Netzwerk
- 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>
<summary><b>Wie viele Tickets passen in die GUI?</b></summary>
**Voraussetzungen:**
- 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.
</details>
**Cross-Server-Befehle:**
- /ticket teleport <ID>
- /ticket forward <ID> <Spieler>
- /ticket archive
- /ticket list
<details>
<summary><b>Werden automatisch Backups erstellt?</b></summary>
Ja, bei jedem Speicherwechsel und regelmäßig nach dem konfigurierten Archiv-Intervall.
</details>
<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>
**Tipps:**
- Server-Name erscheint in GUI & Discord
- Zielspieler muss online sein
- Funktionen auch im Einzelserver-Modus
- Bei Problemen: gleiche MySQL, Kanäle in plugin.yml prüfen
---
## 📊 Vergleich
## Vergleich mit anderen Plugins
| | **TicketSystem** | SimpleTickets | AdvancedTickets |
|---|:---:|:---:|:---:|
| Speicher-Migration | ✅ Vollständig | ⚠️ Nur manuell | ❌ |
| Automatische Backups | ✅ | ⚠️ Teilweise | ❌ |
| Dynamische GUI | ✅ Modern | ⚠️ Basic | ❌ |
| Archivierung | ✅ Auto & manuell | ⚠️ Nur manuell | ❌ |
| Export / Import | ✅ | ❌ | ❌ |
| Debug-Modus | ✅ | ❌ | ❌ |
| Update-Checker | ✅ | ❌ | ❌ |
| Unit-Tests | ✅ | ❌ | ❌ |
TicketSystem hebt sich durch viele Alleinstellungsmerkmale von anderen Ticket-Plugins ab. Die folgende Tabelle zeigt die wichtigsten Unterschiede:
| Feature | TicketSystem | SimpleTickets | AdvancedTickets |
|-------------------------|:------------:|:-------------:|:---------------:|
| Speicher-Migration | ✔️ | ⚠️ | ✖️ |
| Automatische Backups | ✔️ | ⚠️ | ✖️ |
| GUI mit Kategorien | ✔️ | ⚠️ | ✖️ |
| Archivierung | ✔️ | ⚠️ | ✖️ |
| Rollenbasierter Archiv | ✔️ | ✖️ | ✖️ |
| 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 |
|---|---|
| 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 |
Du hast Fragen, brauchst Hilfe oder möchtest Feedback geben?
---
- [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**
Wenn TicketSystem deinen Server bereichert hat, freuen wir uns über eine Bewertung auf spigotmc!
</div>
**Dein Feedback zählt:**
Wenn TicketSystem deinen Server bereichert hat, freuen wir uns über eine 5-Sterne Bewertung auf SpigotMC!
Jede Rückmeldung hilft, das Plugin weiter zu verbessern und die Community zu stärken.

View File

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

View File

@@ -1,11 +1,14 @@
package de.ticketsystem;
import de.ticketsystem.bungee.BungeeMessenger;
import de.ticketsystem.commands.TicketCommand;
import de.ticketsystem.database.DatabaseManager;
import de.ticketsystem.discord.DiscordWebhook;
import de.ticketsystem.gui.TicketGUI;
import de.ticketsystem.listeners.PlayerJoinListener;
import de.ticketsystem.manager.CategoryManager;
import de.ticketsystem.manager.TicketManager;
import de.ticketsystem.model.Ticket;
import org.bukkit.ChatColor;
import org.bukkit.plugin.java.JavaPlugin;
@@ -16,18 +19,58 @@ public class TicketPlugin extends JavaPlugin {
private static TicketPlugin instance;
private boolean debug;
/**
* Name dieses Servers im BungeeCord-Netzwerk.
* Konfigurierbar in config.yml → server-name
* Wird in Tickets gespeichert und in Benachrichtigungen angezeigt.
*/
private String serverName;
private DatabaseManager databaseManager;
private TicketManager ticketManager;
private CategoryManager categoryManager;
private TicketGUI ticketGUI;
private DiscordWebhook discordWebhook;
private BungeeMessenger bungeeMessenger;
@Override
public void onEnable() {
instance = this;
// Config speichern falls nicht vorhanden
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;
new UpdateChecker(this, resourceId).getVersion(version -> {
String current = getDescription().getVersion();
@@ -35,12 +78,11 @@ public class TicketPlugin extends JavaPlugin {
String msg = ChatColor.translateAlternateColorCodes('&',
"&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 + ")");
// Sende Nachricht an alle Admins (online) mit 1 Sekunde Verzögerung
getServer().getScheduler().runTaskLater(this, () -> {
getServer().getOnlinePlayers().stream()
.filter(p -> p.hasPermission("ticket.admin"))
.forEach(p -> p.sendMessage(msg));
}, 20L); // 20 Ticks = 1 Sekunde
}, 20L);
} else {
getLogger().info("TicketSystem ist aktuell (Version " + current + ")");
}
@@ -50,46 +92,52 @@ public class TicketPlugin extends JavaPlugin {
String configVersion = getConfig().getString("version", "");
String expectedVersion = "2.0";
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);
// Datenbankverbindung aufbauen
// Datenbankverbindung
databaseManager = new DatabaseManager(this);
if (!databaseManager.connect()) {
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);
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);
Objects.requireNonNull(getCommand("ticket")).setExecutor(ticketCommand);
Objects.requireNonNull(getCommand("ticket")).setTabCompleter(ticketCommand);
// Events registrieren
getServer().getPluginManager().registerEvents(new PlayerJoinListener(this), 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);
if (archiveIntervalH > 0) {
long ticks = archiveIntervalH * 60L * 60L * 20L; // Stunden → Ticks
long ticks = archiveIntervalH * 60L * 60L * 20L;
getServer().getScheduler().runTaskTimer(this, () -> {
int archived = databaseManager.archiveClosedTickets();
if (archived > 0) {
getLogger().info("Automatische Archivierung: " + archived + " Tickets archiviert.");
if (isDebug()) getLogger().info("[DEBUG] Archivierung ausgeführt, " + archived + " Tickets verschoben.");
}
}, ticks, ticks);
getLogger().info("Automatische Archivierung alle " + archiveIntervalH + " Stunden aktiviert.");
if (isDebug()) getLogger().info("[DEBUG] Archivierungs-Timer gesetzt: alle " + archiveIntervalH + " Stunden.");
}
getLogger().info("TicketSystem erfolgreich gestartet!");
@@ -97,26 +145,22 @@ public class TicketPlugin extends JavaPlugin {
@Override
public void onDisable() {
if (databaseManager != null) {
databaseManager.disconnect();
}
// Plugin-Messaging-Kanäle abmelden
getServer().getMessenger().unregisterOutgoingPluginChannel(this);
getServer().getMessenger().unregisterIncomingPluginChannel(this);
if (databaseManager != null) databaseManager.disconnect();
getLogger().info("TicketSystem wurde deaktiviert.");
}
// ─────────────────────────── Hilfsmethoden ─────────────────────────────
/**
* Formatiert eine Nachricht aus der Config mit Prefix und Farben.
*/
public String formatMessage(String path) {
String prefix = color(getConfig().getString("prefix", "&8[&6Ticket&8] &r"));
String message = getConfig().getString(path, "&cNachricht nicht gefunden: " + path);
return prefix + color(message);
}
/**
* Konvertiert Farbcodes (&x → §x).
*/
public String color(String text) {
return ChatColor.translateAlternateColorCodes('&', text);
}
@@ -126,10 +170,21 @@ public class TicketPlugin extends JavaPlugin {
public static TicketPlugin getInstance() { return instance; }
public DatabaseManager getDatabaseManager() { return databaseManager; }
public TicketManager getTicketManager() { return ticketManager; }
public CategoryManager getCategoryManager() { return categoryManager; }
public TicketGUI getTicketGUI() { return ticketGUI; }
public DiscordWebhook getDiscordWebhook() { return discordWebhook; }
public BungeeMessenger getBungeeMessenger() { return bungeeMessenger; }
public boolean isDebug() { return debug; }
/**
* 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, () -> {
try (InputStream is = new URL("https://api.spigotmc.org/legacy/update.php?resource=" + this.resourceId).openStream(); Scanner scann = new Scanner(is)) {
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) {
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.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 org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.AsyncPlayerChatEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.*;
public class TicketGUI implements Listener {
// ─────────────────────────── Titel-Konstanten ──────────────────────────
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 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<>();
/** 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) {
this.plugin = plugin;
}
/** Aktuelle Seite pro Spieler (Admin, Archiv, Spieler) */
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) {
List<Ticket> tickets = plugin.getDatabaseManager().getTicketsByStatus(
/** Wartet auf Chat-Eingabe für Close-Kommentar */
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);
if (tickets.isEmpty()) {
player.sendMessage(plugin.formatMessage("messages.no-open-tickets"));
return;
// Kategorie-Filter anwenden
ConfigCategory filter = categoryFilter.getOrDefault(player.getUniqueId(), null);
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)
int size = Math.min(54, (int) (Math.ceil(tickets.size() / 9.0) * 9));
if (size < 9) size = 9;
// Priorität-Sortierung (URGENT → HIGH → NORMAL → LOW)
if (plugin.getConfig().getBoolean("priorities-enabled", true)) {
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<>();
for (int i = 0; i < tickets.size() && i < 54; i++) {
Ticket ticket = tickets.get(i);
ItemStack item = buildTicketItem(ticket);
inv.setItem(i, item);
int start = page * PAGE_SIZE;
for (int i = 0; i < PAGE_SIZE && (start + i) < all.size(); i++) {
Ticket ticket = all.get(start + i);
inv.setItem(i, buildAdminListItem(ticket));
slotMap.put(i, ticket);
}
// Trennlinie am Ende, wenn Platz
fillEmpty(inv);
fillAdminNavigation(inv, false, player, page, totalPages);
playerSlotMap.put(player.getUniqueId(), slotMap);
player.openInventory(inv);
}
// ─────────────────────────── Item bauen ────────────────────────────────
// ═══════════════════════════════════════════════════════════════════════
// ADMIN ARCHIV GUI
// ═══════════════════════════════════════════════════════════════════════
private ItemStack buildTicketItem(Ticket ticket) {
// Material je nach Status
Material mat;
switch (ticket.getStatus()) {
case OPEN -> mat = Material.PAPER;
case CLAIMED -> mat = Material.YELLOW_DYE;
case FORWARDED -> mat = Material.ORANGE_DYE;
default -> mat = Material.PAPER;
public void openClosedGUI(Player player) { openClosedGUI(player, archivePage.getOrDefault(player.getUniqueId(), 0)); }
public void openClosedGUI(Player player, int page) {
if (!player.hasPermission(ARCHIVE_PERMISSION)) {
player.sendMessage(plugin.color("&cDu hast keine Berechtigung, das Archiv zu öffnen."));
return;
}
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);
ItemMeta meta = item.getItemMeta();
if (meta == null) return item;
// 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());
fillAdminNavigation(inv, true, player, page, totalPages);
playerClosedSlotMap.put(player.getUniqueId(), slotMap);
player.openInventory(inv);
}
lore.add("§8§m ");
if (ticket.getStatus() == TicketStatus.OPEN) {
lore.add("§a§l» KLICKEN zum Claimen & Teleportieren");
// ═══════════════════════════════════════════════════════════════════════
// SPIELER-GUI (paginiert)
// ═══════════════════════════════════════════════════════════════════════
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 {
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);
item.setItemMeta(meta);
return item;
// Slot 12: Claimen / Löschen / Grau
if (ticket.getStatus() == TicketStatus.OPEN) {
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) {
ItemStack glass = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
ItemMeta meta = glass.getItemMeta();
if (meta != null) { meta.setDisplayName(" "); glass.setItemMeta(meta); }
for (int i = 0; i < inv.getSize(); i++) {
if (inv.getItem(i) == null) inv.setItem(i, glass);
}
// Slot 14: Schließen
if (ticket.getStatus() != TicketStatus.CLOSED) {
inv.setItem(14, buildActionItem(Material.RED_WOOL, "§c§lTicket schließen",
List.of("§7Schließt das Ticket.", "§8§m ", "§eKlick für Kommentar-Eingabe.")));
} else {
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
public void onInventoryClick(InventoryClickEvent event) {
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);
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());
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);
if (ticket == null) return;
player.closeInventory();
// Asynchron aus DB neu laden (aktuelle Daten)
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
Ticket fresh = plugin.getDatabaseManager().getTicketById(ticket.getId());
if (fresh == null) {
player.sendMessage(plugin.formatMessage("messages.ticket-not-found"));
if (ticket.getStatus() == TicketStatus.OPEN || ticket.getStatus() == TicketStatus.CLOSED) {
boolean success = plugin.getDatabaseManager().markAsPlayerDeleted(ticket.getId());
Bukkit.getScheduler().runTask(plugin, () -> {
if (success) {
player.sendMessage(plugin.color("&aDein Ticket &e#" + ticket.getId() + " &awurde aus deiner Übersicht entfernt."));
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;
}
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) {
// Versuche zu claimen, wenn noch OPEN
if (ticket.getStatus() == TicketStatus.OPEN) {
boolean success = plugin.getDatabaseManager().claimTicket(
ticket.getId(), player.getUniqueId(), player.getName());
if (success) {
ticket.setStatus(TicketStatus.CLAIMED);
ticket.setClaimerUUID(player.getUniqueId());
ticket.setClaimerName(player.getName());
player.sendMessage(plugin.formatMessage("messages.ticket-claimed")
.replace("{id}", String.valueOf(ticket.getId()))
.replace("{player}", ticket.getCreatorName()));
plugin.getTicketManager().notifyCreatorClaimed(ticket);
} else {
player.sendMessage(plugin.formatMessage("messages.already-claimed"));
}
}
// Teleportation zur Ticket-Position
// ── BUG FIX: handleDetailTeleport ────────────────────────────────────────
// Vorher: Teleport wurde immer ausgeführt auch bei aktivem BungeeCord.
// ticket.getLocation() gibt null zurück wenn die World auf diesem
// Server nicht existiert → NullPointerException oder falscher Teleport.
//
// Fix: Bei bungeecord: true + bungee-teleport-enabled: true →
// 1. Zielposition in DB speichern (ticket_pending_teleport)
// 2. Spieler via Plugin Messaging Channel auf Ziel-Server schicken
// 3. PlayerJoinListener teleportiert ihn dort zur Position
// Bei bungeecord: true + bungee-teleport-enabled: false → gesperrt.
// Bei bungeecord: false → normaler lokaler Teleport wie bisher.
//
// Hinweis: Ist der Admin bereits auf dem Ziel-Server, wird direkt teleportiert.
// ─────────────────────────────────────────────────────────────────────────
private void handleDetailTeleport(Player player, Ticket ticket) {
if (!plugin.isBungeeCordEnabled()) {
// ── Standalone-Modus: direkt teleportieren ──
if (ticket.getLocation() != null) {
player.teleport(ticket.getLocation());
player.sendMessage(plugin.color("&7Du wurdest zu Ticket &e#" + ticket.getId() + " &7teleportiert."));
} else {
player.sendMessage(plugin.color("&cDie Welt des Tickets ist nicht geladen!"));
}
openDetailGUI(player, ticket);
return;
}
// ── BungeeCord-Modus ──────────────────────────────────────────────
boolean bungeeTP = plugin.getConfig().getBoolean("bungee-teleport-enabled", true);
if (!bungeeTP) {
String serverHint = !"unknown".equals(ticket.getServerName())
? " §7(Server: §b" + ticket.getServerName() + "§7)" : "";
player.sendMessage(plugin.color("&cServerübergreifender Teleport ist in der Config deaktiviert." + serverHint));
openDetailGUI(player, ticket);
return;
}
String targetServer = ticket.getServerName();
if ("unknown".equals(targetServer)) {
player.sendMessage(plugin.color("&cServer des Tickets unbekannt Teleport nicht möglich."));
openDetailGUI(player, ticket);
return;
}
String currentServer = plugin.getServerName();
if (currentServer.equals(targetServer)) {
// ── Bereits auf dem richtigen Server: direkt teleportieren ────
if (ticket.getLocation() != null) {
player.teleport(ticket.getLocation());
player.sendMessage(plugin.color("&7Du wurdest zu Ticket &e#" + ticket.getId() + " &7teleportiert."));
} else {
player.sendMessage(plugin.color("&cDie Welt des Tickets ist nicht geladen!"));
}
openDetailGUI(player, ticket);
} else {
// ── Anderer Server: Position in DB speichern + Server-Wechsel ─
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
plugin.getDatabaseManager().setPendingTeleport(
player.getUniqueId(),
ticket.getWorldName(),
ticket.getX(), ticket.getY(), ticket.getZ(),
ticket.getYaw(), ticket.getPitch()
);
Bukkit.getScheduler().runTask(plugin, () -> {
// BungeeCord Plugin Messaging Channel: Spieler auf Ziel-Server schicken
player.sendMessage(plugin.color("&7Verbinde dich mit Server &b" + targetServer
+ " &7für Ticket &e#" + ticket.getId() + "&7..."));
try {
java.io.ByteArrayOutputStream b = new java.io.ByteArrayOutputStream();
java.io.DataOutputStream out = new java.io.DataOutputStream(b);
out.writeUTF("Connect");
out.writeUTF(targetServer);
player.sendPluginMessage(plugin, "BungeeCord", b.toByteArray());
} catch (Exception e) {
plugin.getLogger().warning("[TicketSystem] BungeeCord Connect fehlgeschlagen: " + e.getMessage());
player.sendMessage(plugin.color("&cServer-Wechsel fehlgeschlagen. Bitte manuell verbinden."));
}
});
});
}
}
// ── BUG FIX: handleDetailClaim ───────────────────────────────────────────
// Vorher: Nach erfolgreichem Claim wurde immer teleportiert wenn
// ticket.getLocation() != null unabhängig von BungeeCord.
//
// Fix: Teleport nach Claim nutzt dieselbe Logik wie handleDetailTeleport:
// Standalone → direkt, BungeeCord + enabled → Server-Wechsel + pending,
// BungeeCord + disabled → nur Nachricht, kein Teleport.
// ─────────────────────────────────────────────────────────────────────────
private void handleDetailClaim(Player player, Ticket ticket) {
if (ticket.getStatus() != TicketStatus.OPEN) {
player.sendMessage(plugin.formatMessage("messages.already-claimed"));
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;
import java.util.List;
import de.ticketsystem.TicketPlugin;
import de.ticketsystem.database.DatabaseManager;
import de.ticketsystem.model.Ticket;
import de.ticketsystem.model.TicketStatus;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
@@ -19,21 +27,113 @@ public class PlayerJoinListener implements Listener {
public void onPlayerJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
// Nur Supporter und Admins erhalten die Join-Benachrichtigung
if (!player.hasPermission("ticket.support") && !player.hasPermission("ticket.admin")) return;
// Verzögerung von 2 Sekunden damit die Join-Sequenz abgeschlossen ist
// ── Supporter/Admin: offene Tickets anzeigen ──────────────────────
if (player.hasPermission("ticket.support") || player.hasPermission("ticket.admin")) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
int count = plugin.getDatabaseManager().countOpenTickets();
if (count > 0) {
Bukkit.getScheduler().runTaskLater(plugin, () -> {
String msg = plugin.formatMessage("messages.join-open-tickets")
.replace("{count}", String.valueOf(count));
player.sendMessage(msg);
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;
import de.ticketsystem.TicketPlugin;
import de.ticketsystem.model.ConfigCategory;
import de.ticketsystem.model.Ticket;
import de.ticketsystem.model.TicketStatus;
import org.bukkit.Bukkit;
@@ -14,7 +15,7 @@ public class TicketManager {
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<>();
public TicketManager(TicketPlugin plugin) {
@@ -35,62 +36,308 @@ public class TicketManager {
return Math.max(0, (cooldownMillis - elapsed) / 1000);
}
public void setCooldown(UUID uuid) {
cooldowns.put(uuid, System.currentTimeMillis());
}
public void setCooldown(UUID uuid) { cooldowns.put(uuid, System.currentTimeMillis()); }
// ─────────────────────────── 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) {
String msg = plugin.formatMessage("messages.new-ticket-notify")
.replace("{player}", ticket.getCreatorName())
.replace("{message}", ticket.getMessage())
.replace("{id}", String.valueOf(ticket.getId()));
String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unbekannt";
String message = ticket.getMessage() != null ? ticket.getMessage() : "";
// 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()) {
if (p.hasPermission("ticket.support") || p.hasPermission("ticket.admin")) {
p.sendMessage(msg);
p.sendMessage(guiHint);
}
}
}
// Klickbaren Hinweis senden (Bukkit Chat-Component)
p.sendMessage(plugin.color("&7» Klicke &e/ticket list &7um die GUI zu öffnen."));
}
}
plugin.getDiscordWebhook().sendNewTicket(ticket);
}
/**
* 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) {
Player creator = Bukkit.getPlayer(ticket.getCreatorUUID());
if (creator != null && creator.isOnline()) {
String claimerName = resolveClaimerName(ticket);
String msg = plugin.formatMessage("messages.ticket-claimed-notify")
.replace("{id}", String.valueOf(ticket.getId()))
.replace("{claimer}", ticket.getClaimerName());
creator.sendMessage(msg);
.replace("{claimer}", claimerName);
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.
* BungeeCord: Zustellung auch wenn der Supporter auf einem anderen Server ist.
*/
public void notifyForwardedTo(Ticket ticket) {
Player target = Bukkit.getPlayer(ticket.getForwardedToUUID());
if (target != null && target.isOnline()) {
public void notifyForwardedTo(Ticket ticket, String fromName) {
if (ticket.getForwardedToUUID() == null) return;
String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unbekannt";
String msg = plugin.formatMessage("messages.ticket-forwarded-notify")
.replace("{player}", ticket.getCreatorName())
.replace("{player}", creatorName)
.replace("{id}", String.valueOf(ticket.getId()));
target.sendMessage(msg);
deliverToPlayer(ticket.getForwardedToUUID(), ticket.getForwardedToName(), msg);
plugin.getDiscordWebhook().sendTicketForwarded(ticket, fromName);
}
/**
* Benachrichtigt den Ersteller, wenn sein Ticket geschlossen wurde.
* BungeeCord: Cross-Server-Zustellung + Fallback in Pending-DB.
*/
public void notifyCreatorClosed(Ticket ticket) { notifyCreatorClosed(ticket, null); }
public void notifyCreatorClosed(Ticket ticket, String closerName) {
// 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 ─────────────────────────────
/**
* Prüft, ob ein Spieler zu viele offene Tickets hat.
*/
private String resolveClaimerName(Ticket ticket) {
if (ticket.getClaimerName() != null) return ticket.getClaimerName();
if (ticket.getClaimerUUID() != null) {
String name = Bukkit.getOfflinePlayer(ticket.getClaimerUUID()).getName();
if (name != null) return name;
}
return "Support";
}
public boolean hasReachedTicketLimit(UUID uuid) {
int max = plugin.getConfig().getInt("max-open-tickets-per-player", 2);
if (max <= 0) return false;
@@ -101,16 +348,29 @@ public class TicketManager {
player.sendMessage(plugin.color("&8&m "));
player.sendMessage(plugin.color("&6TicketSystem &7 Befehle"));
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")) {
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 close <ID> &7 Ticket schließen"));
player.sendMessage(plugin.color("&e/ticket close <ID> [Kommentar] &7 Ticket schließen"));
}
if (player.hasPermission("ticket.admin")) {
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 stats &7 Statistiken anzeigen"));
}
player.sendMessage(plugin.color("&8&m "));
// BungeeCord-Status anzeigen
if (player.hasPermission("ticket.admin") && plugin.isBungeeCordEnabled()) {
player.sendMessage(plugin.color("&8[BungeeCord] &7Server: &b" + plugin.getServerName()
+ " &8| Cross-Server-Benachrichtigungen &aaktiv"));
}
}
}

View File

@@ -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.Location;
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.util.HashMap;
import java.util.Map;
import java.util.UUID;
public class Ticket {
@SerializableAs("Ticket")
public class Ticket implements ConfigurationSerializable {
private int id;
private UUID creatorUUID;
private String creatorName;
private String message;
// Location-Felder (werden separat gespeichert)
private String worldName;
private double x, y, z;
private float yaw, pitch;
/**
* Name des Servers auf dem das Ticket erstellt wurde (BungeeCord-Netzwerk).
* Entspricht dem Wert aus config.yml → server-name.
* Standardwert: "unknown"
*/
private String serverName = "unknown";
private TicketStatus status;
private UUID claimerUUID;
private String claimerName;
@@ -27,6 +40,24 @@ public class Ticket {
private Timestamp createdAt;
private Timestamp claimedAt;
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() {}
@@ -35,74 +66,144 @@ public class Ticket {
this.creatorName = creatorName;
this.message = message;
this.worldName = location.getWorld().getName();
this.x = location.getX();
this.y = location.getY();
this.z = location.getZ();
this.yaw = location.getYaw();
this.pitch = location.getPitch();
this.x = location.getX(); this.y = location.getY(); this.z = location.getZ();
this.yaw = location.getYaw(); this.pitch = location.getPitch();
this.status = TicketStatus.OPEN;
this.createdAt = new Timestamp(System.currentTimeMillis());
}
public Location getLocation() {
World world = Bukkit.getWorld(worldName);
if (world == null) return null;
return new Location(world, x, y, z, yaw, pitch);
public Ticket(Map<String, Object> map) {
this.id = (int) map.get("id");
Object cObj = map.get("creatorUUID");
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 void setId(int id) { this.id = id; }
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 void setCreatorName(String creatorName) { this.creatorName = creatorName; }
public void setCreatorName(String v) { this.creatorName = v; }
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 void setWorldName(String worldName) { this.worldName = worldName; }
public void setWorldName(String v) { this.worldName = v; }
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 void setY(double y) { this.y = y; }
public void setY(double v) { this.y = v; }
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 void setYaw(float yaw) { this.yaw = yaw; }
public void setYaw(float v) { this.yaw = v; }
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 void setStatus(TicketStatus status) { this.status = status; }
public void setStatus(TicketStatus v) { this.status = v; }
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 void setClaimerName(String claimerName) { this.claimerName = claimerName; }
public void setClaimerName(String v) { this.claimerName = v; }
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 void setForwardedToName(String forwardedToName) { this.forwardedToName = forwardedToName; }
public void setForwardedToName(String v) { this.forwardedToName = v; }
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 void setClaimedAt(Timestamp claimedAt) { this.claimedAt = claimedAt; }
public void setClaimedAt(Timestamp v) { this.claimedAt = v; }
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: 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
# ----------------------------------------------------
@@ -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)
# ----------------------------------------------------
# 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)
# ----------------------------------------------------
@@ -82,13 +224,42 @@ messages:
ticket-claimed-notify: "&aDein Ticket &e#{id} &awurde von &e{claimer} &aangenommen."
ticket-closed: "&aTicket &e#{id} &awurde geschlossen."
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 ---
no-permission: "&cDu hast keine Berechtigung!"
no-open-tickets: "&aAktuell gibt es keine offenen Tickets."
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!"
ticket-not-found: "&cTicket nicht gefunden!"
cooldown: "&cBitte warte &e{seconds} Sekunden &cbevor du ein neues Ticket erstellst."

View File

@@ -1,25 +1,62 @@
name: TicketSystem
version: 1.0.1
version: 1.0.5
main: de.ticketsystem.TicketPlugin
api-version: 1.20
author: M_Viper
description: Ingame Support Ticket System with MySQL
# ── BungeeCord Plugin-Messaging-Kanäle ───────────────────────────────────────
# PFLICHTFELD für Cross-Server-Benachrichtigungen!
channels:
- BungeeCord
- ticketsystem:notify
commands:
ticket:
description: Ticket System Hauptbefehl
usage: /ticket <create|list|claim|close|forward|reload>
description: TicketSystem Hauptbefehl
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]
permissions:
# ── Spieler-Permissions ───────────────────────────────────────────────────
ticket.create:
description: Spieler kann Tickets erstellen
description: Spieler kann Tickets erstellen und kommentieren
default: true
# ── Supporter-Permissions ─────────────────────────────────────────────────
ticket.support:
description: Supporter kann Tickets einsehen und claimen
description: Supporter kann Tickets einsehen, claimen und schließen
default: false
ticket.archive:
description: Zugriff auf das Ticket-Archiv (öffnen, einsehen, permanent löschen)
default: false
# ── Admin-Permissions ────────────────────────────────────────────────────
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
children:
ticket.support: true
ticket.blacklist: true
ticket.blacklist:
description: Kann Spieler zur Ticket-Blacklist hinzufügen und entfernen
default: false