From 73e67eed6c90d6a964d8cc0bd81948d5324e0643 Mon Sep 17 00:00:00 2001 From: Stijn <38841986+Teriuihi@users.noreply.github.com> Date: Sat, 27 Jul 2024 23:16:18 +0200 Subject: [PATCH] Vote mute (#3) * Implement chat log handler with database support The code changes introduce the ability to log chat messages. A new ChatLogHandler class has been added that manages the queue of chat log messages, both storing them in memory and writing them to a database. New columns have been added to the database and the interactivity with the database is handled using prepared statements to improve security and performance. The chat messages are deleted from the database after a certain period, which can be configured. * Add start of VoteToMute functionality in chat system Implemented VoteToMute system enabling initiated voting for muting a player in chat. This includes creating new classes "ActiveVoteToMute", "VoteToMute", and "VoteToMuteStarter". The "VoteToMute" class handles the voting command logic, it allows players to vote on whether to mute other players. The code also adds a call to register this new command in the main VelocityChat class. * Replace Object2ObjectOpenHashMap with HashMap The usage of Object2ObjectOpenHashMap in storing chat logs and the chat log handler was switched to HashMap. This was done to ensure the plugin can be run on proxy as velocity does not include this library * Add VoteToMuteHelper and enhance ActiveVoteToMute Implemented a new module titled VoteToMuteHelper to enhance the voting system and augment the user experience. This module enhances the system by providing relevant player suggestions and setting up the mute player. Made updates to the ActiveVoteToMute module to handle potential voters and mute the player if the vote is passed. VoteToMute module is also updated to include the total eligible players. The code is made robust by adding appropriate error checks. * Enable optional logging in ChatLogHandler Modified the ChatLogHandler to support optional logging by introducing a new argument in the getInstance() method. This argument sets the logging state during instantiation. This facilitates better flexibility when using the ChatLogHandler across different sections of the code base as logging requirements may differ. * Add mute vote results sent to Discord and staff presence check Enhanced the vote-to-mute feature by adding a function that sends the mute vote results to a general channel on Discord. A 'staff presence' check has also been added which prevents the mute vote from being initiated if a staff member is online, instead, it prompts users to directly contact a staff member for help. * Fix chat log message deletion query Corrected the SQL query in the `deleteOldMessages` method within `ChatLogQueries.java`. Originally, it was incorrectly deleting newer messages rather than older ones due to an incorrect comparison symbol. It has now been adjusted to properly delete older messages based on the provided duration. * Add player muted logging and abstract embed building The update introduces a log entry indicating when a player has been muted due to voting. The embed creation for this process has been isolated and extracted into a separate function. This contributes to better code modularity and organization. * Implement logging for ChatLogHandler The ChatLogHandler now includes logging for better troubleshooting and understanding of the server state. Logging triggers when chat logging is disabled, when it starts, and also if there's a failure in saving chat messages to the database. * Refactor death message display in PlayerListener The commit refactors the method for displaying player death messages in the PlayerListener class. Specifically, it adds functionality to replace usernames with display names in death notifications. More descriptive death messages with themed colors and icons are now shown. * Refine ChatPlugin and improve "vote to mute" logic Modified ChatPlugin to include "thisPlugin" within the ShutdownListener initialization. Additionally, adjusted the mute vote failure message color from green to red in ActiveVoteToMute file and added a condition to return false if there are no votes. Also, made improvements to the pagination logic in the VoteToMuteStarter file. Lastly, improved the chat logging mechanics in ChatLogHandler by adding and refining various log information for capturing action details. * Update vote validation and argument naming Updated the method "votePassed" in "ActiveVoteToMute" to consider a scenario where no votes have been made for muting. Also, corrected the argument name from "yesno" to "yesNo" in "VoteToMuteHelper" to match with its name in the command constructor * Update vote to mute functionality in chat system Several changes were made to update the vote to mute functionality in the chat system. The threshold for eligible players online for vote has been changed from 10 to 6. In addition, improvements have been made to prevent the vote from ending prematurely. Lastly, feedback messages to users when they cast their vote and an update to the vote start message format have been implemented. * Update vote-to-mute feature The vote-to-mute feature is updated to include information about the vote initiating player. Also, the duration to retrieve chat logs increased from 5 minutes to 10 minutes. Lastly, eligible players are now notified live about the voting progress. * Fix message validation and chat log order Corrected conditional logic in VoteToMuteHelper to validate messages properly and adjusted sorting of chat logs in VoteToMuteStarter to display in reverse chronological order. The update now accurately verifies the existence of selected messages and presents recent logs first for more user-friendly navigation. --- .../java/com/alttd/chat/config/Config.java | 8 + .../alttd/chat/database/ChatLogQueries.java | 98 ++++++ .../chat/database/DatabaseConnection.java | 16 +- .../java/com/alttd/chat/database/Queries.java | 1 + .../alttd/chat/objects/BatchInsertable.java | 10 + .../alttd/chat/objects/chat_log/ChatLog.java | 52 ++++ .../chat/objects/chat_log/ChatLogHandler.java | 128 ++++++++ .../main/java/com/alttd/chat/ChatPlugin.java | 9 +- .../alttd/chat/listeners/ChatListener.java | 8 + .../alttd/chat/listeners/PlayerListener.java | 29 +- .../chat/listeners/ShutdownListener.java | 27 ++ .../com/alttd/velocitychat/VelocityChat.java | 5 + .../velocitychat/commands/VoteToMute.java | 163 ++++++++++ .../commands/VoteToMuteHelper.java | 285 ++++++++++++++++++ .../vote_to_mute/ActiveVoteToMute.java | 238 +++++++++++++++ .../vote_to_mute/VoteToMuteStarter.java | 129 ++++++++ .../listeners/ProxyPlayerListener.java | 18 +- 17 files changed, 1209 insertions(+), 15 deletions(-) create mode 100644 api/src/main/java/com/alttd/chat/database/ChatLogQueries.java create mode 100644 api/src/main/java/com/alttd/chat/objects/BatchInsertable.java create mode 100644 api/src/main/java/com/alttd/chat/objects/chat_log/ChatLog.java create mode 100644 api/src/main/java/com/alttd/chat/objects/chat_log/ChatLogHandler.java create mode 100644 galaxy/src/main/java/com/alttd/chat/listeners/ShutdownListener.java create mode 100644 velocity/src/main/java/com/alttd/velocitychat/commands/VoteToMute.java create mode 100644 velocity/src/main/java/com/alttd/velocitychat/commands/VoteToMuteHelper.java create mode 100644 velocity/src/main/java/com/alttd/velocitychat/commands/vote_to_mute/ActiveVoteToMute.java create mode 100644 velocity/src/main/java/com/alttd/velocitychat/commands/vote_to_mute/VoteToMuteStarter.java diff --git a/api/src/main/java/com/alttd/chat/config/Config.java b/api/src/main/java/com/alttd/chat/config/Config.java index 7d8dfaf..0e7e8cb 100755 --- a/api/src/main/java/com/alttd/chat/config/Config.java +++ b/api/src/main/java/com/alttd/chat/config/Config.java @@ -551,4 +551,12 @@ public final class Config { DEATH_MESSAGES_MAX_PER_PERIOD = getInt("death-messages.max-per-period", DEATH_MESSAGES_MAX_PER_PERIOD); DEATH_MESSAGES_LIMIT_PERIOD_MINUTES = getInt("death-messages.limit-period-minutes", DEATH_MESSAGES_LIMIT_PERIOD_MINUTES); } + + public static long CHAT_LOG_DELETE_OLDER_THAN_DAYS = 31; + public static long CHAT_LOG_SAVE_DELAY_MINUTES = 5; + + private static void chatLogSettings() { + CHAT_LOG_DELETE_OLDER_THAN_DAYS = getLong("chat-log.delete-older-than-days", CHAT_LOG_DELETE_OLDER_THAN_DAYS); + CHAT_LOG_SAVE_DELAY_MINUTES = getLong("chat-log.save-delay-minutes", CHAT_LOG_SAVE_DELAY_MINUTES); + } } diff --git a/api/src/main/java/com/alttd/chat/database/ChatLogQueries.java b/api/src/main/java/com/alttd/chat/database/ChatLogQueries.java new file mode 100644 index 0000000..1554450 --- /dev/null +++ b/api/src/main/java/com/alttd/chat/database/ChatLogQueries.java @@ -0,0 +1,98 @@ +package com.alttd.chat.database; + +import com.alttd.chat.objects.chat_log.ChatLog; +import com.alttd.chat.objects.chat_log.ChatLogHandler; +import com.alttd.chat.util.ALogger; +import org.jetbrains.annotations.NotNull; + +import java.sql.*; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +public class ChatLogQueries { + + protected static void createChatLogTable() { + String nicknamesTableQuery = "CREATE TABLE IF NOT EXISTS chat_log(" + + "uuid CHAR(48) NOT NULL," + + "time_stamp TIMESTAMP(6) NOT NULL, " + + "server VARCHAR(50) NOT NULL, " + + "chat_message VARCHAR(300) NOT NULL, " + + "blocked BIT(1) NOT NULL DEFAULT 0" + + ")"; + + try (PreparedStatement preparedStatement = DatabaseConnection.getConnection().prepareStatement(nicknamesTableQuery)) { + preparedStatement.executeUpdate(); + } catch (Throwable throwable) { + ALogger.error("Failed to create chat log table", throwable); + } + } + + public static @NotNull CompletableFuture storeMessages(HashMap> chatMessages) { + String insertQuery = "INSERT INTO chat_log (uuid, time_stamp, server, chat_message, blocked) VALUES (?, ?, ?, ?, ?)"; + return CompletableFuture.supplyAsync(() -> { + try (Connection connection = DatabaseConnection.createTransactionConnection()) { + PreparedStatement preparedStatement = connection.prepareStatement(insertQuery); + for (List chatLogList : chatMessages.values()) { + for (ChatLog chatLog : chatLogList) { + chatLog.prepareStatement(preparedStatement); + preparedStatement.addBatch(); + } + } + int[] updatedRowsCount = preparedStatement.executeBatch(); + boolean isSuccess = Arrays.stream(updatedRowsCount).allMatch(i -> i >= 0); + + if (isSuccess) { + connection.commit(); + return true; + } else { + connection.rollback(); + ALogger.warn("Failed to store messages"); + return false; + } + } catch (SQLException sqlException) { + ALogger.error("Failed to store chat messages", sqlException); + throw new CompletionException("Failed to store chat messages", sqlException); + } + }); + } + + public static @NotNull CompletableFuture> retrieveMessages(ChatLogHandler chatLogHandler, UUID uuid, Duration duration, String server) { + String query = "SELECT * FROM chat_log WHERE uuid = ? AND time_stamp > ? AND server = ?"; + return CompletableFuture.supplyAsync(() -> { + try (Connection connection = DatabaseConnection.getConnection()) { + PreparedStatement preparedStatement = connection.prepareStatement(query); + preparedStatement.setString(1, uuid.toString()); + preparedStatement.setTimestamp(2, Timestamp.from(Instant.now().minus(duration))); + preparedStatement.setString(3, server); + ResultSet resultSet = preparedStatement.executeQuery(); + List chatLogs = new ArrayList<>(); + while (resultSet.next()) { + ChatLog chatLog = chatLogHandler.loadFromResultSet(resultSet); + chatLogs.add(chatLog); + } + return chatLogs; + } catch (SQLException sqlException) { + ALogger.error(String.format("Failed to retrieve messages for user %s", uuid), sqlException); + throw new CompletionException(String.format("Failed to retrieve messages for user %s", uuid), sqlException); + } + }); + } + + public static CompletableFuture deleteOldMessages(Duration duration) { + String query = "DELETE FROM chat_log WHERE time_stamp < ?"; + + return CompletableFuture.supplyAsync(() -> { + try (Connection connection = DatabaseConnection.getConnection()) { + PreparedStatement preparedStatement = connection.prepareStatement(query); + preparedStatement.setTimestamp(1, Timestamp.from(Instant.now().minus(duration))); + return preparedStatement.execute(); + } catch (SQLException sqlException) { + ALogger.error(String.format("Failed to delete messages older than %s days", duration.toDays()), sqlException); + throw new CompletionException(String.format("Failed to delete messages older than %s days", duration.toDays()), sqlException); + } + }); + } +} diff --git a/api/src/main/java/com/alttd/chat/database/DatabaseConnection.java b/api/src/main/java/com/alttd/chat/database/DatabaseConnection.java index 40e8988..ba4d63c 100755 --- a/api/src/main/java/com/alttd/chat/database/DatabaseConnection.java +++ b/api/src/main/java/com/alttd/chat/database/DatabaseConnection.java @@ -2,7 +2,6 @@ package com.alttd.chat.database; import com.alttd.chat.config.Config; -import com.alttd.chat.util.ALogger; import java.sql.Connection; import java.sql.DriverManager; @@ -66,6 +65,21 @@ public class DatabaseConnection { return connection; } + /** + * Creates a transactional database connection. + * + * @return A {@code Connection} object representing the transactional database connection. + * @throws SQLException If there is an error creating the database connection. + */ + public static Connection createTransactionConnection() throws SQLException { + connection = DriverManager.getConnection( + "jdbc:mysql://" + Config.IP + ":" + Config.PORT + "/" + Config.DATABASE + "?autoReconnect=true"+ + "&useSSL=false", + Config.USERNAME, Config.PASSWORD); + connection.setAutoCommit(false); + return connection; + } + /** * Sets the connection for this instance */ diff --git a/api/src/main/java/com/alttd/chat/database/Queries.java b/api/src/main/java/com/alttd/chat/database/Queries.java index 5d3df66..4c044ef 100755 --- a/api/src/main/java/com/alttd/chat/database/Queries.java +++ b/api/src/main/java/com/alttd/chat/database/Queries.java @@ -21,6 +21,7 @@ public class Queries { tables.add("CREATE TABLE IF NOT EXISTS mails (`id` INT NOT NULL AUTO_INCREMENT, `uuid` VARCHAR(36) NOT NULL, `sender` VARCHAR(36) NOT NULL, `message` VARCHAR(256) NOT NULL, `sendtime` BIGINT default 0, `readtime` BIGINT default 0, PRIMARY KEY (`id`))"); createNicknamesTable(); createRequestedNicknamesTable(); + ChatLogQueries.createChatLogTable(); try { Connection connection = DatabaseConnection.getConnection(); diff --git a/api/src/main/java/com/alttd/chat/objects/BatchInsertable.java b/api/src/main/java/com/alttd/chat/objects/BatchInsertable.java new file mode 100644 index 0000000..bde7fbd --- /dev/null +++ b/api/src/main/java/com/alttd/chat/objects/BatchInsertable.java @@ -0,0 +1,10 @@ +package com.alttd.chat.objects; + +import java.sql.PreparedStatement; +import java.sql.SQLException; + +public interface BatchInsertable { + + void prepareStatement(PreparedStatement preparedStatement) throws SQLException; + +} diff --git a/api/src/main/java/com/alttd/chat/objects/chat_log/ChatLog.java b/api/src/main/java/com/alttd/chat/objects/chat_log/ChatLog.java new file mode 100644 index 0000000..8015b91 --- /dev/null +++ b/api/src/main/java/com/alttd/chat/objects/chat_log/ChatLog.java @@ -0,0 +1,52 @@ +package com.alttd.chat.objects.chat_log; + +import com.alttd.chat.objects.BatchInsertable; +import org.jetbrains.annotations.NotNull; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.UUID; + +public class ChatLog implements BatchInsertable { + + private final UUID uuid; + private final Instant timestamp; + private final String server; + private final String message; + private final boolean blocked; + + protected ChatLog(UUID uuid, Instant timestamp, String server, String message, boolean blocked) { + this.uuid = uuid; + this.timestamp = timestamp; + this.server = server; + this.message = message; + this.blocked = blocked; + } + + @Override + public void prepareStatement(@NotNull PreparedStatement preparedStatement) throws SQLException { + preparedStatement.setString(1, uuid.toString()); + preparedStatement.setTimestamp(2, Timestamp.from(timestamp)); + preparedStatement.setString(3, server); + preparedStatement.setString(4, message); + preparedStatement.setInt(5, blocked ? 1 : 0); + } + + public UUID getUuid() { + return uuid; + } + + public Instant getTimestamp() { + return timestamp; + } + + public String getMessage() { + return message; + } + + public boolean isBlocked() { + return blocked; + } +} diff --git a/api/src/main/java/com/alttd/chat/objects/chat_log/ChatLogHandler.java b/api/src/main/java/com/alttd/chat/objects/chat_log/ChatLogHandler.java new file mode 100644 index 0000000..a3fee53 --- /dev/null +++ b/api/src/main/java/com/alttd/chat/objects/chat_log/ChatLogHandler.java @@ -0,0 +1,128 @@ +package com.alttd.chat.objects.chat_log; + +import com.alttd.chat.config.Config; +import com.alttd.chat.database.ChatLogQueries; +import com.alttd.chat.util.ALogger; +import org.jetbrains.annotations.NotNull; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; + +public class ChatLogHandler { + + private static ChatLogHandler instance = null; + private ScheduledExecutorService executorService = null; + + public static ChatLogHandler getInstance(boolean enableLogging) { + if (instance == null) + instance = new ChatLogHandler(enableLogging); + return instance; + } + + private boolean isSaving; + private final Queue chatLogQueue = new ConcurrentLinkedQueue<>(); + private final HashMap> chatLogs = new HashMap<>(); + + public ChatLogHandler(boolean enableLogging) { + if (!enableLogging) { + ALogger.info("Logging is not enabled on this server."); + return; + } + Duration deleteThreshold = Duration.ofDays(Config.CHAT_LOG_DELETE_OLDER_THAN_DAYS); + ChatLogQueries.deleteOldMessages(deleteThreshold).thenAccept(success -> { + if (success) { + ALogger.info(String.format("Deleted all messages older than %s days from chat log database.", deleteThreshold.toDays())); + } else { + ALogger.warn(String.format("Failed to delete all messages older than %s days from chat log database.", deleteThreshold.toDays())); + } + }); + executorService = Executors.newSingleThreadScheduledExecutor(); + executorService.scheduleAtFixedRate(() -> { + saveToDatabase(false); + ALogger.info(String.format("Running scheduler to save messages with a %d delay", Config.CHAT_LOG_SAVE_DELAY_MINUTES)); + }, + Config.CHAT_LOG_SAVE_DELAY_MINUTES, Config.CHAT_LOG_SAVE_DELAY_MINUTES, TimeUnit.MINUTES); + ALogger.info("Logging has started!"); + } + + /** + * Shuts down the executor service and saves the chat logs to the database. + * Will throw an error if called on a ChatLogHandler that was started without logging + */ + public void shutDown() { + executorService.shutdown(); + saveToDatabase(true); + } + + private synchronized void savingToDatabase(boolean saving) { + isSaving = saving; + } + + private synchronized boolean isBlocked() { + return isSaving; + } + + public synchronized void addLog(ChatLog chatLog) { + if (isBlocked()) { + chatLogQueue.add(chatLog); + } else { + chatLogs.computeIfAbsent(chatLog.getUuid(), k -> new ArrayList<>()).add(chatLog); + } + } + + private void saveToDatabase(boolean onMainThread) { + savingToDatabase(true); + ALogger.info(String.format("Saving %d messages to database", chatLogs.size())); + CompletableFuture booleanCompletableFuture = ChatLogQueries.storeMessages(chatLogs); + if (onMainThread) { + booleanCompletableFuture.join(); + ALogger.info("Finished saving messages on main thread"); + return; + } + booleanCompletableFuture.whenComplete((result, throwable) -> { + if (throwable == null && result) { + chatLogs.clear(); + } else { + ALogger.error("Failed to save chat messages."); + } + savingToDatabase(false); + if (!chatLogQueue.isEmpty()) { + ALogger.info("Adding back messages from queue to chatLogs map"); + } + while (!chatLogQueue.isEmpty()) { + addLog(chatLogQueue.remove()); + } + ALogger.info("Finished saving messages"); + }); + } + + public ChatLog loadFromResultSet(@NotNull ResultSet resultSet) throws SQLException { + UUID chatLogUUID = UUID.fromString(resultSet.getString("uuid")); + Instant chatTimestamp = resultSet.getTimestamp("time_stamp").toInstant(); + String server = resultSet.getString("server"); + String chatMessage = resultSet.getString("chat_message"); + boolean chatMessageBlocked = resultSet.getInt("blocked") == 1; + return new ChatLog(chatLogUUID, chatTimestamp, server, chatMessage, chatMessageBlocked); + } + + public void addChatLog(UUID uuid, String server, String message, boolean blocked) { + addLog(new ChatLog(uuid, Instant.now(), server, message, blocked)); + } + + public CompletableFuture> retrieveChatLogs(UUID uuid, Duration duration, String server) { + List chatLogList = chatLogs.getOrDefault(uuid, new ArrayList<>()); + return ChatLogQueries.retrieveMessages(this, uuid, duration, server) + .thenCompose(chatLogs -> CompletableFuture.supplyAsync(() -> { + chatLogList.addAll(chatLogs); + return chatLogList; + })) + .exceptionally(ex -> { + throw new CompletionException(ex); + }); + } + +} diff --git a/galaxy/src/main/java/com/alttd/chat/ChatPlugin.java b/galaxy/src/main/java/com/alttd/chat/ChatPlugin.java index 9122a63..9721038 100755 --- a/galaxy/src/main/java/com/alttd/chat/ChatPlugin.java +++ b/galaxy/src/main/java/com/alttd/chat/ChatPlugin.java @@ -5,14 +5,12 @@ import com.alttd.chat.config.Config; import com.alttd.chat.config.ServerConfig; import com.alttd.chat.database.DatabaseConnection; import com.alttd.chat.handler.ChatHandler; -import com.alttd.chat.listeners.BookListener; -import com.alttd.chat.listeners.ChatListener; -import com.alttd.chat.listeners.PlayerListener; -import com.alttd.chat.listeners.PluginMessage; +import com.alttd.chat.listeners.*; import com.alttd.chat.nicknames.Nicknames; import com.alttd.chat.nicknames.NicknamesEvents; import com.alttd.chat.objects.channels.Channel; import com.alttd.chat.objects.channels.CustomChannel; +import com.alttd.chat.objects.chat_log.ChatLogHandler; import com.alttd.chat.util.ALogger; import com.alttd.chat.util.Utility; import org.bukkit.Bukkit; @@ -41,7 +39,8 @@ public class ChatPlugin extends JavaPlugin { chatHandler = new ChatHandler(); DatabaseConnection.initialize(); serverConfig = new ServerConfig(Bukkit.getServerName()); - registerListener(new PlayerListener(serverConfig), new ChatListener(), new BookListener()); + ChatLogHandler chatLogHandler = ChatLogHandler.getInstance(true); + registerListener(new PlayerListener(serverConfig), new ChatListener(chatLogHandler), new BookListener(), new ShutdownListener(chatLogHandler, this)); if(serverConfig.GLOBALCHAT) { registerCommand("globalchat", new GlobalChat()); registerCommand("toggleglobalchat", new ToggleGlobalChat()); diff --git a/galaxy/src/main/java/com/alttd/chat/listeners/ChatListener.java b/galaxy/src/main/java/com/alttd/chat/listeners/ChatListener.java index 29aece6..0e40256 100755 --- a/galaxy/src/main/java/com/alttd/chat/listeners/ChatListener.java +++ b/galaxy/src/main/java/com/alttd/chat/listeners/ChatListener.java @@ -6,6 +6,7 @@ import com.alttd.chat.handler.ChatHandler; import com.alttd.chat.managers.ChatUserManager; import com.alttd.chat.managers.RegexManager; import com.alttd.chat.objects.*; +import com.alttd.chat.objects.chat_log.ChatLogHandler; import com.alttd.chat.util.ALogger; import com.alttd.chat.util.GalaxyUtility; import com.alttd.chat.util.Utility; @@ -37,6 +38,11 @@ import java.util.stream.Collectors; public class ChatListener implements Listener { private final PlainTextComponentSerializer plainTextComponentSerializer = PlainTextComponentSerializer.plainText(); + private final ChatLogHandler chatLogHandler; + + public ChatListener(ChatLogHandler chatLogHandler) { + this.chatLogHandler = chatLogHandler; + } @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) @@ -97,6 +103,7 @@ public class ChatListener implements Listener { GalaxyUtility.sendBlockedNotification("Language", player, modifiableString.component(), ""); + chatLogHandler.addChatLog(player.getUniqueId(), player.getServer().getServerName(), PlainTextComponentSerializer.plainText().serialize(input), true); return; // the message was blocked } @@ -115,6 +122,7 @@ public class ChatListener implements Listener { for (Player pingPlayer : playersToPing) { pingPlayer.playSound(pingPlayer.getLocation(), Sound.BLOCK_NOTE_BLOCK_BASS, 1, 1); } + chatLogHandler.addChatLog(player.getUniqueId(), player.getServer().getServerName(), modifiableString.string(), false); ALogger.info(PlainTextComponentSerializer.plainText().serialize(input)); } diff --git a/galaxy/src/main/java/com/alttd/chat/listeners/PlayerListener.java b/galaxy/src/main/java/com/alttd/chat/listeners/PlayerListener.java index 060135e..e748e0e 100755 --- a/galaxy/src/main/java/com/alttd/chat/listeners/PlayerListener.java +++ b/galaxy/src/main/java/com/alttd/chat/listeners/PlayerListener.java @@ -11,6 +11,7 @@ import com.alttd.chat.objects.Toggleable; import com.alttd.chat.util.GalaxyUtility; import com.alttd.chat.util.Utility; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextReplacementConfig; import net.kyori.adventure.text.format.Style; import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.format.TextDecoration; @@ -111,15 +112,29 @@ public class PlayerListener implements Listener { if (playerDeathsStack.size() > Config.DEATH_MESSAGES_MAX_PER_PERIOD || serverConfig.MUTED) { event.deathMessage(Component.empty()); return; - } else { - Component component = event.deathMessage(); - if (component != null) { - component = Component.text("* ").append(component); - component = component.style(Style.style(TextColor.color(82, 80, 77), TextDecoration.ITALIC)); - event.deathMessage(component); - } } + Component component = event.deathMessage(); + playerDeathsStack.push(Instant.now()); + if (component == null) { + return; + } + TextReplacementConfig playerReplacement = TextReplacementConfig.builder() + .match(event.getPlayer().getName()) + .replacement(event.getPlayer().displayName()) + .build(); + component = component.replaceText(playerReplacement); + Player killer = event.getPlayer().getKiller(); + if (killer != null) { + TextReplacementConfig killerReplacement = TextReplacementConfig.builder() + .match(killer.getName()) + .replacement(killer.displayName()) + .build(); + component = component.replaceText(killerReplacement); + } + component = MiniMessage.miniMessage().deserialize("[] ").append(component); + component = component.style(Style.style(TextColor.color(255, 155, 48), TextDecoration.ITALIC)); + event.deathMessage(component); } } diff --git a/galaxy/src/main/java/com/alttd/chat/listeners/ShutdownListener.java b/galaxy/src/main/java/com/alttd/chat/listeners/ShutdownListener.java new file mode 100644 index 0000000..6b4dfc0 --- /dev/null +++ b/galaxy/src/main/java/com/alttd/chat/listeners/ShutdownListener.java @@ -0,0 +1,27 @@ +package com.alttd.chat.listeners; + +import com.alttd.chat.objects.chat_log.ChatLogHandler; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginDisableEvent; +import org.bukkit.plugin.Plugin; + +public class ShutdownListener implements Listener { + + private final ChatLogHandler chatLogHandler; + private final Plugin thisPlugin; + + public ShutdownListener(ChatLogHandler chatLogHandler, Plugin thisPlugin) { + this.chatLogHandler = chatLogHandler; + this.thisPlugin = thisPlugin; + } + + @EventHandler + public void onShutdown(PluginDisableEvent event) { + if (!event.getPlugin().getName().equals(thisPlugin.getName())){ + return; + } + chatLogHandler.shutDown(); + } + +} diff --git a/velocity/src/main/java/com/alttd/velocitychat/VelocityChat.java b/velocity/src/main/java/com/alttd/velocitychat/VelocityChat.java index 6eeac13..d7cb3a9 100755 --- a/velocity/src/main/java/com/alttd/velocitychat/VelocityChat.java +++ b/velocity/src/main/java/com/alttd/velocitychat/VelocityChat.java @@ -5,6 +5,7 @@ import com.alttd.chat.ChatImplementation; import com.alttd.chat.managers.ChatUserManager; import com.alttd.chat.managers.PartyManager; import com.alttd.chat.objects.ChatUser; +import com.alttd.chat.objects.chat_log.ChatLogHandler; import com.alttd.velocitychat.commands.*; import com.alttd.chat.config.Config; import com.alttd.chat.database.DatabaseConnection; @@ -23,6 +24,7 @@ import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; import com.velocitypowered.api.plugin.Dependency; import com.velocitypowered.api.plugin.Plugin; import com.velocitypowered.api.plugin.annotation.DataDirectory; +import com.velocitypowered.api.proxy.ConsoleCommandSource; import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.messages.ChannelIdentifier; import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; @@ -108,11 +110,14 @@ public class VelocityChat { } public void loadCommands() { + ChatLogHandler instance = ChatLogHandler.getInstance(false); new SilentJoinCommand(server); new GlobalAdminChat(server); new Reload(server); new MailCommand(server); new Report(server); + new VoteToMute(server, instance); + new VoteToMuteHelper(server); server.getCommandManager().register("party", new PartyCommand()); // all (proxy)commands go here } diff --git a/velocity/src/main/java/com/alttd/velocitychat/commands/VoteToMute.java b/velocity/src/main/java/com/alttd/velocitychat/commands/VoteToMute.java new file mode 100644 index 0000000..7a7e260 --- /dev/null +++ b/velocity/src/main/java/com/alttd/velocitychat/commands/VoteToMute.java @@ -0,0 +1,163 @@ +package com.alttd.velocitychat.commands; + +import com.alttd.chat.objects.chat_log.ChatLogHandler; +import com.alttd.chat.util.Utility; +import com.alttd.velocitychat.commands.vote_to_mute.VoteToMuteStarter; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.velocitypowered.api.command.BrigadierCommand; +import com.velocitypowered.api.command.CommandMeta; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.server.RegisteredServer; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +public class VoteToMute { + + public VoteToMute(ProxyServer proxyServer, ChatLogHandler chatLogHandler) { + RequiredArgumentBuilder playerNode = RequiredArgumentBuilder + .argument("player", StringArgumentType.string()) + .suggests((context, builder) -> { + List possiblePlayers; + if (context.getSource() instanceof Player player) { + Optional currentServer = player.getCurrentServer(); + if (currentServer.isPresent()) { + possiblePlayers = getEligiblePlayers(currentServer.get().getServer()); + } else { + possiblePlayers = getEligiblePlayers(proxyServer); + } + } else { + possiblePlayers = getEligiblePlayers(proxyServer); + } + Collection possibleValues = possiblePlayers.stream() + .map(Player::getUsername) + .toList(); + + if (possibleValues.isEmpty()) + return Suggestions.empty(); + + String remaining = builder.getRemaining().toLowerCase(); + possibleValues.stream() + .filter(str -> str.toLowerCase().startsWith(remaining)) + .map(StringArgumentType::escapeIfRequired) + .forEach(builder::suggest); + return builder.buildFuture(); + }) + .executes(context -> { + sendHelpMessage(context.getSource()); + return 1; + }); + + LiteralCommandNode command = LiteralArgumentBuilder + .literal("votetomute") + .requires(commandSource -> commandSource.hasPermission("chat.vote-to-mute")) + .requires(commandSource -> commandSource instanceof Player) + .then(playerNode + .suggests(((commandContext, suggestionsBuilder) -> { + if (!(commandContext.getSource() instanceof Player player)) { + return suggestionsBuilder.buildFuture(); + } + Optional currentServer = player.getCurrentServer(); + if (currentServer.isEmpty()) { + sendHelpMessage(commandContext.getSource()); + return suggestionsBuilder.buildFuture(); + } + String remaining = suggestionsBuilder.getRemaining().toLowerCase(); + currentServer.get().getServer().getPlayersConnected().stream() + .filter(connectedPlayer -> connectedPlayer.hasPermission("chat.affected-by-vote-to-mute")) + .map(Player::getUsername) + .filter((String str) -> str.toLowerCase().startsWith(remaining)) + .map(StringArgumentType::escapeIfRequired) + .forEach(suggestionsBuilder::suggest); + return suggestionsBuilder.buildFuture(); + })) + .executes(commandContext -> { + String playerName = commandContext.getArgument("player", String.class); + Optional optionalPlayer = proxyServer.getPlayer(playerName); + if (optionalPlayer.isEmpty()) { + commandContext.getSource().sendMessage(Utility.parseMiniMessage( + "Player is not online.", + Placeholder.parsed("player", playerName))); + return 1; + } + Player voteTarget = optionalPlayer.get(); + if (!voteTarget.hasPermission("chat.affected-by-vote-to-mute")) { + commandContext.getSource().sendMessage(Utility.parseMiniMessage( + "Player can not be muted by a vote.", + Placeholder.parsed("player", playerName))); + return 1; + } + Player player = (Player) commandContext.getSource(); + Optional currentServer = player.getCurrentServer(); + if (currentServer.isEmpty()) { + sendHelpMessage(commandContext.getSource()); + return 1; + } + RegisteredServer server = currentServer.get().getServer(); + if (currentServer.get().getServer().getPlayersConnected().stream().anyMatch(onlinePlayer -> onlinePlayer.hasPermission("chat.staff"))) { + commandContext.getSource().sendMessage(Utility.parseMiniMessage("There is a staff member online, so vote to mute can not be used. Please contact a staff member for help instead.")); + return 1; + } + boolean countLowerRanks = false; + long count = getTotalEligiblePlayers(server, false); + if (count < 6) { + countLowerRanks = true; + count = getTotalEligiblePlayers(server, true); + if (count < 6) { + commandContext.getSource().sendMessage(Utility.parseMiniMessage("Not enough eligible players online to vote.")); + return 1; + } + } + new VoteToMuteStarter(chatLogHandler, voteTarget, player, server.getServerInfo().getName(), countLowerRanks) + .start(); + return 1; + })) + .executes(context -> { + sendHelpMessage(context.getSource()); + return 1; + }) + .build(); + + BrigadierCommand brigadierCommand = new BrigadierCommand(command); + + CommandMeta.Builder metaBuilder = proxyServer.getCommandManager().metaBuilder(brigadierCommand); + + CommandMeta meta = metaBuilder.build(); + + proxyServer.getCommandManager().register(meta, brigadierCommand); + } + + private int getTotalEligiblePlayers(RegisteredServer server, boolean countLowerRanks) { + return (int) server.getPlayersConnected().stream() + .filter(player -> countLowerRanks ? player.hasPermission("chat.backup-vote-to-mute") : player.hasPermission("chat.vote-to-mute")) + .count(); + } + + private void sendHelpMessage(CommandSource commandSource) { + commandSource.sendMessage(Utility.parseMiniMessage("Use: /votetomute .")); + } + + private List getEligiblePlayers(ProxyServer proxyServer) { + return proxyServer.getAllPlayers().stream() + .filter(player -> player.hasPermission("chat.affected-by-vote-to-mute")) + .collect(Collectors.toList()); + } + + private List getEligiblePlayers(RegisteredServer registeredServer) { + return registeredServer.getPlayersConnected().stream() + .filter(player -> player.hasPermission("chat.affected-by-vote-to-mute")) + .collect(Collectors.toList()); + + } + +} diff --git a/velocity/src/main/java/com/alttd/velocitychat/commands/VoteToMuteHelper.java b/velocity/src/main/java/com/alttd/velocitychat/commands/VoteToMuteHelper.java new file mode 100644 index 0000000..4d87162 --- /dev/null +++ b/velocity/src/main/java/com/alttd/velocitychat/commands/VoteToMuteHelper.java @@ -0,0 +1,285 @@ +package com.alttd.velocitychat.commands; + +import com.alttd.chat.util.Utility; +import com.alttd.velocitychat.commands.vote_to_mute.ActiveVoteToMute; +import com.alttd.velocitychat.commands.vote_to_mute.VoteToMuteStarter; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.velocitypowered.api.command.BrigadierCommand; +import com.velocitypowered.api.command.CommandMeta; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.server.RegisteredServer; +import jdk.jshell.execution.Util; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +public class VoteToMuteHelper { + + private static final Component prefix = Utility.parseMiniMessage("[VoteMute]"); + + public VoteToMuteHelper(ProxyServer proxyServer) { + RequiredArgumentBuilder playerNode = RequiredArgumentBuilder + .argument("player", StringArgumentType.string()) + .suggests((context, builder) -> { + List possiblePlayers; + if (context.getSource() instanceof Player player) { + Optional currentServer = player.getCurrentServer(); + if (currentServer.isPresent()) { + possiblePlayers = getEligiblePlayers(currentServer.get().getServer()); + } else { + possiblePlayers = getEligiblePlayers(proxyServer); + } + } else { + possiblePlayers = getEligiblePlayers(proxyServer); + } + Collection possibleValues = possiblePlayers.stream() + .map(Player::getUsername) + .toList(); + + if (possibleValues.isEmpty()) + return Suggestions.empty(); + + String remaining = builder.getRemaining().toLowerCase(); + possibleValues.stream() + .filter(str -> str.toLowerCase().startsWith(remaining)) + .map(StringArgumentType::escapeIfRequired) + .forEach(builder::suggest); + return builder.buildFuture(); + }) + .executes(context -> { + sendHelpMessage(context.getSource()); + return 1; + }); + + RequiredArgumentBuilder yesNoNode = RequiredArgumentBuilder. + argument("yesNo", StringArgumentType.string()) + .suggests(((commandContext, suggestionsBuilder) -> { + List yesNoValues = Arrays.asList("yes", "no"); + String remaining = suggestionsBuilder.getRemaining().toLowerCase(); + yesNoValues.stream() + .filter((String str) -> str.toLowerCase().startsWith(remaining)) + .map(StringArgumentType::escapeIfRequired) + .forEach(suggestionsBuilder::suggest); + return suggestionsBuilder.buildFuture(); + })); + + LiteralArgumentBuilder pageNode = LiteralArgumentBuilder + .literal("page") + .requires(commandSource -> commandSource.hasPermission("chat.vote-to-mute")) + .then(RequiredArgumentBuilder.argument("page number", IntegerArgumentType.integer(1)) + .suggests(((commandContext, suggestionsBuilder) -> { + if (!(commandContext.getSource() instanceof Player player)) { + return suggestionsBuilder.buildFuture(); + } + Optional instance = VoteToMuteStarter.getInstance(player.getUniqueId()); + if (instance.isEmpty()) { + return suggestionsBuilder.buildFuture(); + } + VoteToMuteStarter voteToMuteStarter = instance.get(); + String remaining = suggestionsBuilder.getRemaining().toLowerCase(); + int totalPages = voteToMuteStarter.getTotalPages(); + IntStream.range(1, totalPages + 1) + .mapToObj(String::valueOf) + .filter((String str) -> str.toLowerCase().startsWith(remaining)) + .map(StringArgumentType::escapeIfRequired) + .forEach(suggestionsBuilder::suggest); + return suggestionsBuilder.buildFuture(); + })) + .executes(commandContext -> { + if (!(commandContext.getSource() instanceof Player player)) { + commandContext.getSource().sendMessage(Utility.parseMiniMessage("Only players can use this command.")); + return 1; + } + Optional instance = VoteToMuteStarter.getInstance(player.getUniqueId()); + if (instance.isEmpty()) { + commandContext.getSource().sendMessage(Utility.parseMiniMessage("You don't have an active vote to mute.")); + return 1; + } + int pageNumber = commandContext.getArgument("page number", Integer.class); + instance.get().showPage(pageNumber); + return 1; + }) + ).executes(commandContext -> { + sendHelpMessage(commandContext.getSource()); + return 1; + }); + + LiteralArgumentBuilder enterMessagesNode = LiteralArgumentBuilder + .literal("messages") + .requires(commandSource -> commandSource.hasPermission("chat.vote-to-mute")) + .then(RequiredArgumentBuilder.argument("list of messages", StringArgumentType.greedyString()) + .executes(commandContext -> { + if (!(commandContext.getSource() instanceof Player player)) { + commandContext.getSource().sendMessage(Utility.parseMiniMessage("Only players can use this command.")); + return 1; + } + Optional instance = VoteToMuteStarter.getInstance(player.getUniqueId()); + if (instance.isEmpty()) { + commandContext.getSource().sendMessage(Utility.parseMiniMessage("You don't have an active vote to mute.")); + return 1; + } + String listOfPages = commandContext.getArgument("list of messages", String.class); + if (!listOfPages.matches("([1-9][0-9]*, )*[1-9][0-9]*")) { + commandContext.getSource().sendMessage(Utility.parseMiniMessage("Please make sure to format the command correctly.")); + return 1; + } + VoteToMuteStarter voteToMuteStarter = instance.get(); + + List collect = Arrays.stream(listOfPages.split(", ")) + .map(Integer::parseInt) + .collect(Collectors.toList()); + Optional max = collect.stream().max(Integer::compare); + if (max.isEmpty()) { + commandContext.getSource().sendMessage(Utility.parseMiniMessage("Some of your selected messages do not exist.")); + return 1; + } + int highestLogEntry = max.get(); + + if (voteToMuteStarter.getTotalLogEntries() < highestLogEntry) { + commandContext.getSource().sendMessage(Utility.parseMiniMessage("Some of your selected messages do not exist.")); + return 1; + } + + Optional currentServer = player.getCurrentServer(); + if (currentServer.isEmpty()) { + sendHelpMessage(commandContext.getSource()); + return 1; + } + + Component chatLogs = voteToMuteStarter.getChatLogsAndClose(collect); + RegisteredServer server = currentServer.get().getServer(); + long count = getTotalEligiblePlayers(server, voteToMuteStarter.countLowerRanks()); + new ActiveVoteToMute(voteToMuteStarter.getVotedPlayer(), server, proxyServer, Duration.ofMinutes(5), + (int) count, voteToMuteStarter.countLowerRanks(), chatLogs, player) + .start(); + return 1; + }) + ).executes(commandContext -> { + sendHelpMessage(commandContext.getSource()); + return 1; + }); + + LiteralArgumentBuilder voteNode = LiteralArgumentBuilder + .literal("vote") + .then(playerNode + .then(yesNoNode + .executes(commandContext -> { + if (!(commandContext.getSource() instanceof Player player)) { + commandContext.getSource().sendMessage(Utility.parseMiniMessage( + "Only players are allowed to vote")); + return 1; + } + String playerName = commandContext.getArgument("player", String.class); + Optional optionalActiveVoteToMute = ActiveVoteToMute.getInstance(playerName); + if (optionalActiveVoteToMute.isEmpty()) { + commandContext.getSource().sendMessage(Utility.parseMiniMessage( + "This player does not have an active vote to mute them.")); + return 1; + } + ActiveVoteToMute activeVoteToMute = optionalActiveVoteToMute.get(); + + if (!activeVoteToMute.countLowerRanks()) { + if (!player.hasPermission("chat.vote-to-mute")) { + player.sendMessage(Utility.parseMiniMessage("You are not eligible to vote.")); + return 1; + } + } + + String vote = commandContext.getArgument("yesNo", String.class); + switch (vote.toLowerCase()) { + case "yes" -> { + activeVoteToMute.vote(player.getUniqueId(), true); + commandContext.getSource().sendMessage(Utility.parseMiniMessage( + "You voted to mute. Thanks for voting, staff will be online soon to review!")); + player.getCurrentServer().ifPresent(serverConnection -> notifyEligiblePlayers(serverConnection.getServer(), activeVoteToMute)); + } + case "no" -> { + activeVoteToMute.vote(player.getUniqueId(), false); + commandContext.getSource().sendMessage(Utility.parseMiniMessage( + "You voted not to mute. Thanks for voting, staff will be online soon to review!")); + } + default -> commandContext.getSource().sendMessage(Utility.parseMiniMessage( + " is not a valid vote option", Placeholder.parsed("vote", vote))); + } + return 1; + })).executes(context -> { + sendHelpMessage(context.getSource()); + return 1; + })).executes(context -> { + sendHelpMessage(context.getSource()); + return 1; + }); + + LiteralCommandNode command = LiteralArgumentBuilder + .literal("votetomutehelper") + .requires(commandSource -> commandSource.hasPermission("chat.backup-vote-to-mute")) + .requires(commandSource -> commandSource instanceof Player) + .then(voteNode) + .then(pageNode) + .then(enterMessagesNode) + .executes(context -> { + sendHelpMessage(context.getSource()); + return 1; + }) + .build(); + + BrigadierCommand brigadierCommand = new BrigadierCommand(command); + + CommandMeta.Builder metaBuilder = proxyServer.getCommandManager().metaBuilder(brigadierCommand); + + CommandMeta meta = metaBuilder.build(); + + proxyServer.getCommandManager().register(meta, brigadierCommand); + } + + private int getTotalEligiblePlayers(RegisteredServer server, boolean countLowerRanks) { + return (int) server.getPlayersConnected().stream() + .filter(player -> countLowerRanks ? player.hasPermission("chat.backup-vote-to-mute") : player.hasPermission("chat.vote-to-mute")) + .count(); + } + + private void notifyEligiblePlayers(RegisteredServer server, ActiveVoteToMute activeVoteToMute) { + Component message = Utility.parseMiniMessage(" out of players have voted to mute ", + Placeholder.component("prefix", prefix), + Placeholder.parsed("voted_for", String.valueOf(activeVoteToMute.getVotedFor())), + Placeholder.parsed("total_votes", String.valueOf(activeVoteToMute.getTotalEligibleVoters())), + Placeholder.parsed("player", activeVoteToMute.getVotedPlayer().getUsername())); + boolean countLowerRanks = activeVoteToMute.countLowerRanks(); + server.getPlayersConnected().stream() + .filter(player -> countLowerRanks ? player.hasPermission("chat.backup-vote-to-mute") : player.hasPermission("chat.vote-to-mute")) + .forEach(player -> player.sendMessage(message)); + } + + private void sendHelpMessage(CommandSource commandSource) { + commandSource.sendMessage(Utility.parseMiniMessage("Use: /votetomutehelper .")); + } + + private List getEligiblePlayers(ProxyServer proxyServer) { + return proxyServer.getAllPlayers().stream() + .filter(player -> player.hasPermission("chat.affected-by-vote-to-mute")) + .collect(Collectors.toList()); + } + + private List getEligiblePlayers(RegisteredServer registeredServer) { + return registeredServer.getPlayersConnected().stream() + .filter(player -> player.hasPermission("chat.affected-by-vote-to-mute")) + .collect(Collectors.toList()); + + } + +} diff --git a/velocity/src/main/java/com/alttd/velocitychat/commands/vote_to_mute/ActiveVoteToMute.java b/velocity/src/main/java/com/alttd/velocitychat/commands/vote_to_mute/ActiveVoteToMute.java new file mode 100644 index 0000000..65978cd --- /dev/null +++ b/velocity/src/main/java/com/alttd/velocitychat/commands/vote_to_mute/ActiveVoteToMute.java @@ -0,0 +1,238 @@ +package com.alttd.velocitychat.commands.vote_to_mute; + +import com.alttd.chat.config.Config; +import com.alttd.chat.util.ALogger; +import com.alttd.chat.util.Utility; +import com.alttd.proxydiscordlink.DiscordLink; +import com.alttd.proxydiscordlink.lib.net.dv8tion.jda.api.EmbedBuilder; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.server.RegisteredServer; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.jetbrains.annotations.NotNull; + +import java.awt.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +public class ActiveVoteToMute { + + private static final HashMap instances = new HashMap<>(); + private static final Component prefix = Utility.parseMiniMessage("[VoteMute]"); + + private final Player votedPlayer; + private final Player startedByPlayer; + private HashSet votedFor = new HashSet<>(); + private HashSet votedAgainst = new HashSet<>(); + private int totalEligibleVoters; + private final boolean countLowerRanks; + private final RegisteredServer server; + private final ProxyServer proxyServer; + private final Component chatLogs; + private boolean endedVote = false; + + public static Optional getInstance(String username) { + if (!instances.containsKey(username)) + return Optional.empty(); + return Optional.of(instances.get(username)); + } + + public static void removePotentialVoter(Player player, RegisteredServer previousServer) { + if (!player.hasPermission("chat.backup-vote-to-mute")) + return; + if (player.hasPermission("chat.vote-to-mute")) { + instances.values().stream() + .filter(activeVoteToMute -> previousServer == null || activeVoteToMute.getServer().getServerInfo().hashCode() == previousServer.getServerInfo().hashCode()) + .forEach(inst -> inst.removeEligibleVoter(player.getUniqueId())); + } else { + instances.values().stream() + .filter(ActiveVoteToMute::countLowerRanks) + .filter(activeVoteToMute -> previousServer == null || activeVoteToMute.getServer().getServerInfo().hashCode() == previousServer.getServerInfo().hashCode()) + .forEach(inst -> inst.removeEligibleVoter(player.getUniqueId())); + } + } + + public static void addPotentialVoter(Player player, ServerConnection server) { + if (!player.hasPermission("chat.backup-vote-to-mute")) + return; + if (player.hasPermission("chat.vote-to-mute")) { + instances.values().stream() + .filter(activeVoteToMute -> activeVoteToMute.getServer().getServerInfo().hashCode() == server.getServerInfo().hashCode()) + .forEach(activeVoteToMute -> activeVoteToMute.addEligibleVoter(player)); + } else { + instances.values().stream() + .filter(ActiveVoteToMute::countLowerRanks) + .filter(activeVoteToMute -> activeVoteToMute.getServer().getServerInfo().hashCode() == server.getServerInfo().hashCode()) + .forEach(activeVoteToMute -> activeVoteToMute.addEligibleVoter(player)); + } + } + + public ActiveVoteToMute(@NotNull Player votedPlayer, @NotNull RegisteredServer server, ProxyServer proxyServer, Duration duration, + int totalEligibleVoters, boolean countLowerRanks, Component chatLogs, @NotNull Player startedByPlayer) { + this.chatLogs = chatLogs; + this.votedPlayer = votedPlayer; + this.totalEligibleVoters = totalEligibleVoters; + this.countLowerRanks = countLowerRanks; + this.server = server; + this.proxyServer = proxyServer; + instances.put(votedPlayer.getUsername(), this); + ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); + executorService.schedule(this::endVote, + duration.toMinutes(), TimeUnit.MINUTES); + this.startedByPlayer = startedByPlayer; + } + + private RegisteredServer getServer() { + return server; + } + + private void endVote() { + if (endedVote) + return; + instances.remove(votedPlayer.getUsername()); + if (votePassed()) { + mutePlayer(); + return; + } + Component message = Utility.parseMiniMessage(" The vote to mute has failed, they will not be muted.", + Placeholder.component("prefix", prefix), Placeholder.parsed("player", votedPlayer.getUsername())); + server.getPlayersConnected().stream() + .filter(player -> countLowerRanks ? player.hasPermission("chat.backup-vote-to-mute") : player.hasPermission("chat.vote-to-mute")) + .forEach(player -> player.sendMessage(message)); + } + + public void start() { + Component message = getVoteStartMessage(); + server.getPlayersConnected().stream() + .filter(player -> countLowerRanks ? player.hasPermission("chat.backup-vote-to-mute") : player.hasPermission("chat.vote-to-mute")) + .forEach(player -> player.sendMessage(message)); + } + + public void vote(UUID uuid, boolean votedToMute) { + if (votedToMute) { + votedFor.add(uuid); + votedAgainst.remove(uuid); + if (!votePassed()) { + return; + } + endedVote = true; + instances.remove(votedPlayer.getUsername()); + mutePlayer(); + } else { + votedAgainst.add(uuid); + votedFor.remove(uuid); + } + } + + public boolean votePassed() { + double totalVotes = (votedFor.size() + votedAgainst.size()); + if (totalVotes == 0 || votedFor.isEmpty()) { + return false; + } + if (totalVotes / totalEligibleVoters < 0.6) { + return false; + } + return votedFor.size() / totalVotes > 0.6; + } + + public boolean countLowerRanks() { + return countLowerRanks; + } + + private void mutePlayer() { + Component message = Utility.parseMiniMessage(" The vote to mute has passed, they will be muted.", + Placeholder.component("prefix", prefix), Placeholder.parsed("player", votedPlayer.getUsername())); + server.getPlayersConnected().stream() + .filter(player -> countLowerRanks ? player.hasPermission("chat.backup-vote-to-mute") : player.hasPermission("chat.vote-to-mute")) + .forEach(player -> player.sendMessage(message)); + proxyServer.getCommandManager().executeAsync(proxyServer.getConsoleCommandSource(), + String.format("tempmute %s 1h Muted by the community - under review. -p", votedPlayer.getUsername())); + + + String chatLogsString = PlainTextComponentSerializer.plainText().serialize(chatLogs); + EmbedBuilder embedBuilder = buildMutedEmbed(chatLogsString); + + ALogger.info(String.format("Player %s muted by vote\nLogs:\n%s\n\nVotes for:\n%s\nVotes against:\n%s\n", + votedPlayer.getUsername(), + chatLogsString, + parseUUIDsToPlayerOrString(votedFor), + parseUUIDsToPlayerOrString(votedAgainst) + )); + + long id = Config.serverChannelId.get("general"); + DiscordLink.getPlugin().getBot().sendEmbedToDiscord(id, embedBuilder, -1); + } + + @NotNull + private EmbedBuilder buildMutedEmbed(String chatLogsString) { + EmbedBuilder embedBuilder = new EmbedBuilder(); + embedBuilder.setAuthor(votedPlayer.getUsername(), null, "https://crafatar.com/avatars/" + votedPlayer.getUniqueId() + "?overlay"); + embedBuilder.setTitle("Player muted by vote"); + embedBuilder.setColor(Color.CYAN); + embedBuilder.addField("Logs", + chatLogsString.substring(0, Math.min(chatLogsString.length(), 1024)), + false); + embedBuilder.addField("Server", + server.getServerInfo().getName().substring(0, 1).toUpperCase() + server.getServerInfo().getName().substring(1), + true); + embedBuilder.addField("Started by", + String.format("Username: %s\nUUID: %s", startedByPlayer.getUsername(), startedByPlayer.getUniqueId().toString()), + true); + return embedBuilder; + } + + private String parseUUIDsToPlayerOrString(Collection uuids) { + return uuids.stream().map(uuid -> { + Optional player = proxyServer.getPlayer(uuid); + if (player.isPresent()) { + return player.get().getUsername(); + } + return uuid.toString(); + }).collect(Collectors.joining("\n")); + } + + public void addEligibleVoter(Player player) { + UUID uuid = player.getUniqueId(); + if (votedAgainst.contains(uuid) || votedFor.contains(uuid)) + return; + totalEligibleVoters++; + player.sendMessage(getVoteStartMessage()); + } + + public void removeEligibleVoter(UUID uuid) { + if (votedFor.contains(uuid) || votedAgainst.contains(uuid)) + return; + totalEligibleVoters--; + } + + private Component getVoteStartMessage() { + return Utility.parseMiniMessage( + String.format(""" + A vote to mute for one hour has been started, please read the logs below before voting. + + Click: Mute --- Don't mute""", + votedPlayer.getUsername(), votedPlayer.getUsername()), + Placeholder.component("prefix", prefix), + Placeholder.parsed("player", votedPlayer.getUsername()), + Placeholder.component("logs", chatLogs)); + } + + public Player getVotedPlayer() { + return votedPlayer; + } + + public int getVotedFor() { + return votedFor.size(); + } + + public int getTotalEligibleVoters() { + return totalEligibleVoters; + } +} diff --git a/velocity/src/main/java/com/alttd/velocitychat/commands/vote_to_mute/VoteToMuteStarter.java b/velocity/src/main/java/com/alttd/velocitychat/commands/vote_to_mute/VoteToMuteStarter.java new file mode 100644 index 0000000..57b213b --- /dev/null +++ b/velocity/src/main/java/com/alttd/velocitychat/commands/vote_to_mute/VoteToMuteStarter.java @@ -0,0 +1,129 @@ +package com.alttd.velocitychat.commands.vote_to_mute; + +import com.alttd.chat.objects.chat_log.ChatLog; +import com.alttd.chat.objects.chat_log.ChatLogHandler; +import com.alttd.chat.util.Utility; +import com.velocitypowered.api.proxy.Player; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; + +import java.time.Duration; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +public class VoteToMuteStarter { + + private static final HashMap instanceMap = new HashMap<>(); + + private static final Component prefix = Utility.parseMiniMessage("[VoteMute]"); + private final ChatLogHandler chatLogHandler; + private final Player votedPlayer; + private final Player commandSource; + private final String serverName; + private List parsedChatLogs; + private final boolean countLowerRanks; + + public static Optional getInstance(UUID uuid) { + if (!instanceMap.containsKey(uuid)) + return Optional.empty(); + return Optional.of(instanceMap.get(uuid)); + } + + public VoteToMuteStarter(ChatLogHandler chatLogHandler, Player votedPlayer, Player commandSource, String serverName, boolean countLowerRanks) { + this.chatLogHandler = chatLogHandler; + this.votedPlayer = votedPlayer; + this.commandSource = commandSource; + this.serverName = serverName; + this.countLowerRanks = countLowerRanks; + instanceMap.put(commandSource.getUniqueId(), this); + } + + public void start() { + chatLogHandler.retrieveChatLogs(votedPlayer.getUniqueId(), Duration.ofMinutes(10), serverName).whenCompleteAsync((chatLogs, throwable) -> { + if (throwable != null) { + commandSource.sendMessage(Utility.parseMiniMessage(" Unable to retrieve messages for player ", + Placeholder.component("prefix", prefix), + Placeholder.parsed("player", votedPlayer.getUsername()))); + return; + } + parseChatLogs(chatLogs); + commandSource.sendMessage(Utility.parseMiniMessage( + " Please select up to 10 messages other players should see to decide their vote, seperated by comma's. " + + "Example: /votetomutehelper messages 1, 2, 5, 8", Placeholder.component("prefix", prefix))); + showPage(1); + }); + } + + private void parseChatLogs(List chatLogs) { + TagResolver.Single playerTag = Placeholder.parsed("player", votedPlayer.getUsername()); + TagResolver.Single prefixTag = Placeholder.component("prefix", prefix); + chatLogs.sort(Comparator.comparing(ChatLog::getTimestamp).reversed()); + parsedChatLogs = IntStream.range(0, chatLogs.size()) + .mapToObj(i -> Utility.parseMiniMessage( + ". : ", + TagResolver.resolver( + Placeholder.unparsed("message", chatLogs.get(i).getMessage()), + Placeholder.parsed("number", String.valueOf(i + 1)), + playerTag, prefixTag + )) + ) + .toList(); + } + + public void showPage(int page) { + List collect = parsedChatLogs.stream().skip((page - 1) * 10L).limit(10L).toList(); + Component chatLogsComponent = Component.join(JoinConfiguration.newlines(), collect); + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append(" ChatLogs for \n\n"); + if (page > 1) { + stringBuilder.append("Click to go to previous page'> "); + } + if (parsedChatLogs.size() > page * 10) { + stringBuilder.append("Click to go to next page'> "); + } + commandSource.sendMessage(Utility.parseMiniMessage(stringBuilder.toString(), + Placeholder.parsed("player", votedPlayer.getUsername()), + Placeholder.component("prefix", prefix), + Placeholder.component("logs", chatLogsComponent))); + } + + /** + * Retrieves the chat logs for the given list of IDs. It removes 1 from the IDs before using them + * It removes the instance from the hashmap after this function call + * + * @param ids A list of integers representing the IDs of the chat logs to retrieve. + * @return A Component object containing the selected chat logs joined by newlines. + */ + public Component getChatLogsAndClose(List ids) { + List selectedChatLogs = ids.stream() + .filter(id -> id >= 1 && id <= parsedChatLogs.size()) + .map(id -> parsedChatLogs.get(id - 1)) + .collect(Collectors.toList()); + + instanceMap.remove(commandSource.getUniqueId()); + return Component.join(JoinConfiguration.newlines(), selectedChatLogs); + } + + public int getTotalPages() { + return (int) Math.ceil((double) parsedChatLogs.size() / 10); + } + + public Player getVotedPlayer() { + return votedPlayer; + } + + public int getTotalLogEntries() { + return parsedChatLogs.size(); + } + + public boolean countLowerRanks() { + return countLowerRanks; + } +} diff --git a/velocity/src/main/java/com/alttd/velocitychat/listeners/ProxyPlayerListener.java b/velocity/src/main/java/com/alttd/velocitychat/listeners/ProxyPlayerListener.java index 091270f..f8c2f83 100755 --- a/velocity/src/main/java/com/alttd/velocitychat/listeners/ProxyPlayerListener.java +++ b/velocity/src/main/java/com/alttd/velocitychat/listeners/ProxyPlayerListener.java @@ -6,6 +6,7 @@ import com.alttd.chat.objects.Mail; import com.alttd.chat.util.Utility; import com.alttd.velocitychat.VelocityChat; import com.alttd.chat.config.Config; +import com.alttd.velocitychat.commands.vote_to_mute.ActiveVoteToMute; import com.alttd.velocitychat.data.ServerWrapper; import com.alttd.velocitychat.handlers.ServerHandler; import com.alttd.chat.managers.PartyManager; @@ -17,6 +18,7 @@ import com.velocitypowered.api.event.connection.LoginEvent; import com.velocitypowered.api.event.player.ServerConnectedEvent; import com.velocitypowered.api.event.player.ServerPostConnectEvent; import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ServerConnection; import com.velocitypowered.api.proxy.server.RegisteredServer; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -48,9 +50,20 @@ public class ProxyPlayerListener { @Subscribe(order = PostOrder.LAST) public void afterPlayerLogin(ServerPostConnectEvent event) { - if (event.getPreviousServer() != null) - return; Player player = event.getPlayer(); + RegisteredServer previousServer = event.getPreviousServer(); + if (previousServer != null) { + ActiveVoteToMute.removePotentialVoter(player, previousServer); + Optional currentServer = player.getCurrentServer(); + if (currentServer.isEmpty()) + return; + ActiveVoteToMute.addPotentialVoter(player, currentServer.get()); + return; + } + Optional currentServer = player.getCurrentServer(); + if (currentServer.isEmpty()) + return; + ActiveVoteToMute.addPotentialVoter(player, currentServer.get()); ChatUser chatUser = ChatUserManager.getChatUser(player.getUniqueId()); List unReadMail = chatUser.getUnReadMail(); if (unReadMail.isEmpty()) @@ -62,6 +75,7 @@ public class ProxyPlayerListener { @Subscribe public void quitEvent(DisconnectEvent event) { + ActiveVoteToMute.removePotentialVoter(event.getPlayer(), null); UUID uuid = event.getPlayer().getUniqueId(); Party party = PartyManager.getParty(event.getPlayer().getUniqueId()); if (party == null) return;