i forgot to make small contributions

This commit is contained in:
2026-02-04 14:02:40 -05:00
parent da08846b5c
commit 81bd9f286d
32 changed files with 2743 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# gradle
.gradle/
build/
out/
# intellij
.idea/
*.iml
*.ipr
*.iws
# eclipse
.classpath
.project
.settings/
# vscode
.vscode/
# macos
.DS_Store
# logs
*.log

58
build.gradle.kts Normal file
View File

@@ -0,0 +1,58 @@
plugins {
java
id("com.gradleup.shadow") version "8.3.0"
}
group = "party.cybsec"
version = "1.0.0-SNAPSHOT"
repositories {
mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/")
}
dependencies {
// paper api
compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT")
// sqlite
implementation("org.xerial:sqlite-jdbc:3.47.1.0")
// jda discord bot
implementation("net.dv8tion:JDA:6.3.0") {
exclude(module = "opus-java")
}
}
java {
toolchain.languageVersion.set(JavaLanguageVersion.of(21))
}
tasks {
shadowJar {
archiveClassifier.set("")
// relocate dependencies to avoid conflicts
relocate("org.xerial", "party.cybsec.oyetickets.libs.xerial")
}
jar {
archiveClassifier.set("slim")
}
build {
dependsOn(shadowJar)
}
processResources {
val props = mapOf("version" to version)
inputs.properties(props)
filteringCharset = "UTF-8"
filesMatching("plugin.yml") {
expand(props)
}
}
}

3
gradle.properties Normal file
View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2G
org.gradle.parallel=true
org.gradle.caching=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

1
settings.gradle.kts Normal file
View File

@@ -0,0 +1 @@
rootProject.name = "oyetickets"

View File

@@ -0,0 +1,98 @@
package party.cybsec.oyetickets;
import party.cybsec.oyetickets.command.AdminCommand;
import party.cybsec.oyetickets.command.TicketCommand;
import party.cybsec.oyetickets.command.TicketsCommand;
import party.cybsec.oyetickets.config.Config;
import party.cybsec.oyetickets.database.Database;
import party.cybsec.oyetickets.discord.DiscordBot;
import party.cybsec.oyetickets.session.SessionManager;
import org.bukkit.plugin.java.JavaPlugin;
public final class OyeTicketsPlugin extends JavaPlugin {
private static OyeTicketsPlugin instance;
private Database database;
private Config pluginConfig;
private DiscordBot discordBot;
private SessionManager sessionManager;
@Override
public void onEnable() {
instance = this;
// save default config
saveDefaultConfig();
// load config
this.pluginConfig = new Config(this);
// initialize database
this.database = new Database(this);
if (!database.initialize()) {
getLogger().severe("failed to initialize database, disabling plugin");
getServer().getPluginManager().disablePlugin(this);
return;
}
// init session manager
this.sessionManager = new SessionManager();
// init discord bot
if (pluginConfig.isDiscordEnabled()) {
this.discordBot = new DiscordBot(this);
if (!discordBot.start()) {
getLogger().warning("failed to start discord bot, continuing without discord integration");
}
}
// register commands
getCommand("ticket").setExecutor(new TicketCommand(this));
getCommand("tickets").setExecutor(new TicketsCommand(this));
AdminCommand adminCommand = new AdminCommand(this);
getCommand("oyetickets").setExecutor(adminCommand);
getCommand("oyetickets").setTabCompleter(adminCommand);
getLogger().info("oyetickets enabled");
}
@Override
public void onDisable() {
// cleanup sessions
if (sessionManager != null) {
sessionManager.cleanup();
}
// shutdown discord bot
if (discordBot != null) {
discordBot.shutdown();
}
// close database
if (database != null) {
database.close();
}
getLogger().info("oyetickets disabled");
}
public static OyeTicketsPlugin getInstance() {
return instance;
}
public Database getDatabase() {
return database;
}
public Config getPluginConfig() {
return pluginConfig;
}
public DiscordBot getDiscordBot() {
return discordBot;
}
public SessionManager getSessionManager() {
return sessionManager;
}
}

View File

@@ -0,0 +1,76 @@
package party.cybsec.oyetickets.command;
import party.cybsec.oyetickets.OyeTicketsPlugin;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
public class AdminCommand implements CommandExecutor, TabCompleter {
private final OyeTicketsPlugin plugin;
public AdminCommand(OyeTicketsPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label,
@NotNull String[] args) {
if (!sender.hasPermission("tickets.admin")) {
sender.sendMessage(Component.text("no permission").color(NamedTextColor.RED));
return true;
}
if (args.length == 0) {
sendHelp(sender);
return true;
}
switch (args[0].toLowerCase()) {
case "reload" -> {
plugin.getPluginConfig().reload();
plugin.getDiscordBot().shutdown();
plugin.getDiscordBot().start();
sender.sendMessage(
Component.text("oyetickets config and discord bot reloaded").color(NamedTextColor.GREEN));
}
case "status" -> {
sender.sendMessage(Component.text("--- oyetickets status ---").color(NamedTextColor.GOLD));
sender.sendMessage(Component
.text("discord: " + (plugin.getDiscordBot().getJda() != null ? "connected" : "disconnected"))
.color(NamedTextColor.GRAY));
sender.sendMessage(Component.text("database: connected").color(NamedTextColor.GRAY));
}
default -> sendHelp(sender);
}
return true;
}
private void sendHelp(CommandSender sender) {
sender.sendMessage(Component.text("--- oyetickets admin ---").color(NamedTextColor.GOLD));
sender.sendMessage(Component.text("/oyetickets reload - reload configuration").color(NamedTextColor.GRAY));
sender.sendMessage(Component.text("/oyetickets status - show plugin status").color(NamedTextColor.GRAY));
}
@Override
public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String alias, @NotNull String[] args) {
if (!sender.hasPermission("tickets.admin"))
return null;
if (args.length == 1) {
return List.of("reload", "status");
}
return new ArrayList<>();
}
}

View File

@@ -0,0 +1,64 @@
package party.cybsec.oyetickets.command;
import party.cybsec.oyetickets.OyeTicketsPlugin;
import party.cybsec.oyetickets.session.TicketSession;
import party.cybsec.oyetickets.ui.player.NavigationDialog;
import party.cybsec.oyetickets.util.PermissionUtil;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.sql.SQLException;
public class TicketCommand implements CommandExecutor {
private final OyeTicketsPlugin plugin;
public TicketCommand(OyeTicketsPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String label, @NotNull String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("only players can create tickets")
.color(NamedTextColor.RED));
return true;
}
// check permission
if (!PermissionUtil.checkAndNotify(player, "tickets.create")) {
return true;
}
// check cooldown
if (plugin.getPluginConfig().isCooldownEnabled()) {
try {
int cooldownSeconds = plugin.getPluginConfig().getCooldownDuration();
if (plugin.getDatabase().isOnCooldown(player.getUniqueId(), cooldownSeconds)) {
player.sendMessage(Component.text("you're creating tickets too quickly, please wait")
.color(NamedTextColor.RED));
return true;
}
} catch (SQLException e) {
plugin.getLogger().severe("failed to check cooldown: " + e.getMessage());
}
}
// create or get session
TicketSession session = plugin.getSessionManager().getOrCreateSession(player);
// capture location immediately
session.setLocation(player.getLocation());
// open navigation dialog
new NavigationDialog(plugin, player, session).open();
return true;
}
}

View File

@@ -0,0 +1,41 @@
package party.cybsec.oyetickets.command;
import party.cybsec.oyetickets.OyeTicketsPlugin;
import party.cybsec.oyetickets.ui.staff.TicketBrowserDialog;
import party.cybsec.oyetickets.util.PermissionUtil;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
public class TicketsCommand implements CommandExecutor {
private final OyeTicketsPlugin plugin;
public TicketsCommand(OyeTicketsPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String label, @NotNull String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("only players can view tickets")
.color(NamedTextColor.RED));
return true;
}
// check permission
if (!PermissionUtil.checkAndNotify(player, "tickets.view")) {
return true;
}
// open ticket browser
new TicketBrowserDialog(plugin, player).open();
return true;
}
}

