diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..591333b --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# gradle +.gradle/ +build/ +out/ + +# intellij +.idea/ +*.iml +*.ipr +*.iws + +# eclipse +.classpath +.project +.settings/ + +# vscode +.vscode/ + +# macos +.DS_Store + +# logs +*.log diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..39e776f --- /dev/null +++ b/build.gradle.kts @@ -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) + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..4ea4ae3 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2G +org.gradle.parallel=true +org.gradle.caching=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..61285a6 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..b0c04de --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "oyetickets" diff --git a/src/main/java/party/cybsec/oyetickets/OyeTicketsPlugin.java b/src/main/java/party/cybsec/oyetickets/OyeTicketsPlugin.java new file mode 100644 index 0000000..5ca1a7d --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/OyeTicketsPlugin.java @@ -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; + } +} diff --git a/src/main/java/party/cybsec/oyetickets/command/AdminCommand.java b/src/main/java/party/cybsec/oyetickets/command/AdminCommand.java new file mode 100644 index 0000000..89a3c1f --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/command/AdminCommand.java @@ -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 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<>(); + } +} diff --git a/src/main/java/party/cybsec/oyetickets/command/TicketCommand.java b/src/main/java/party/cybsec/oyetickets/command/TicketCommand.java new file mode 100644 index 0000000..7b515b1 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/command/TicketCommand.java @@ -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; + } +} diff --git a/src/main/java/party/cybsec/oyetickets/command/TicketsCommand.java b/src/main/java/party/cybsec/oyetickets/command/TicketsCommand.java new file mode 100644 index 0000000..0cc4516 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/command/TicketsCommand.java @@ -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; + } +} diff --git a/src/main/java/party/cybsec/oyetickets/config/Config.java b/src/main/java/party/cybsec/oyetickets/config/Config.java new file mode 100644 index 0000000..a9fe7ac --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/config/Config.java @@ -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"); + } +} diff --git a/src/main/java/party/cybsec/oyetickets/database/Database.java b/src/main/java/party/cybsec/oyetickets/database/Database.java new file mode 100644 index 0000000..cddf660 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/database/Database.java @@ -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 getTickets() throws SQLException { + List 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; + } +} diff --git a/src/main/java/party/cybsec/oyetickets/discord/DiscordBot.java b/src/main/java/party/cybsec/oyetickets/discord/DiscordBot.java new file mode 100644 index 0000000..62de43c --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/discord/DiscordBot.java @@ -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; + }; + } +} diff --git a/src/main/java/party/cybsec/oyetickets/model/Ticket.java b/src/main/java/party/cybsec/oyetickets/model/Ticket.java new file mode 100644 index 0000000..f07a7d0 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/model/Ticket.java @@ -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); + } + } +} diff --git a/src/main/java/party/cybsec/oyetickets/model/TicketCategory.java b/src/main/java/party/cybsec/oyetickets/model/TicketCategory.java new file mode 100644 index 0000000..b2e2ec3 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/model/TicketCategory.java @@ -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(); + } +} diff --git a/src/main/java/party/cybsec/oyetickets/model/TicketStatus.java b/src/main/java/party/cybsec/oyetickets/model/TicketStatus.java new file mode 100644 index 0000000..5620b88 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/model/TicketStatus.java @@ -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; + } +} diff --git a/src/main/java/party/cybsec/oyetickets/session/SessionManager.java b/src/main/java/party/cybsec/oyetickets/session/SessionManager.java new file mode 100644 index 0000000..489a007 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/session/SessionManager.java @@ -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 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)); + } +} diff --git a/src/main/java/party/cybsec/oyetickets/session/TicketSession.java b/src/main/java/party/cybsec/oyetickets/session/TicketSession.java new file mode 100644 index 0000000..2d1d694 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/session/TicketSession.java @@ -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; + } +} diff --git a/src/main/java/party/cybsec/oyetickets/ui/player/CategoryDialog.java b/src/main/java/party/cybsec/oyetickets/ui/player/CategoryDialog.java new file mode 100644 index 0000000..4832d5b --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/ui/player/CategoryDialog.java @@ -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 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); + } +} diff --git a/src/main/java/party/cybsec/oyetickets/ui/player/NavigationDialog.java b/src/main/java/party/cybsec/oyetickets/ui/player/NavigationDialog.java new file mode 100644 index 0000000..3954220 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/ui/player/NavigationDialog.java @@ -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); + } +} diff --git a/src/main/java/party/cybsec/oyetickets/ui/player/ReviewDialog.java b/src/main/java/party/cybsec/oyetickets/ui/player/ReviewDialog.java new file mode 100644 index 0000000..ce9dff6 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/ui/player/ReviewDialog.java @@ -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)); + } + } +} diff --git a/src/main/java/party/cybsec/oyetickets/ui/player/TicketDetailsDialog.java b/src/main/java/party/cybsec/oyetickets/ui/player/TicketDetailsDialog.java new file mode 100644 index 0000000..3d217c6 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/ui/player/TicketDetailsDialog.java @@ -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); + } +} diff --git a/src/main/java/party/cybsec/oyetickets/ui/staff/StatusDialog.java b/src/main/java/party/cybsec/oyetickets/ui/staff/StatusDialog.java new file mode 100644 index 0000000..0716df1 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/ui/staff/StatusDialog.java @@ -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 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)); + } + } +} diff --git a/src/main/java/party/cybsec/oyetickets/ui/staff/TicketActionDialog.java b/src/main/java/party/cybsec/oyetickets/ui/staff/TicketActionDialog.java new file mode 100644 index 0000000..168c4a8 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/ui/staff/TicketActionDialog.java @@ -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); + } +} diff --git a/src/main/java/party/cybsec/oyetickets/ui/staff/TicketBrowserDialog.java b/src/main/java/party/cybsec/oyetickets/ui/staff/TicketBrowserDialog.java new file mode 100644 index 0000000..ccb6b53 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/ui/staff/TicketBrowserDialog.java @@ -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 tickets = plugin.getDatabase().getTickets(); + // Filter for open tickets + List 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 tickets) { + List 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); + } +} diff --git a/src/main/java/party/cybsec/oyetickets/util/ItemBuilder.java b/src/main/java/party/cybsec/oyetickets/util/ItemBuilder.java new file mode 100644 index 0000000..92af62b --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/util/ItemBuilder.java @@ -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 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 lore = new ArrayList<>(); + for (Component line : lines) { + lore.add(line.decoration(TextDecoration.ITALIC, false)); + } + meta.lore(lore); + return this; + } + + public ItemBuilder lore(List lore) { + List 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; + } +} diff --git a/src/main/java/party/cybsec/oyetickets/util/LocationUtil.java b/src/main/java/party/cybsec/oyetickets/util/LocationUtil.java new file mode 100644 index 0000000..fc20968 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/util/LocationUtil.java @@ -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()); + } +} diff --git a/src/main/java/party/cybsec/oyetickets/util/PermissionUtil.java b/src/main/java/party/cybsec/oyetickets/util/PermissionUtil.java new file mode 100644 index 0000000..ff43648 --- /dev/null +++ b/src/main/java/party/cybsec/oyetickets/util/PermissionUtil.java @@ -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; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..7e9f3a8 --- /dev/null +++ b/src/main/resources/config.yml @@ -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. diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..b1c1927 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -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 + +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