View File

@@ -0,0 +1,56 @@
package party.cybsec.oyetickets.config;
import party.cybsec.oyetickets.OyeTicketsPlugin;
import org.bukkit.configuration.file.FileConfiguration;
public class Config {
private final OyeTicketsPlugin plugin;
private FileConfiguration config;
public Config(OyeTicketsPlugin plugin) {
this.plugin = plugin;
this.config = plugin.getConfig();
}
public void reload() {
plugin.reloadConfig();
this.config = plugin.getConfig();
}
// discord
public boolean isDiscordEnabled() {
return config.getBoolean("discord.enabled", true);
}
public String getDiscordBotToken() {
return config.getString("discord.bot-token", "");
}
public String getDiscordChannelId() {
return config.getString("discord.channel-id", "");
}
// cooldown
public boolean isCooldownEnabled() {
return config.getBoolean("cooldown.enabled", true);
}
public int getCooldownDuration() {
return config.getInt("cooldown.duration-seconds", 300);
}
// duplicate detection
public boolean isDuplicateDetectionEnabled() {
return config.getBoolean("duplicate-detection.enabled", true);
}
public double getSimilarityThreshold() {
return config.getDouble("duplicate-detection.similarity-threshold", 0.8);
}
// guidelines
public String getGuidelines() {
return config.getString("guidelines", "no guidelines configured");
}
}

View File

@@ -0,0 +1,351 @@
package party.cybsec.oyetickets.database;
import party.cybsec.oyetickets.OyeTicketsPlugin;
import party.cybsec.oyetickets.model.Ticket;
import party.cybsec.oyetickets.model.TicketCategory;
import party.cybsec.oyetickets.model.TicketStatus;
import java.io.File;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class Database {
private final OyeTicketsPlugin plugin;
private Connection connection;
public Database(OyeTicketsPlugin plugin) {
this.plugin = plugin;
}
public boolean initialize() {
try {
File dataFolder = plugin.getDataFolder();
if (!dataFolder.exists()) {
dataFolder.mkdirs();
}
File dbFile = new File(dataFolder, "tickets.db");
String url = "jdbc:sqlite:" + dbFile.getAbsolutePath();
connection = DriverManager.getConnection(url);
// enable foreign keys
try (Statement stmt = connection.createStatement()) {
stmt.execute("PRAGMA foreign_keys = ON");
}
// run migrations
runMigrations();
plugin.getLogger().info("database initialized");
return true;
} catch (SQLException e) {
plugin.getLogger().severe("failed to initialize database: " + e.getMessage());
e.printStackTrace();
return false;
}
}
private void runMigrations() throws SQLException {
// create schema version table
try (Statement stmt = connection.createStatement()) {
stmt.execute("""
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
)
""");
}
int currentVersion = getCurrentVersion();
// migration 1: initial schema
if (currentVersion < 1) {
try (Statement stmt = connection.createStatement()) {
stmt.execute("""
CREATE TABLE tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL,
player_name TEXT NOT NULL,
category TEXT NOT NULL,
short_desc TEXT NOT NULL,
long_desc TEXT,
world TEXT NOT NULL,
x REAL NOT NULL,
y REAL NOT NULL,
z REAL NOT NULL,
yaw REAL NOT NULL,
pitch REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'NONE',
assigned_to TEXT,
discord_thread_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
""");
stmt.execute("""
CREATE TABLE staff_filters (
uuid TEXT PRIMARY KEY,
filter_unseen BOOLEAN DEFAULT 1,
filter_active BOOLEAN DEFAULT 1,
filter_waiting BOOLEAN DEFAULT 1,
filter_solved BOOLEAN DEFAULT 0,
filter_closed BOOLEAN DEFAULT 0
)
""");
stmt.execute("""
CREATE TABLE cooldowns (
uuid TEXT PRIMARY KEY,
last_ticket INTEGER NOT NULL
)
""");
stmt.execute("""
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
staff_uuid TEXT NOT NULL,
action TEXT NOT NULL,
timestamp INTEGER NOT NULL,
FOREIGN KEY (ticket_id) REFERENCES tickets(id)
)
""");
setVersion(1);
plugin.getLogger().info("applied migration 1: initial schema");
}
}
}
private int getCurrentVersion() throws SQLException {
try (Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery("SELECT MAX(version) FROM schema_version")) {
if (rs.next()) {
return rs.getInt(1);
}
return 0;
}
}
private void setVersion(int version) throws SQLException {
try (PreparedStatement stmt = connection.prepareStatement(
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)")) {
stmt.setInt(1, version);
stmt.setLong(2, System.currentTimeMillis());
stmt.executeUpdate();
}
}
// ticket operations
public int saveTicket(Ticket ticket) throws SQLException {
return createTicket(ticket);
}
public int createTicket(Ticket ticket) throws SQLException {
String sql = """
INSERT INTO tickets (uuid, player_name, category, short_desc, long_desc,
world, x, y, z, yaw, pitch, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement stmt = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
stmt.setString(1, ticket.getPlayerUuid().toString());
stmt.setString(2, ticket.getPlayerName());
stmt.setString(3, ticket.getCategory().name());
stmt.setString(4, ticket.getShortDesc());
stmt.setString(5, ticket.getLongDesc());
stmt.setString(6, ticket.getWorld());
stmt.setDouble(7, ticket.getX());
stmt.setDouble(8, ticket.getY());
stmt.setDouble(9, ticket.getZ());
stmt.setFloat(10, ticket.getYaw());
stmt.setFloat(11, ticket.getPitch());
stmt.setString(12, ticket.getStatus().name());
stmt.setLong(13, ticket.getCreatedAt());
stmt.setLong(14, ticket.getUpdatedAt());
stmt.executeUpdate();
try (ResultSet rs = stmt.getGeneratedKeys()) {
if (rs.next()) {
return rs.getInt(1);
}
}
}
throw new SQLException("failed to get generated ticket id");
}
public Ticket getTicket(int id) throws SQLException {
String sql = "SELECT * FROM tickets WHERE id = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setInt(1, id);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return mapResultSetToTicket(rs);
}
}
}
return null;
}
public List<Ticket> getTickets() throws SQLException {
List<Ticket> tickets = new ArrayList<>();
String sql = "SELECT * FROM tickets ORDER BY created_at DESC";
try (PreparedStatement pstmt = connection.prepareStatement(sql);
ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
tickets.add(mapResultSetToTicket(rs));
}
}
return tickets;
}
public void updateTicketStatus(int ticketId, TicketStatus status) throws SQLException {
String sql = "UPDATE tickets SET status = ?, updated_at = ? WHERE id = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, status.name());
stmt.setLong(2, System.currentTimeMillis());
stmt.setInt(3, ticketId);
stmt.executeUpdate();
}
}
public void updateTicketAssignment(int ticketId, UUID staffUuid) throws SQLException {
String sql = "UPDATE tickets SET assigned_to = ?, updated_at = ? WHERE id = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, staffUuid != null ? staffUuid.toString() : null);
stmt.setLong(2, System.currentTimeMillis());
stmt.setInt(3, ticketId);
stmt.executeUpdate();
}
}
public void updateDiscordThreadId(int ticketId, String threadId) throws SQLException {
String sql = "UPDATE tickets SET discord_thread_id = ? WHERE id = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, threadId);
stmt.setInt(2, ticketId);
stmt.executeUpdate();
}
}
public void deleteTicket(int ticketId) throws SQLException {
// delete from audit log first due to foreign keys if they exist (though
// audit_log has it)
try (PreparedStatement stmt = connection.prepareStatement("DELETE FROM audit_log WHERE ticket_id = ?")) {
stmt.setInt(1, ticketId);
stmt.executeUpdate();
}
String sql = "DELETE FROM tickets WHERE id = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setInt(1, ticketId);
stmt.executeUpdate();
}
}
// cooldown operations
public boolean isOnCooldown(UUID playerUuid, int cooldownSeconds) throws SQLException {
String sql = "SELECT last_ticket FROM cooldowns WHERE uuid = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, playerUuid.toString());
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
long lastTicket = rs.getLong("last_ticket");
long now = System.currentTimeMillis();
return (now - lastTicket) < (cooldownSeconds * 1000L);
}
}
}
return false;
}
public void updateCooldown(UUID playerUuid) throws SQLException {
String sql = "INSERT OR REPLACE INTO cooldowns (uuid, last_ticket) VALUES (?, ?)";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, playerUuid.toString());
stmt.setLong(2, System.currentTimeMillis());
stmt.executeUpdate();
}
}
// audit log
public void logAction(int ticketId, UUID staffUuid, String action) throws SQLException {
String sql = "INSERT INTO audit_log (ticket_id, staff_uuid, action, timestamp) VALUES (?, ?, ?, ?)";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setInt(1, ticketId);
stmt.setString(2, staffUuid.toString());
stmt.setString(3, action);
stmt.setLong(4, System.currentTimeMillis());
stmt.executeUpdate();
}
}
public Ticket getTicketByThreadId(String threadId) throws SQLException {
String sql = "SELECT * FROM tickets WHERE discord_thread_id = ?";
try (PreparedStatement pstmt = connection.prepareStatement(sql)) {
pstmt.setString(1, threadId);
try (ResultSet rs = pstmt.executeQuery()) {
if (rs.next()) {
return mapResultSetToTicket(rs);
}
}
}
return null;
}
private Ticket mapResultSetToTicket(ResultSet rs) throws SQLException {
return new Ticket.Builder()
.id(rs.getInt("id"))
.playerUuid(UUID.fromString(rs.getString("uuid")))
.playerName(rs.getString("player_name"))
.category(TicketCategory.valueOf(rs.getString("category")))
.shortDesc(rs.getString("short_desc"))
.longDesc(rs.getString("long_desc"))
.world(rs.getString("world"))
.x(rs.getDouble("x"))
.y(rs.getDouble("y"))
.z(rs.getDouble("z"))
.yaw(rs.getFloat("yaw"))
.pitch(rs.getFloat("pitch"))
.status(TicketStatus.valueOf(rs.getString("status")))
.assignedTo(rs.getString("assigned_to") != null ? UUID.fromString(rs.getString("assigned_to")) : null)
.discordThreadId(rs.getString("discord_thread_id"))
.createdAt(rs.getLong("created_at"))
.updatedAt(rs.getLong("updated_at"))
.build();
}
public void close() {
if (connection != null) {
try {
connection.close();
plugin.getLogger().info("database connection closed");
} catch (SQLException e) {
plugin.getLogger().severe("error closing database: " + e.getMessage());
}
}
}
public Connection getConnection() {
return connection;
}
}

View File

@@ -0,0 +1,196 @@
package party.cybsec.oyetickets.discord;
import party.cybsec.oyetickets.OyeTicketsPlugin;
import party.cybsec.oyetickets.model.Ticket;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel;
import net.dv8tion.jda.api.requests.GatewayIntent;
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import java.awt.Color;
import java.sql.SQLException;
public class DiscordBot extends ListenerAdapter {
private final OyeTicketsPlugin plugin;
private JDA jda;
private boolean ready = false;
public DiscordBot(OyeTicketsPlugin plugin) {
this.plugin = plugin;
}
public boolean start() {
String token = plugin.getPluginConfig().getDiscordBotToken();
if (token == null || token.isEmpty() || token.equals("your-bot-token-here")) {
plugin.getLogger().warning("discord bot token not configured");
return false;
}
try {
jda = JDABuilder.createDefault(token)
.enableIntents(GatewayIntent.GUILD_MESSAGES, GatewayIntent.MESSAGE_CONTENT)
.addEventListeners(this)
.build();
// wait for ready
jda.awaitReady();
ready = true;
plugin.getLogger().info("discord bot connected");
return true;
} catch (Exception e) {
plugin.getLogger().severe("failed to start discord bot: " + e.getMessage());
e.printStackTrace();
return false;
}
}
public void shutdown() {
if (jda != null) {
jda.shutdown();
plugin.getLogger().info("discord bot disconnected");
}
}
public JDA getJda() {
return jda;
}
public boolean isReady() {
return ready && jda != null;
}
public void createTicketThread(Ticket ticket) {
if (!isReady())
return;
String channelId = plugin.getPluginConfig().getDiscordChannelId();
if (channelId == null || channelId.isEmpty()) {
plugin.getLogger().warning("discord channel id not configured");
return;
}
try {
net.dv8tion.jda.api.entities.channel.middleman.GuildChannel guildChannel = jda
.getGuildChannelById(channelId);
if (guildChannel == null) {
plugin.getLogger().warning("discord channel not found: " + channelId);
return;
}
// create thread
String threadTitle = ticket.getStatus().getEmoji() + " " + ticket.getTitle();
if (guildChannel instanceof net.dv8tion.jda.api.entities.channel.concrete.ForumChannel forumChannel) {
EmbedBuilder embed = buildTicketEmbed(ticket);
MessageCreateBuilder message = new MessageCreateBuilder()
.setEmbeds(embed.build())
.setContent("**new support ticket**\n\n" +
plugin.getPluginConfig().getGuidelines() +
"\n\n**reminder:** upload screenshots in this thread if applicable");
forumChannel.createForumPost(threadTitle, message.build())
.queue(post -> {
long threadId = post.getThreadChannel().getIdLong();
String threadIdStr = String.valueOf(threadId);
try {
plugin.getDatabase().updateDiscordThreadId(ticket.getId(), threadIdStr);
ticket.setDiscordThreadId(threadIdStr);
plugin.getLogger().info("created discord forum post for ticket #" + ticket.getId()
+ " (Thread ID: " + threadIdStr + ")");
} catch (SQLException e) {
plugin.getLogger().severe("failed to save discord thread id: " + e.getMessage());
}
});
} else if (guildChannel instanceof net.dv8tion.jda.api.entities.channel.attribute.IThreadContainer threadContainer) {
threadContainer.createThreadChannel(threadTitle).queue(thread -> {
EmbedBuilder embed = buildTicketEmbed(ticket);
MessageCreateBuilder message = new MessageCreateBuilder()
.setEmbeds(embed.build())
.setContent("**new support ticket**\n\n" +
plugin.getPluginConfig().getGuidelines() +
"\n\n**reminder:** upload screenshots in this thread if applicable");
thread.sendMessage(message.build()).queue(msg -> {
try {
plugin.getDatabase().updateDiscordThreadId(ticket.getId(), thread.getId());
ticket.setDiscordThreadId(thread.getId());
plugin.getLogger().info("created discord thread for ticket #" + ticket.getId() + " (ID: "
+ thread.getId() + ")");
} catch (SQLException e) {
plugin.getLogger().severe("failed to save discord thread id: " + e.getMessage());
}
});
});
}
} catch (Exception e) {
plugin.getLogger().severe("error creating discord thread: " + e.getMessage());
e.printStackTrace();
}
}
public void updateTicketStatus(Ticket ticket) {
if (!isReady())
return;
String threadId = ticket.getDiscordThreadId();
if (threadId == null || threadId.isEmpty())
return;
try {
ThreadChannel thread = jda.getThreadChannelById(threadId);
if (thread == null)
return;
String newTitle = ticket.getStatus().getEmoji() + " " + ticket.getTitle();
thread.getManager().setName(newTitle).queue();
EmbedBuilder embed = buildTicketEmbed(ticket);
thread.sendMessageEmbeds(embed.build()).queue();
if (ticket.getStatus().isClosed()) {
thread.getManager().setArchived(true).setLocked(true).queue();
}
} catch (Exception e) {
plugin.getLogger().severe("error updating discord thread: " + e.getMessage());
}
}
private EmbedBuilder buildTicketEmbed(Ticket ticket) {
EmbedBuilder embed = new EmbedBuilder();
embed.setTitle(ticket.getTitle());
embed.setColor(getColorForStatus(ticket.getStatus()));
embed.addField("player", ticket.getPlayerName(), true);
embed.addField("category", ticket.getCategory().getDisplayName(), true);
embed.addField("status", ticket.getStatus().getEmoji() + " " + ticket.getStatus().getDisplayName(), true);
embed.addField("location", "```\n" + ticket.getCoordinates() + "\n```", false);
embed.addField("description", ticket.getFullDescription(), false);
if (ticket.getAssignedTo() != null) {
String assignedName = plugin.getServer().getOfflinePlayer(ticket.getAssignedTo()).getName();
embed.addField("assigned to", assignedName, true);
}
embed.setFooter("ticket #" + ticket.getId());
embed.setTimestamp(java.time.Instant.ofEpochMilli(ticket.getUpdatedAt()));
return embed;
}
private Color getColorForStatus(party.cybsec.oyetickets.model.TicketStatus status) {
return switch (status) {
case NONE -> Color.GRAY;
case ACTIVE -> Color.YELLOW;
case WAITING -> Color.ORANGE;
case SOLVED -> Color.GREEN;
case CLOSED -> Color.RED;
};
}
}

View File

@@ -0,0 +1,276 @@
package party.cybsec.oyetickets.model;
import org.bukkit.Location;
import java.util.UUID;
public class Ticket {
private final int id;
private final UUID playerUuid;
private final String playerName;
private final TicketCategory category;
private final String shortDesc;
private final String longDesc;
private final String world;
private final double x;
private final double y;
private final double z;
private final float yaw;
private final float pitch;
private TicketStatus status;
private UUID assignedTo;
private String discordThreadId;
private final long createdAt;
private long updatedAt;
private Ticket(Builder builder) {
this.id = builder.id;
this.playerUuid = builder.playerUuid;
this.playerName = builder.playerName;
this.category = builder.category;
this.shortDesc = builder.shortDesc;
this.longDesc = builder.longDesc;
this.world = builder.world;
this.x = builder.x;
this.y = builder.y;
this.z = builder.z;
this.yaw = builder.yaw;
this.pitch = builder.pitch;
this.status = builder.status;
this.assignedTo = builder.assignedTo;
this.discordThreadId = builder.discordThreadId;
this.createdAt = builder.createdAt;
this.updatedAt = builder.updatedAt;
}
public String getTitle() {
return category.getTag() + " - " + shortDesc;
}
public String getCoordinates() {
return String.format("%s %.0f %.0f %.0f", world, x, y, z);
}
public String getFullDescription() {
if (longDesc == null || longDesc.isEmpty()) {
return shortDesc;
}
return shortDesc + "\n\n" + longDesc;
}
// getters
public int getId() {
return id;
}
public UUID getPlayerUuid() {
return playerUuid;
}
public String getPlayerName() {
return playerName;
}
public TicketCategory getCategory() {
return category;
}
public String getShortDesc() {
return shortDesc;
}
public String getLongDesc() {
return longDesc;
}
public String getWorld() {
return world;
}
public double getX() {
return x;
}
public double getY() {
return y;
}
public double getZ() {
return z;
}
public float getYaw() {
return yaw;
}
public float getPitch() {
return pitch;
}
public TicketStatus getStatus() {
return status;
}
public UUID getAssignedTo() {
return assignedTo;
}
public String getDiscordThreadId() {
return discordThreadId;
}
public long getCreatedAt() {
return createdAt;
}
public long getUpdatedAt() {
return updatedAt;
}
// setters for mutable fields
public void setStatus(TicketStatus status) {
this.status = status;
this.updatedAt = System.currentTimeMillis();
}
public void setAssignedTo(UUID assignedTo) {
this.assignedTo = assignedTo;
this.updatedAt = System.currentTimeMillis();
}
public void setDiscordThreadId(String discordThreadId) {
this.discordThreadId = discordThreadId;
}
public static class Builder {
private int id;
private UUID playerUuid;
private String playerName;
private TicketCategory category;
private String shortDesc;
private String longDesc;
private String world;
private double x;
private double y;
private double z;
private float yaw;
private float pitch;
private TicketStatus status = TicketStatus.NONE;
private UUID assignedTo;
private String discordThreadId;
private long createdAt = System.currentTimeMillis();
private long updatedAt = System.currentTimeMillis();
public Builder id(int id) {
this.id = id;
return this;
}
public Builder playerUuid(UUID uuid) {
this.playerUuid = uuid;
return this;
}
public Builder playerName(String name) {
this.playerName = name;
return this;
}
public Builder category(TicketCategory category) {
this.category = category;
return this;
}
public Builder shortDesc(String desc) {
this.shortDesc = desc;
return this;
}
public Builder longDesc(String desc) {
this.longDesc = desc;
return this;
}
public Builder location(Location loc) {
this.world = loc.getWorld().getName();
this.x = loc.getX();
this.y = loc.getY();
this.z = loc.getZ();
this.yaw = loc.getYaw();
this.pitch = loc.getPitch();
return this;
}
public Builder world(String world) {
this.world = world;
return this;
}
public Builder x(double x) {
this.x = x;
return this;
}
public Builder y(double y) {
this.y = y;
return this;
}
public Builder z(double z) {
this.z = z;
return this;
}
public Builder yaw(float yaw) {
this.yaw = yaw;
return this;
}
public Builder pitch(float pitch) {
this.pitch = pitch;
return this;
}
public Builder status(TicketStatus status) {
this.status = status;
return this;
}
public Builder assignedTo(UUID uuid) {
this.assignedTo = uuid;
return this;
}
public Builder discordThreadId(String id) {
this.discordThreadId = id;
return this;
}
public Builder createdAt(long timestamp) {
this.createdAt = timestamp;
return this;
}
public Builder updatedAt(long timestamp) {
this.updatedAt = timestamp;
return this;
}
public Builder player(UUID uuid, String name) {
this.playerUuid = uuid;
this.playerName = name;
return this;
}
public Builder description(String shortDesc, String longDesc) {
this.shortDesc = shortDesc;
this.longDesc = longDesc;
return this;
}
public Ticket build() {
return new Ticket(this);
}
}
}

View File

@@ -0,0 +1,30 @@
package party.cybsec.oyetickets.model;
import org.bukkit.Material;
public enum TicketCategory {
CRIME("crime", Material.IRON_SWORD),
LOST_ITEM("lost item", Material.CHEST),
SOCIAL_CASE("social case", Material.PLAYER_HEAD),
BUG("bug", Material.SPIDER_EYE);
private final String displayName;
private final Material icon;
TicketCategory(String displayName, Material icon) {
this.displayName = displayName;
this.icon = icon;
}
public String getDisplayName() {
return displayName;
}
public Material getIcon() {
return icon;
}
public String getTag() {
return name();
}
}

View File

@@ -0,0 +1,44 @@
package party.cybsec.oyetickets.model;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Material;
public enum TicketStatus {
NONE("none", Material.GRAY_WOOL, "", NamedTextColor.GRAY),
ACTIVE("active", Material.YELLOW_WOOL, "🟡", NamedTextColor.YELLOW),
WAITING("waiting", Material.ORANGE_WOOL, "🟠", NamedTextColor.GOLD),
SOLVED("solved", Material.GREEN_WOOL, "🟢", NamedTextColor.GREEN),
CLOSED("closed", Material.RED_WOOL, "🔴", NamedTextColor.RED);
private final String displayName;
private final Material icon;
private final String emoji;
private final NamedTextColor color;
TicketStatus(String displayName, Material icon, String emoji, NamedTextColor color) {
this.displayName = displayName;
this.icon = icon;
this.emoji = emoji;
this.color = color;
}
public String getDisplayName() {
return displayName;
}
public Material getIcon() {
return icon;
}
public String getEmoji() {
return emoji;
}
public NamedTextColor getColor() {
return color;
}
public boolean isClosed() {
return this == SOLVED || this == CLOSED;
}
}

View File

@@ -0,0 +1,39 @@
package party.cybsec.oyetickets.session;
import org.bukkit.entity.Player;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public class SessionManager {
private final Map<UUID, TicketSession> sessions;
private static final long SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
public SessionManager() {
this.sessions = new HashMap<>();
}
public TicketSession getOrCreateSession(Player player) {
UUID uuid = player.getUniqueId();
sessions.computeIfAbsent(uuid, k -> new TicketSession());
return sessions.get(uuid);
}
public TicketSession getSession(Player player) {
return sessions.get(player.getUniqueId());
}
public void removeSession(Player player) {
sessions.remove(player.getUniqueId());
}
public void endSession(UUID uuid) {
sessions.remove(uuid);
}
public void cleanup() {
sessions.entrySet().removeIf(entry -> entry.getValue().isExpired(SESSION_TIMEOUT_MS));
}
}

View File

@@ -0,0 +1,84 @@
package party.cybsec.oyetickets.session;
import party.cybsec.oyetickets.model.TicketCategory;
import org.bukkit.Location;
public class TicketSession {
private TicketCategory category;
private String shortDesc;
private String longDesc;
private Location location;
private boolean bookOpened;
private final long createdAt;
public TicketSession() {
this.createdAt = System.currentTimeMillis();
this.bookOpened = false;
}
public boolean isExpired(long timeoutMs) {
return (System.currentTimeMillis() - createdAt) > timeoutMs;
}
public boolean isComplete() {
return category != null && shortDesc != null && location != null;
}
// getters and setters
public TicketCategory getCategory() {
return category;
}
public void setCategory(TicketCategory category) {
this.category = category;
}
public String getShortDesc() {
return shortDesc;
}
public void setShortDesc(String shortDesc) {
this.shortDesc = shortDesc;
}
public String getLongDesc() {
return longDesc;
}
public void setLongDesc(String longDesc) {
this.longDesc = longDesc;
}
public Location getLocation() {
return location;
}
public void setLocation(Location location) {
this.location = location;
}
public boolean isBookOpened() {
return bookOpened;
}
public void setBookOpened(boolean bookOpened) {
this.bookOpened = bookOpened;
}
public String getWorldName() {
return location != null ? location.getWorld().getName() : "unknown";
}
public double getX() {
return location != null ? location.getX() : 0;
}
public double getY() {
return location != null ? location.getY() : 0;
}
public double getZ() {
return location != null ? location.getZ() : 0;
}
}

View File

@@ -0,0 +1,77 @@
package party.cybsec.oyetickets.ui.player;
import party.cybsec.oyetickets.OyeTicketsPlugin;
import party.cybsec.oyetickets.model.TicketCategory;
import party.cybsec.oyetickets.session.TicketSession;
import io.papermc.paper.dialog.Dialog;
import io.papermc.paper.registry.data.dialog.DialogBase;
import io.papermc.paper.registry.data.dialog.body.DialogBody;
import io.papermc.paper.registry.data.dialog.ActionButton;
import io.papermc.paper.registry.data.dialog.type.DialogType;
import io.papermc.paper.registry.data.dialog.action.DialogAction;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickCallback;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.entity.Player;
import java.util.ArrayList;
import java.util.List;
public class CategoryDialog {
private final OyeTicketsPlugin plugin;
private final Player player;
private final TicketSession session;
public CategoryDialog(OyeTicketsPlugin plugin, Player player, TicketSession session) {
this.plugin = plugin;
this.player = player;
this.session = session;
}
public void open() {
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
if (!player.isOnline())
return;
List<ActionButton> buttons = new ArrayList<>();
for (TicketCategory category : TicketCategory.values()) {
buttons.add(ActionButton.builder(Component.text(category.getDisplayName(), NamedTextColor.YELLOW))
.tooltip(Component.text("Select this category for your ticket."))
.width(150)
.action(DialogAction.customClick((view, audience) -> {
session.setCategory(category);
if (audience instanceof Player p) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
new TicketDetailsDialog(plugin, p, session).open();
});
}
}, ClickCallback.Options.builder().build()))
.build());
}
// Add back button
buttons.add(ActionButton.builder(Component.text("Back", NamedTextColor.RED))
.width(100)
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
new NavigationDialog(plugin, p, session).open();
});
}
}, ClickCallback.Options.builder().build()))
.build());
Dialog dialog = Dialog.create(builder -> builder.empty()
.base(DialogBase.builder(Component.text("Select Category", NamedTextColor.GOLD))
.body(List.of(
DialogBody.plainMessage(Component.text("What is this ticket regarding?"))))
.canCloseWithEscape(true)
.build())
.type(DialogType.multiAction(buttons).build()));
player.showDialog(dialog);
}, 1L);
}
}

View File

@@ -0,0 +1,96 @@
package party.cybsec.oyetickets.ui.player;
import party.cybsec.oyetickets.OyeTicketsPlugin;
import party.cybsec.oyetickets.session.TicketSession;
import io.papermc.paper.dialog.Dialog;
import io.papermc.paper.registry.data.dialog.DialogBase;
import io.papermc.paper.registry.data.dialog.body.DialogBody;
import io.papermc.paper.registry.data.dialog.ActionButton;
import io.papermc.paper.registry.data.dialog.type.DialogType;
import io.papermc.paper.registry.data.dialog.action.DialogAction;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickCallback;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.BookMeta;
import java.util.List;
public class NavigationDialog {
private final OyeTicketsPlugin plugin;
private final Player player;
private final TicketSession session;
public NavigationDialog(OyeTicketsPlugin plugin, Player player, TicketSession session) {
this.plugin = plugin;
this.player = player;
this.session = session;
}
public void open() {
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
if (!player.isOnline())
return;
Dialog dialog = Dialog.create(builder -> builder.empty()
.base(DialogBase.builder(Component.text("Ticket Guidelines", NamedTextColor.GOLD))
.body(List.of(
DialogBody.plainMessage(Component.text("rules checklist:", NamedTextColor.YELLOW)),
DialogBody.plainMessage(Component.text("- location captured automatically")),
DialogBody.plainMessage(Component.text("- screenshots must go to discord")),
DialogBody.plainMessage(Component.text("- no spam / respectful behavior"))))
.canCloseWithEscape(true)
.build())
.type(DialogType.multiAction(List.of(
ActionButton.builder(Component.text("Accept & Continue", TextColor.color(0xAEFFC1)))
.tooltip(Component.text("I agree to these guidelines."))
.width(150)
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
new CategoryDialog(plugin, p, session).open();
});
}
}, ClickCallback.Options.builder().build()))
.build(),
ActionButton.builder(Component.text("Read Rules", NamedTextColor.GOLD))
.tooltip(Component.text("Open the full server rulebook."))
.width(100)
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
openGuidelinesBook(p);
});
}
}, ClickCallback.Options.builder().build()))
.build(),
ActionButton.builder(Component.text("Exit", NamedTextColor.RED))
.width(80)
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
p.sendMessage(Component.text("Ticket creation abandoned.")
.color(NamedTextColor.RED));
plugin.getSessionManager().endSession(p.getUniqueId());
}
}, ClickCallback.Options.builder().build()))
.build()))
.build()));
player.showDialog(dialog);
}, 1L);
}
private void openGuidelinesBook(Player p) {
ItemStack book = new ItemStack(Material.WRITTEN_BOOK);
BookMeta meta = (BookMeta) book.getItemMeta();
meta.setTitle("server guidelines");
meta.setAuthor("server staff");
meta.addPages(Component.text(plugin.getPluginConfig().getGuidelines()));
book.setItemMeta(meta);
p.openBook(book);
}
}

View File

@@ -0,0 +1,111 @@
package party.cybsec.oyetickets.ui.player;
import party.cybsec.oyetickets.OyeTicketsPlugin;
import party.cybsec.oyetickets.model.Ticket;
import party.cybsec.oyetickets.session.TicketSession;
import io.papermc.paper.dialog.Dialog;
import io.papermc.paper.registry.data.dialog.DialogBase;
import io.papermc.paper.registry.data.dialog.body.DialogBody;
import io.papermc.paper.registry.data.dialog.ActionButton;
import io.papermc.paper.registry.data.dialog.type.DialogType;
import io.papermc.paper.registry.data.dialog.action.DialogAction;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickCallback;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.entity.Player;
import java.sql.SQLException;
import java.util.List;
public class ReviewDialog {
private final OyeTicketsPlugin plugin;
private final Player player;
private final TicketSession session;
public ReviewDialog(OyeTicketsPlugin plugin, Player player, TicketSession session) {
this.plugin = plugin;
this.player = player;
this.session = session;
}
public void open() {
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
if (!player.isOnline())
return;
Dialog dialog = Dialog.create(builder -> builder.empty()
.base(DialogBase.builder(Component.text("Final Review", NamedTextColor.GOLD))
.body(List.of(
DialogBody.plainMessage(Component.text("Summary:", NamedTextColor.YELLOW)),
DialogBody.plainMessage(
Component.text("Category: " + session.getCategory().getDisplayName())),
DialogBody.plainMessage(Component.text("Title: " + session.getShortDesc())),
DialogBody.plainMessage(Component
.text("Location: " + session.getWorldName() + " (" + (int) session.getX()
+ ", " + (int) session.getY() + ", " + (int) session.getZ() + ")")),
DialogBody.plainMessage(Component.text("Description: "
+ (session.getLongDesc() != null && !session.getLongDesc().isEmpty()
? "Provided"
: "None"))),
DialogBody.plainMessage(Component.empty()),
DialogBody.plainMessage(Component.text(
"Click submit to create your ticket. A Discord thread will be created for screenshots.",
NamedTextColor.GRAY))))
.canCloseWithEscape(true)
.build())
.type(DialogType.confirmation(
ActionButton.builder(Component.text("Submit", TextColor.color(0xAEFFC1)))
.tooltip(Component.text("Create your ticket now."))
.width(100)
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
submitAndFinish(p);
});
}
}, ClickCallback.Options.builder().build()))
.build(),
ActionButton.builder(Component.text("Back", NamedTextColor.RED))
.tooltip(Component.text("Go back to edit details."))
.width(100)
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
new TicketDetailsDialog(plugin, p, session).open();
});
}
}, ClickCallback.Options.builder().build()))
.build())));
player.showDialog(dialog);
}, 1L);
}
private void submitAndFinish(Player p) {
Ticket ticket = new Ticket.Builder()
.player(p.getUniqueId(), p.getName())
.category(session.getCategory())
.description(session.getShortDesc(), session.getLongDesc())
.location(p.getLocation())
.build();
try {
plugin.getDatabase().saveTicket(ticket);
p.sendMessage(Component.text("Ticket #" + ticket.getId() + " submitted successfully!")
.color(NamedTextColor.GREEN));
// trigger discord integration
plugin.getDiscordBot().createTicketThread(ticket);
// cleanup session
plugin.getSessionManager().endSession(p.getUniqueId());
} catch (SQLException e) {
plugin.getLogger().severe("failed to save ticket: " + e.getMessage());
p.sendMessage(Component.text("failed to submit ticket. please contact staff.")
.color(NamedTextColor.RED));
}
}
}

View File

@@ -0,0 +1,100 @@
package party.cybsec.oyetickets.ui.player;
import party.cybsec.oyetickets.OyeTicketsPlugin;
import party.cybsec.oyetickets.session.TicketSession;
import io.papermc.paper.dialog.Dialog;
import io.papermc.paper.registry.data.dialog.DialogBase;
import io.papermc.paper.registry.data.dialog.body.DialogBody;
import io.papermc.paper.registry.data.dialog.ActionButton;
import io.papermc.paper.registry.data.dialog.input.DialogInput;
import io.papermc.paper.registry.data.dialog.input.TextDialogInput;
import io.papermc.paper.registry.data.dialog.type.DialogType;
import io.papermc.paper.registry.data.dialog.action.DialogAction;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickCallback;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.entity.Player;
import java.util.List;
public class TicketDetailsDialog {
private final OyeTicketsPlugin plugin;
private final Player player;
private final TicketSession session;
public TicketDetailsDialog(OyeTicketsPlugin plugin, Player player, TicketSession session) {
this.plugin = plugin;
this.player = player;
this.session = session;
}
public void open() {
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
if (!player.isOnline())
return;
Dialog dialog = Dialog.create(builder -> builder.empty()
.base(DialogBase.builder(Component.text("Ticket Details", NamedTextColor.GOLD))
.body(List.of(
DialogBody.plainMessage(
Component.text("Please provide a title and detailed description."))))
.inputs(List.of(
DialogInput.text("title", Component.text("Brief Title", NamedTextColor.YELLOW))
.initial(session.getShortDesc() != null ? session.getShortDesc() : "")
.width(300)
.maxLength(100)
.build(),
DialogInput
.text("description",
Component.text("Full Description", NamedTextColor.YELLOW))
.initial(session.getLongDesc() != null ? session.getLongDesc() : "")
.width(300)
.maxLength(4000)
.multiline(TextDialogInput.MultilineOptions.create(10, 200))
.build()))
.canCloseWithEscape(true)
.build())
.type(DialogType.confirmation(
ActionButton.builder(Component.text("Confirm", TextColor.color(0xAEFFC1)))
.tooltip(Component.text("Click to save and proceed to review."))
.width(100)
.action(DialogAction.customClick((view, audience) -> {
String title = view.getText("title");
String description = view.getText("description");
if (title == null || title.trim().length() < 5) {
if (audience instanceof Player p) {
p.sendMessage(Component.text("Title is too short! (min 5 characters)")
.color(NamedTextColor.RED));
this.open();
}
return;
}
session.setShortDesc(title.trim());
session.setLongDesc(description != null ? description.trim() : "");
if (audience instanceof Player p) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
new ReviewDialog(plugin, p, session).open();
});
}
}, ClickCallback.Options.builder().build()))
.build(),
ActionButton.builder(Component.text("Cancel", TextColor.color(0xFFA0B1)))
.tooltip(Component.text("Discard changes and exit."))
.width(100)
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
p.sendMessage(Component.text("Ticket creation abandoned.")
.color(NamedTextColor.RED));
}
}, ClickCallback.Options.builder().build()))
.build())));
player.showDialog(dialog);
}, 1L);
}
}

View File

@@ -0,0 +1,79 @@
package party.cybsec.oyetickets.ui.staff;
import party.cybsec.oyetickets.OyeTicketsPlugin;
import party.cybsec.oyetickets.model.Ticket;
import party.cybsec.oyetickets.model.TicketStatus;
import io.papermc.paper.dialog.Dialog;
import io.papermc.paper.registry.data.dialog.DialogBase;
import io.papermc.paper.registry.data.dialog.body.DialogBody;
import io.papermc.paper.registry.data.dialog.ActionButton;
import io.papermc.paper.registry.data.dialog.type.DialogType;
import io.papermc.paper.registry.data.dialog.action.DialogAction;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickCallback;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.entity.Player;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
public class StatusDialog {
private final OyeTicketsPlugin plugin;
private final Player staff;
private final Ticket ticket;
public StatusDialog(OyeTicketsPlugin plugin, Player staff, Ticket ticket) {
this.plugin = plugin;
this.staff = staff;
this.ticket = ticket;
}
public void open() {
List<ActionButton> buttons = new ArrayList<>();
for (TicketStatus status : TicketStatus.values()) {
buttons.add(ActionButton.builder(Component.text(status.getDisplayName()))
.tooltip(Component.text("Set status to " + status.name().toLowerCase()))
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
updateStatus(p, status);
}
}, ClickCallback.Options.builder().build()))
.build());
}
buttons.add(ActionButton.builder(Component.text("Back", NamedTextColor.GRAY))
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
new TicketActionDialog(plugin, p, ticket).open();
}
}, ClickCallback.Options.builder().build()))
.build());
Dialog dialog = Dialog.create(builder -> builder.empty()
.base(DialogBase.builder(Component.text("Set Ticket Status", NamedTextColor.GOLD))
.body(List.of(DialogBody
.plainMessage(Component.text("Select a new status for ticket #" + ticket.getId()))))
.canCloseWithEscape(true)
.build())
.type(DialogType.multiAction(buttons).build()));
staff.showDialog(dialog);
}
private void updateStatus(Player p, TicketStatus status) {
try {
plugin.getDatabase().updateTicketStatus(ticket.getId(), status);
ticket.setStatus(status);
p.sendMessage(Component.text("Status updated to " + status.getDisplayName(), NamedTextColor.GREEN));
if (plugin.getDiscordBot() != null) {
plugin.getDiscordBot().updateTicketStatus(ticket);
}
new TicketActionDialog(plugin, p, ticket).open();
} catch (SQLException e) {
p.sendMessage(Component.text("Failed to update status.", NamedTextColor.RED));
}
}
}

View File

@@ -0,0 +1,156 @@
package party.cybsec.oyetickets.ui.staff;
import party.cybsec.oyetickets.OyeTicketsPlugin;
import party.cybsec.oyetickets.model.Ticket;
import party.cybsec.oyetickets.model.TicketStatus;
import io.papermc.paper.dialog.Dialog;
import io.papermc.paper.registry.data.dialog.DialogBase;
import io.papermc.paper.registry.data.dialog.body.DialogBody;
import io.papermc.paper.registry.data.dialog.ActionButton;
import io.papermc.paper.registry.data.dialog.type.DialogType;
import io.papermc.paper.registry.data.dialog.action.DialogAction;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickCallback;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import java.sql.SQLException;
import java.util.List;
public class TicketActionDialog {
private final OyeTicketsPlugin plugin;
private final Player staff;
private final Ticket ticket;
public TicketActionDialog(OyeTicketsPlugin plugin, Player staff, Ticket ticket) {
this.plugin = plugin;
this.staff = staff;
this.ticket = ticket;
}
public void open() {
Dialog dialog = Dialog.create(builder -> builder.empty()
.base(DialogBase.builder(Component.text("Ticket #" + ticket.getId(), NamedTextColor.GOLD))
.body(List.of(
DialogBody.plainMessage(Component.text("Creator: ", NamedTextColor.GRAY)
.append(Component.text(ticket.getPlayerName(), NamedTextColor.WHITE))),
DialogBody.plainMessage(Component.text("Category: ", NamedTextColor.GRAY)
.append(Component.text(ticket.getCategory().getDisplayName(),
NamedTextColor.YELLOW))),
DialogBody.plainMessage(Component.text("Description: ", NamedTextColor.GRAY)
.append(Component.text(ticket.getShortDesc(), NamedTextColor.WHITE))),
DialogBody.plainMessage(Component.text("Status: ", NamedTextColor.GRAY)
.append(Component.text(ticket.getStatus().getDisplayName(),
ticket.getStatus().getColor())))))
.canCloseWithEscape(true)
.build())
.type(DialogType.multiAction(List.of(
ActionButton.builder(Component.text("Read Full Info"))
.tooltip(Component.text("Open the ticket as a book."))
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
openDescriptionBook(p);
}
}, ClickCallback.Options.builder().build()))
.build(),
ActionButton.builder(Component.text("Teleport"))
.tooltip(Component.text("Teleport to the ticket location."))
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
Location loc = new Location(Bukkit.getWorld(ticket.getWorld()), ticket.getX(),
ticket.getY(), ticket.getZ(), ticket.getYaw(), ticket.getPitch());
p.teleport(loc);
p.sendMessage(Component.text("Teleported!", NamedTextColor.GREEN));
}
}, ClickCallback.Options.builder().build()))
.build(),
ActionButton.builder(Component.text("Update Status"))
.tooltip(Component.text("Change the ticket status."))
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
new StatusDialog(plugin, p, ticket).open();
}
}, ClickCallback.Options.builder().build()))
.build(),
ActionButton.builder(Component.text("Claim Ticket"))
.tooltip(Component.text("Assign this ticket to yourself."))
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
try {
plugin.getDatabase().updateTicketAssignment(ticket.getId(),
p.getUniqueId());
ticket.setAssignedTo(p.getUniqueId());
p.sendMessage(
Component.text("Ticket assigned to you.", NamedTextColor.GREEN));
open(); // Refresh
} catch (SQLException e) {
p.sendMessage(
Component.text("Failed to assign ticket.", NamedTextColor.RED));
}
}
}, ClickCallback.Options.builder().build()))
.build(),
ActionButton.builder(Component.text("Delete Ticket", NamedTextColor.RED))
.tooltip(Component.text("Permanently delete this ticket."))
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
showDeleteConfirmation(p);
}
}, ClickCallback.Options.builder().build()))
.build(),
ActionButton.builder(Component.text("Back", NamedTextColor.GRAY))
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
new TicketBrowserDialog(plugin, p).open();
}
}, ClickCallback.Options.builder().build()))
.build()))
.build()));
staff.showDialog(dialog);
}
private void showDeleteConfirmation(Player p) {
Dialog dialog = Dialog.create(builder -> builder.empty()
.base(DialogBase.builder(Component.text("Confirm Deletion", NamedTextColor.RED))
.body(List.of(DialogBody.plainMessage(
Component.text("Are you sure you want to delete ticket #" + ticket.getId() + "?"))))
.build())
.type(DialogType.confirmation(
ActionButton.builder(Component.text("Delete", NamedTextColor.RED))
.action(DialogAction.customClick((view, audience) -> {
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
try {
plugin.getDatabase().deleteTicket(ticket.getId());
p.sendMessage(Component.text("Ticket deleted.", NamedTextColor.GREEN));
plugin.getServer().getScheduler().runTask(plugin, () -> {
new TicketBrowserDialog(plugin, p).open();
});
} catch (SQLException e) {
p.sendMessage(
Component.text("Failed to delete ticket.", NamedTextColor.RED));
}
});
}, ClickCallback.Options.builder().build()))
.build(),
ActionButton.builder(Component.text("Cancel", NamedTextColor.GRAY))
.action(DialogAction.customClick((view, audience) -> {
open();
}, ClickCallback.Options.builder().build()))
.build())));
p.showDialog(dialog);
}
private void openDescriptionBook(Player p) {
org.bukkit.inventory.ItemStack book = new org.bukkit.inventory.ItemStack(org.bukkit.Material.WRITTEN_BOOK);
org.bukkit.inventory.meta.BookMeta meta = (org.bukkit.inventory.meta.BookMeta) book.getItemMeta();
meta.setTitle(ticket.getTitle());
meta.setAuthor(ticket.getPlayerName());
meta.addPages(Component.text(ticket.getFullDescription()));
book.setItemMeta(meta);
p.openBook(book);
}
}

View File

@@ -0,0 +1,136 @@
package party.cybsec.oyetickets.ui.staff;
import party.cybsec.oyetickets.OyeTicketsPlugin;
import party.cybsec.oyetickets.model.Ticket;
import party.cybsec.oyetickets.model.TicketStatus;
import io.papermc.paper.dialog.Dialog;
import io.papermc.paper.registry.data.dialog.DialogBase;
import io.papermc.paper.registry.data.dialog.body.DialogBody;
import io.papermc.paper.registry.data.dialog.ActionButton;
import io.papermc.paper.registry.data.dialog.type.DialogType;
import io.papermc.paper.registry.data.dialog.action.DialogAction;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickCallback;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.SkullMeta;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
public class TicketBrowserDialog {
private final OyeTicketsPlugin plugin;
private final Player staff;
public TicketBrowserDialog(OyeTicketsPlugin plugin, Player staff) {
this.plugin = plugin;
this.staff = staff;
}
public void open() {
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
try {
List<Ticket> tickets = plugin.getDatabase().getTickets();
// Filter for open tickets
List<Ticket> openTickets = tickets.stream()
.filter(t -> t.getStatus() != TicketStatus.CLOSED)
.toList();
plugin.getServer().getScheduler().runTask(plugin, () -> {
if (!staff.isOnline())
return;
if (openTickets.isEmpty()) {
staff.sendMessage(Component.text("No open tickets found.", NamedTextColor.YELLOW));
return;
}
showListDialog(openTickets);
});
} catch (SQLException e) {
plugin.getLogger().severe("Failed to fetch tickets: " + e.getMessage());
staff.sendMessage(Component.text("Error accessing database.", NamedTextColor.RED));
}
});
}
private void showListDialog(List<Ticket> tickets) {
List<ActionButton> buttons = new ArrayList<>();
for (Ticket ticket : tickets) {
buttons.add(ActionButton.builder(Component.text(ticket.getTitle()))
.tooltip(Component
.text("Creator: " + ticket.getPlayerName() + "\nDescription: " + ticket.getShortDesc()))
.action(DialogAction.customClick((view, audience) -> {
if (audience instanceof Player p) {
new TicketActionDialog(plugin, p, ticket).open();
}
}, ClickCallback.Options.builder().build()))
.build());
}
// Use MultiActionType with 1 column for a vertical list
Dialog dialog = Dialog.create(builder -> builder.empty()
.base(DialogBase.builder(Component.text("Staff Ticket Browser", NamedTextColor.GOLD))
.canCloseWithEscape(true)
.build())
.type(DialogType.multiAction(buttons)
.columns(1)
.exitAction(ActionButton.builder(Component.text("Exit Browser", NamedTextColor.GRAY))
.action(DialogAction.customClick((view, audience) -> {
}, ClickCallback.Options.builder().build()))
.build())
.build()));
staff.showDialog(dialog);
}
private String truncate(String text, int maxLength) {
if (text == null)
return "";
if (text.length() <= maxLength)
return text;
return text.substring(0, maxLength - 3) + "...";
}
private ItemStack getPlayerHead(Ticket ticket) {
ItemStack head = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) head.getItemMeta();
meta.setOwningPlayer(plugin.getServer().getOfflinePlayer(ticket.getPlayerUuid()));
head.setItemMeta(meta);
return head;
}
private void showDeleteConfirmation(Player p, Ticket ticket) {
Dialog dialog = Dialog.create(builder -> builder.empty()
.base(DialogBase.builder(Component.text("Confirm Deletion", NamedTextColor.RED))
.body(List.of(DialogBody.plainMessage(
Component.text("Are you sure you want to delete ticket #" + ticket.getId() + "?"))))
.build())
.type(DialogType.confirmation(
ActionButton.builder(Component.text("Delete", NamedTextColor.RED))
.action(DialogAction.customClick((view, audience) -> {
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
try {
plugin.getDatabase().deleteTicket(ticket.getId());
p.sendMessage(Component.text("Ticket deleted.", NamedTextColor.GREEN));
plugin.getServer().getScheduler().runTask(plugin, this::open);
} catch (SQLException e) {
p.sendMessage(
Component.text("Failed to delete ticket.", NamedTextColor.RED));
}
});
}, ClickCallback.Options.builder().build()))
.build(),
ActionButton.builder(Component.text("Cancel", NamedTextColor.GRAY))
.action(DialogAction.customClick((view, audience) -> {
plugin.getServer().getScheduler().runTask(plugin, this::open);
}, ClickCallback.Options.builder().build()))
.build())));
p.showDialog(dialog);
}
}

View File

@@ -0,0 +1,79 @@
package party.cybsec.oyetickets.util;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Material;
import org.bukkit.inventory.ItemFlag;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.util.ArrayList;
import java.util.List;
public class ItemBuilder {
private final ItemStack item;
private final ItemMeta meta;
public ItemBuilder(Material material) {
this.item = new ItemStack(material);
this.meta = item.getItemMeta();
}
public ItemBuilder(Material material, int amount) {
this.item = new ItemStack(material, amount);
this.meta = item.getItemMeta();
}
public ItemBuilder name(String name) {
meta.displayName(Component.text(name).decoration(TextDecoration.ITALIC, false));
return this;
}
public ItemBuilder name(Component component) {
meta.displayName(component.decoration(TextDecoration.ITALIC, false));
return this;
}
public ItemBuilder lore(String... lines) {
List<Component> lore = new ArrayList<>();
for (String line : lines) {
lore.add(Component.text(line).decoration(TextDecoration.ITALIC, false));
}
meta.lore(lore);
return this;
}
public ItemBuilder lore(Component... lines) {
List<Component> lore = new ArrayList<>();
for (Component line : lines) {
lore.add(line.decoration(TextDecoration.ITALIC, false));
}
meta.lore(lore);
return this;
}
public ItemBuilder lore(List<Component> lore) {
List<Component> noItalic = new ArrayList<>();
for (Component line : lore) {
noItalic.add(line.decoration(TextDecoration.ITALIC, false));
}
meta.lore(noItalic);
return this;
}
public ItemBuilder flags(ItemFlag... flags) {
meta.addItemFlags(flags);
return this;
}
public ItemBuilder hideAll() {
meta.addItemFlags(ItemFlag.values());
return this;
}
public ItemStack build() {
item.setItemMeta(meta);
return item;
}
}

View File

@@ -0,0 +1,24 @@
package party.cybsec.oyetickets.util;
import org.bukkit.Location;
public class LocationUtil {
public static String format(Location loc) {
return String.format("%s %.0f %.0f %.0f",
loc.getWorld().getName(),
loc.getX(),
loc.getY(),
loc.getZ());
}
public static String formatDetailed(Location loc) {
return String.format("%s x:%.2f y:%.2f z:%.2f yaw:%.1f pitch:%.1f",
loc.getWorld().getName(),
loc.getX(),
loc.getY(),
loc.getZ(),
loc.getYaw(),
loc.getPitch());
}
}

View File

@@ -0,0 +1,31 @@
package party.cybsec.oyetickets.util;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.entity.Player;
public class PermissionUtil {
public static boolean has(Player player, String permission) {
return player.hasPermission(permission);
}
public static boolean checkAndNotify(Player player, String permission) {
if (player.hasPermission(permission)) {
return true;
}
player.sendMessage(Component.text("you don't have permission to do that")
.color(NamedTextColor.RED));
return false;
}
public static boolean checkAndNotify(Player player, String permission, String message) {
if (player.hasPermission(permission)) {
return true;
}
player.sendMessage(Component.text(message).color(NamedTextColor.RED));
return false;
}
}

View File

@@ -0,0 +1,27 @@
# discord integration
discord:
enabled: true
bot-token: "your-bot-token-here"
channel-id: "1234567890"
# abuse controls
cooldown:
enabled: true
duration-seconds: 300
duplicate-detection:
enabled: true
similarity-threshold: 0.8
# guidelines (shown in orientation book)
guidelines: |
welcome to the staff support ticket system.
this system helps staff track and resolve player issues.
rules:
- be specific in your description
- upload screenshots to discord
- do not spam tickets
your location will be automatically captured.

View File

@@ -0,0 +1,38 @@
name: OyeTickets
version: ${version}
main: party.cybsec.oyetickets.OyeTicketsPlugin
api-version: "1.21"
author: jawn
description: staff support ticket system with gui-driven workflow
commands:
ticket:
description: create a new support ticket
permission: tickets.create
usage: /ticket
tickets:
description: view and manage support tickets
permission: tickets.view
usage: /tickets
oyetickets:
description: admin commands for oyetickets
permission: tickets.admin
usage: /oyetickets <reload|status>
permissions:
tickets.create:
description: allows creating support tickets
default: true
tickets.view:
description: allows viewing support tickets
default: op
tickets.manage:
description: allows managing support tickets
default: op
tickets.teleport:
description: allows teleporting to ticket locations
default: op
tickets.admin:
description: allows reloading and managing the plugin
default: op