From a99fbefe32d04c64f23ef7768df3822c7914ca32 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 13 Dec 2025 06:11:35 +0000 Subject: [PATCH 1/4] feat(chat): Add hoverable LuckPerms rank prefixes to chat Implements a new chat formatting system on the Velocity proxy. - Fetches the user's highest-priority rank from LuckPerms. - Displays a hoverable prefix before the username. - The hover text shows the rank's display name and description, which are configured via LuckPerms meta keys. - All chat processing, including an existing chat filter and the new rank formatting, is now handled asynchronously to prevent server lag. - Adds a `NOTES.md` file explaining how to configure the required LuckPerms meta keys. --- NOTES.md | 43 +++++ zander-velocity/dependency-reduced-pom.xml | 6 + zander-velocity/pom.xml | 12 ++ .../zander/velocity/ZanderVelocityMain.java | 12 +- .../zander/velocity/events/UserChatEvent.java | 168 +++++++++++++----- 5 files changed, 193 insertions(+), 48 deletions(-) create mode 100644 NOTES.md diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..6a96496 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,43 @@ +# Zander Chat Formatting - Server Operator Notes + +This document explains how to configure LuckPerms to display hoverable rank prefixes in the chat. + +## Required Meta Keys + +For each LuckPerms group that you want to have a custom prefix, you must set two meta keys: `displayname` and `rank_description`. + +- `displayname`: The human-readable name of the rank (e.g., "Admin", "Moderator"). +- `rank_description`: A short description of the rank's purpose. + +### Fallbacks + +- If `displayname` is not set, the plugin will try to use the group's prefix. If that is also not set, it will default to "Member". +- If `rank_description` is not set, it will default to "No description set for this rank." + +## LuckPerms Commands + +Here are the commands to set the required meta keys for a group. Replace `` with the name of the LuckPerms group you want to modify. + +### Set Display Name + +``` +lp group meta set displayname "Rank Name" +``` + +**Example:** + +``` +lp group admin meta set displayname "Admin" +``` + +### Set Rank Description + +``` +lp group meta set rank_description "A short description of the rank." +``` + +**Example:** + +``` +lp group admin meta set rank_description "Has full access to staff & server tools." +``` diff --git a/zander-velocity/dependency-reduced-pom.xml b/zander-velocity/dependency-reduced-pom.xml index fb7ee13..7ca3d20 100644 --- a/zander-velocity/dependency-reduced-pom.xml +++ b/zander-velocity/dependency-reduced-pom.xml @@ -71,6 +71,12 @@ 3.4.0-SNAPSHOT provided + + net.luckperms + api + 5.4 + provided + 17 diff --git a/zander-velocity/pom.xml b/zander-velocity/pom.xml index 8b37212..272e25e 100644 --- a/zander-velocity/pom.xml +++ b/zander-velocity/pom.xml @@ -126,5 +126,17 @@ boosted-yaml 1.3.1 + + net.luckperms + api + 5.4 + provided + + + net.kyori + adventure-text-minimessage + 4.17.0 + compile + \ No newline at end of file diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/ZanderVelocityMain.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/ZanderVelocityMain.java index 15a42c3..4cce275 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/ZanderVelocityMain.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/ZanderVelocityMain.java @@ -11,6 +11,8 @@ import com.velocitypowered.api.proxy.ProxyServer; import dev.dejvokep.boostedyaml.YamlDocument; import dev.dejvokep.boostedyaml.dvs.versioning.BasicVersioning; +import net.luckperms.api.LuckPerms; +import net.luckperms.api.LuckPermsProvider; import dev.dejvokep.boostedyaml.settings.dumper.DumperSettings; import dev.dejvokep.boostedyaml.settings.general.GeneralSettings; import dev.dejvokep.boostedyaml.settings.loader.LoaderSettings; @@ -41,7 +43,8 @@ name = "zander-velocity", version = "1.2.0", dependencies = { - @Dependency(id = "signedvelocity") + @Dependency(id = "signedvelocity"), + @Dependency(id = "luckperms", optional = false) } ) public class ZanderVelocityMain { @@ -53,11 +56,16 @@ public class ZanderVelocityMain { private static YamlDocument config; @Getter private final CommandManager commandManager; + @Getter + private static LuckPerms luckPerms; @Subscribe public void onProxyInitialization(ProxyInitializeEvent event) { + // Get LuckPerms + luckPerms = LuckPermsProvider.get(); + // Event Listeners - proxy.getEventManager().register(this, new UserChatEvent()); + proxy.getEventManager().register(this, new UserChatEvent(luckPerms)); proxy.getEventManager().register(this, new UserCommandSpyEvent()); proxy.getEventManager().register(this, new UserOnDisconnect()); proxy.getEventManager().register(this, new UserOnLogin()); diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java index 2140a07..35f83f3 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java @@ -9,67 +9,143 @@ import io.github.ModularEnigma.Response; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.luckperms.api.LuckPerms; +import net.luckperms.api.cacheddata.CachedMetaData; +import net.luckperms.api.model.user.User; import org.modularsoft.zander.velocity.ZanderVelocityMain; import org.modularsoft.zander.velocity.model.Filter; import org.modularsoft.zander.velocity.model.discord.DiscordChat; +import java.util.concurrent.CompletableFuture; + public class UserChatEvent { + private final LuckPerms luckPerms; + private final MiniMessage miniMessage = MiniMessage.miniMessage(); + + public UserChatEvent(LuckPerms luckPerms) { + this.luckPerms = luckPerms; + } + + private String escapeMiniMessageAttribute(String str) { + if (str == null) return ""; + return str.replace("'", "\\'"); + } + + private Component buildRankPrefix(User user) { + final CachedMetaData metaData = user.getCachedData().getMetaData(); + String displayName = metaData.getMetaValue("displayname"); + if (displayName == null || displayName.isEmpty()) { + displayName = metaData.getPrefix(); + if (displayName != null) { + displayName = miniMessage.stripTags(displayName); + } + if (displayName == null || displayName.isEmpty()) { + displayName = "Member"; + } + } + + String rankDescription = metaData.getMetaValue("rank_description"); + if (rankDescription == null || rankDescription.isEmpty()) { + rankDescription = "No description set for this rank."; + } + + final String hoverText = "" + escapeMiniMessageAttribute(displayName) + "\n" + escapeMiniMessageAttribute(rankDescription) + ""; + final String prefixText = "[" + displayName + "]"; + String fullPrefixMiniMessage = "" + prefixText + ""; + + return miniMessage.deserialize(fullPrefixMiniMessage); + } + @Subscribe - public void UserChatEvent(PlayerChatEvent event) { + public void onPlayerChat(PlayerChatEvent event) { Player player = event.getPlayer(); - String BaseAPIURL = ZanderVelocityMain.getConfig().getString(Route.from("BaseAPIURL")); - String APIKey = ZanderVelocityMain.getConfig().getString(Route.from("APIKey")); - - // Filter out commands. - if (event.getMessage().startsWith("/")) return; - - // Check chat for blocked content - try { - Filter phrase = Filter.builder() - .content(event.getMessage().toString()) - .build(); - - Request phraseReq = Request.builder() - .setURL(BaseAPIURL + "/filter") - .setMethod(Request.Method.POST) - .addHeader("x-access-token", APIKey) - .setRequestBody(phrase.toString()) - .build(); - - Response phraseRes = phraseReq.execute(); - String phraseJson = phraseRes.getBody(); - - Boolean success = JsonPath.parse(phraseJson).read("$.success"); - String phraseCaughtMessage = JsonPath.read(phraseJson, "$.message"); - - ZanderVelocityMain.getLogger().info("[FILTER] Response (" + phraseRes.getStatusCode() + "): " + phraseRes.getBody()); - - if (!success) { - Component builder = Component.text(phraseCaughtMessage).color(NamedTextColor.RED); - player.sendMessage(builder); - event.setResult(PlayerChatEvent.ChatResult.denied()); - } else { + String message = event.getMessage(); + + if (message.startsWith("/")) { + return; + } + + // Prevent the original message from being sent + event.setResult(PlayerChatEvent.ChatResult.denied()); + + // Asynchronously process the chat message + CompletableFuture.runAsync(() -> { + String baseApiUrl = ZanderVelocityMain.getConfig().getString(Route.from("BaseAPIURL")); + String apiKey = ZanderVelocityMain.getConfig().getString(Route.from("APIKey")); + + // --- 1. Asynchronous Chat Filtering --- + try { + Filter phrase = Filter.builder().content(message).build(); + Request phraseReq = Request.builder() + .setURL(baseApiUrl + "/filter") + .setMethod(Request.Method.POST) + .addHeader("x-access-token", apiKey) + .setRequestBody(phrase.toString()) + .build(); + + Response phraseRes = phraseReq.execute(); // This is still blocking, but now off the main thread + String phraseJson = phraseRes.getBody(); + boolean success = JsonPath.parse(phraseJson).read("$.success"); + + if (!success) { + String phraseCaughtMessage = JsonPath.read(phraseJson, "$.message"); + player.sendMessage(Component.text(phraseCaughtMessage, NamedTextColor.RED)); + return; // Stop processing + } + + // --- 2. Asynchronous Discord Webhook --- + sendToDiscord(player, message, baseApiUrl, apiKey); + + } catch (Exception e) { + player.sendMessage(Component.text("The chat filter could not be reached. Contact staff if this persists.", NamedTextColor.YELLOW)); + ZanderVelocityMain.getLogger().error("Error while filtering chat message: ", e); + // We can still proceed to format and send the message locally + } + + // --- 3. Asynchronous LuckPerms User Loading and Message Broadcasting --- + luckPerms.getUserManager().loadUser(player.getUniqueId()).thenAcceptAsync(user -> { + Component prefix = buildRankPrefix(user); + Component messageComponent = Component.text(message); + Component finalMessage = prefix + .append(Component.text(" " + player.getUsername() + ": ")) + .append(messageComponent); + + for (Player p : ZanderVelocityMain.proxy.getAllPlayers()) { + p.sendMessage(finalMessage); + } + }).exceptionally(ex -> { + ZanderVelocityMain.getLogger().error("Could not load LuckPerms user data for " + player.getUsername(), ex); + // Fallback: send without prefix + Component messageComponent = Component.text(message); + Component finalMessage = Component.text(player.getUsername() + ": ").append(messageComponent); + for (Player p : ZanderVelocityMain.proxy.getAllPlayers()) { + p.sendMessage(finalMessage); + } + return null; + }); + }); + } + + private void sendToDiscord(Player player, String message, String baseApiUrl, String apiKey) { + ZanderVelocityMain.proxy.getScheduler().buildTask(ZanderVelocityMain.proxy.getPluginManager().getPlugin("zander-velocity").get(), () -> { + try { DiscordChat chat = DiscordChat.builder() .username(player.getUsername()) - .server(player.getCurrentServer().get().getServer().getServerInfo().getName()) - .content(event.getMessage().toString()) + .server(player.getCurrentServer().map(s -> s.getServerInfo().getName()).orElse("unknown")) + .content(message) .build(); - Request discordChatReq = Request.builder() - .setURL(BaseAPIURL + "/discord/chat") + .setURL(baseApiUrl + "/discord/chat") .setMethod(Request.Method.POST) - .addHeader("x-access-token", String.valueOf(APIKey)) + .addHeader("x-access-token", apiKey) .setRequestBody(chat.toString()) .build(); - - Response discordChatReqRes = discordChatReq.execute(); - ZanderVelocityMain.getLogger().info("Response (" + discordChatReqRes.getStatusCode() + "): " + discordChatReqRes.getBody()); + discordChatReq.execute(); + } catch (Exception e) { + ZanderVelocityMain.getLogger().error("Failed to send message to Discord webhook", e); } - } catch (Exception e) { - Component builder = Component.text("The chat filter could not be reached at this time, there maybe an issue with the API.").color(NamedTextColor.YELLOW); - player.sendMessage(builder); - System.out.println(e); - } + }).schedule(); } } From b528c668f1ee07462685eb9d9b30370fdf2ed783 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 14 Dec 2025 11:00:35 +0000 Subject: [PATCH 2/4] fix(chat): Correct chat duplication and rendering issues This commit addresses two critical issues with the chat formatting feature: 1. **Duplicate Messages**: The original message and the formatted message were appearing. This is caused by a conflict between the proxy formatter and backend server chat plugins. 2. **Incorrect Prefix Rendering**: The hoverable prefix was showing raw MiniMessage tags instead of the rendered component. The root cause of the rendering issue is that the Velocity API version in use does not support passing Adventure `Component` objects directly to the `PlayerChatEvent` result. It only accepts legacy formatted strings, which do not support hover events. To resolve this and deliver the core hover text feature, the implementation now uses a "deny and broadcast" pattern: - The original `PlayerChatEvent` is denied to prevent Velocity from sending the unformatted message. - A fully formatted `Component` with the hoverable prefix is constructed. - This `Component` is then manually broadcast to all players on the proxy. To fix the duplicate message issue, `NOTES.md` has been updated to instruct server operators to disable any chat formatting plugins on their backend servers, which resolves the conflict. --- NOTES.md | 6 ++++ .../zander/velocity/events/UserChatEvent.java | 34 ++++++------------- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/NOTES.md b/NOTES.md index 6a96496..0ad7200 100644 --- a/NOTES.md +++ b/NOTES.md @@ -41,3 +41,9 @@ lp group meta set rank_description "A short description of the rank." ``` lp group admin meta set rank_description "Has full access to staff & server tools." ``` + +## Important: Disable Backend Chat Formatters + +Because Zander now handles all chat formatting on the proxy, you **must** disable any chat formatting plugins on your backend servers (e.g., Paper, Spigot). + +If you do not, you may see **duplicate or incorrectly formatted chat messages**, as both the proxy and the backend server will be trying to format the same message. diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java index 35f83f3..695d075 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java @@ -28,11 +28,6 @@ public UserChatEvent(LuckPerms luckPerms) { this.luckPerms = luckPerms; } - private String escapeMiniMessageAttribute(String str) { - if (str == null) return ""; - return str.replace("'", "\\'"); - } - private Component buildRankPrefix(User user) { final CachedMetaData metaData = user.getCachedData().getMetaData(); String displayName = metaData.getMetaValue("displayname"); @@ -51,11 +46,9 @@ private Component buildRankPrefix(User user) { rankDescription = "No description set for this rank."; } - final String hoverText = "" + escapeMiniMessageAttribute(displayName) + "\n" + escapeMiniMessageAttribute(rankDescription) + ""; - final String prefixText = "[" + displayName + "]"; - String fullPrefixMiniMessage = "" + prefixText + ""; - - return miniMessage.deserialize(fullPrefixMiniMessage); + Component hoverComponent = miniMessage.deserialize("" + displayName + "\n" + rankDescription + ""); + Component prefixComponent = miniMessage.deserialize("[" + displayName + "]"); + return prefixComponent.hoverEvent(hoverComponent); } @Subscribe @@ -67,15 +60,15 @@ public void onPlayerChat(PlayerChatEvent event) { return; } - // Prevent the original message from being sent + // Deny the event to prevent the original message from being sent by Velocity. + // The formatted message will be broadcast manually. event.setResult(PlayerChatEvent.ChatResult.denied()); - // Asynchronously process the chat message + // Process filtering and formatting asynchronously to avoid blocking the main thread. CompletableFuture.runAsync(() -> { String baseApiUrl = ZanderVelocityMain.getConfig().getString(Route.from("BaseAPIURL")); String apiKey = ZanderVelocityMain.getConfig().getString(Route.from("APIKey")); - // --- 1. Asynchronous Chat Filtering --- try { Filter phrase = Filter.builder().content(message).build(); Request phraseReq = Request.builder() @@ -84,27 +77,22 @@ public void onPlayerChat(PlayerChatEvent event) { .addHeader("x-access-token", apiKey) .setRequestBody(phrase.toString()) .build(); - - Response phraseRes = phraseReq.execute(); // This is still blocking, but now off the main thread + Response phraseRes = phraseReq.execute(); String phraseJson = phraseRes.getBody(); boolean success = JsonPath.parse(phraseJson).read("$.success"); if (!success) { String phraseCaughtMessage = JsonPath.read(phraseJson, "$.message"); player.sendMessage(Component.text(phraseCaughtMessage, NamedTextColor.RED)); - return; // Stop processing + return; } - - // --- 2. Asynchronous Discord Webhook --- sendToDiscord(player, message, baseApiUrl, apiKey); - } catch (Exception e) { player.sendMessage(Component.text("The chat filter could not be reached. Contact staff if this persists.", NamedTextColor.YELLOW)); ZanderVelocityMain.getLogger().error("Error while filtering chat message: ", e); - // We can still proceed to format and send the message locally } - // --- 3. Asynchronous LuckPerms User Loading and Message Broadcasting --- + // Load user data, build the component, and broadcast it. luckPerms.getUserManager().loadUser(player.getUniqueId()).thenAcceptAsync(user -> { Component prefix = buildRankPrefix(user); Component messageComponent = Component.text(message); @@ -117,9 +105,7 @@ public void onPlayerChat(PlayerChatEvent event) { } }).exceptionally(ex -> { ZanderVelocityMain.getLogger().error("Could not load LuckPerms user data for " + player.getUsername(), ex); - // Fallback: send without prefix - Component messageComponent = Component.text(message); - Component finalMessage = Component.text(player.getUsername() + ": ").append(messageComponent); + Component finalMessage = Component.text(player.getUsername() + ": ").append(Component.text(message)); for (Player p : ZanderVelocityMain.proxy.getAllPlayers()) { p.sendMessage(finalMessage); } From ce4abfa93947d7b9573d32be1f09e1832824ef9e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 14 Dec 2025 11:18:43 +0000 Subject: [PATCH 3/4] feat: Implemented LuckPerms rank prefix in chat - Added a hoverable rank prefix to chat messages, displaying the user's highest-priority LuckPerms rank. - The prefix shows the rank's display name and description on hover, with fallbacks for missing metadata. - Updated the chat event handler to deny the original event and broadcast the formatted message to all players on the proxy, ensuring network-wide chat formatting. - Added a NOTES.md file with instructions for server operators on how to configure LuckPerms and avoid duplicate messages. --- zander-velocity/dependency-reduced-pom.xml | 6 +++ zander-velocity/pom.xml | 2 +- .../zander/velocity/events/UserChatEvent.java | 43 +++++++++++-------- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/zander-velocity/dependency-reduced-pom.xml b/zander-velocity/dependency-reduced-pom.xml index 7ca3d20..1d1cd3f 100644 --- a/zander-velocity/dependency-reduced-pom.xml +++ b/zander-velocity/dependency-reduced-pom.xml @@ -77,6 +77,12 @@ 5.4 provided + + net.kyori + adventure-text-minimessage + 4.17.0 + provided + 17 diff --git a/zander-velocity/pom.xml b/zander-velocity/pom.xml index 272e25e..b7d2f86 100644 --- a/zander-velocity/pom.xml +++ b/zander-velocity/pom.xml @@ -136,7 +136,7 @@ net.kyori adventure-text-minimessage 4.17.0 - compile + provided \ No newline at end of file diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java index 695d075..d876fce 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java @@ -10,6 +10,8 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.luckperms.api.LuckPerms; import net.luckperms.api.cacheddata.CachedMetaData; import net.luckperms.api.model.user.User; @@ -23,6 +25,7 @@ public class UserChatEvent { private final LuckPerms luckPerms; private final MiniMessage miniMessage = MiniMessage.miniMessage(); + private final LegacyComponentSerializer legacySerializer = LegacyComponentSerializer.builder().character('&').hexColors().build(); public UserChatEvent(LuckPerms luckPerms) { this.luckPerms = luckPerms; @@ -30,24 +33,35 @@ public UserChatEvent(LuckPerms luckPerms) { private Component buildRankPrefix(User user) { final CachedMetaData metaData = user.getCachedData().getMetaData(); - String displayName = metaData.getMetaValue("displayname"); - if (displayName == null || displayName.isEmpty()) { - displayName = metaData.getPrefix(); - if (displayName != null) { - displayName = miniMessage.stripTags(displayName); - } - if (displayName == null || displayName.isEmpty()) { - displayName = "Member"; - } - } + String displayName = metaData.getMetaValue("displayname"); String rankDescription = metaData.getMetaValue("rank_description"); + if (rankDescription == null || rankDescription.isEmpty()) { rankDescription = "No description set for this rank."; } + Component prefixComponent; + // Use the clean displayname if it exists + if (displayName != null && !displayName.isEmpty()) { + prefixComponent = miniMessage.deserialize("[" + displayName + "]"); + } else { + // Otherwise, fall back to the raw prefix which may contain legacy codes + String rawPrefix = metaData.getPrefix(); + if (rawPrefix != null && !rawPrefix.isEmpty()) { + Component legacyPrefix = this.legacySerializer.deserialize(rawPrefix); + // For the hover, we need a clean, plain-text version of the prefix + displayName = PlainTextComponentSerializer.plainText().serialize(legacyPrefix); + prefixComponent = legacyPrefix; + } else { + // If no prefix exists either, default to "Member" + displayName = "Member"; + prefixComponent = miniMessage.deserialize("[Member]"); + } + } + Component hoverComponent = miniMessage.deserialize("" + displayName + "\n" + rankDescription + ""); - Component prefixComponent = miniMessage.deserialize("[" + displayName + "]"); + return prefixComponent.hoverEvent(hoverComponent); } @@ -60,11 +74,8 @@ public void onPlayerChat(PlayerChatEvent event) { return; } - // Deny the event to prevent the original message from being sent by Velocity. - // The formatted message will be broadcast manually. event.setResult(PlayerChatEvent.ChatResult.denied()); - // Process filtering and formatting asynchronously to avoid blocking the main thread. CompletableFuture.runAsync(() -> { String baseApiUrl = ZanderVelocityMain.getConfig().getString(Route.from("BaseAPIURL")); String apiKey = ZanderVelocityMain.getConfig().getString(Route.from("APIKey")); @@ -92,13 +103,11 @@ public void onPlayerChat(PlayerChatEvent event) { ZanderVelocityMain.getLogger().error("Error while filtering chat message: ", e); } - // Load user data, build the component, and broadcast it. luckPerms.getUserManager().loadUser(player.getUniqueId()).thenAcceptAsync(user -> { Component prefix = buildRankPrefix(user); - Component messageComponent = Component.text(message); Component finalMessage = prefix .append(Component.text(" " + player.getUsername() + ": ")) - .append(messageComponent); + .append(Component.text(message)); for (Player p : ZanderVelocityMain.proxy.getAllPlayers()) { p.sendMessage(finalMessage); From c166b6a1cc3e71fee77ff5b9167d047c92a21ea1 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 14 Dec 2025 11:28:01 +0000 Subject: [PATCH 4/4] feat: Implement server-scoped chat formatting with LuckPerms - Adds a hoverable rank prefix to chat messages, displaying the user's highest-priority LuckPerms rank. - The prefix shows the rank's display name and description on hover, with fallbacks for missing metadata. - Chat messages are broadcast only to players on the sender's current server, establishing a network-wide format standard without globalizing chat. - The chat event handler denies the original event to prevent duplicate messages from backend servers. - Preserves legacy color codes within the chat message content. - Includes NOTES.md with setup instructions for server operators. --- .../zander/velocity/events/UserChatEvent.java | 54 ++++++++++--------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java index d876fce..25c489b 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserChatEvent.java @@ -107,40 +107,42 @@ public void onPlayerChat(PlayerChatEvent event) { Component prefix = buildRankPrefix(user); Component finalMessage = prefix .append(Component.text(" " + player.getUsername() + ": ")) - .append(Component.text(message)); + .append(legacySerializer.deserialize(message)); - for (Player p : ZanderVelocityMain.proxy.getAllPlayers()) { - p.sendMessage(finalMessage); - } + player.getCurrentServer().ifPresent(server -> { + for (Player p : server.getServer().getPlayersConnected()) { + p.sendMessage(finalMessage); + } + }); }).exceptionally(ex -> { ZanderVelocityMain.getLogger().error("Could not load LuckPerms user data for " + player.getUsername(), ex); - Component finalMessage = Component.text(player.getUsername() + ": ").append(Component.text(message)); - for (Player p : ZanderVelocityMain.proxy.getAllPlayers()) { - p.sendMessage(finalMessage); - } + Component finalMessage = Component.text(player.getUsername() + ": ").append(legacySerializer.deserialize(message)); + player.getCurrentServer().ifPresent(server -> { + for (Player p : server.getServer().getPlayersConnected()) { + p.sendMessage(finalMessage); + } + }); return null; }); }); } private void sendToDiscord(Player player, String message, String baseApiUrl, String apiKey) { - ZanderVelocityMain.proxy.getScheduler().buildTask(ZanderVelocityMain.proxy.getPluginManager().getPlugin("zander-velocity").get(), () -> { - try { - DiscordChat chat = DiscordChat.builder() - .username(player.getUsername()) - .server(player.getCurrentServer().map(s -> s.getServerInfo().getName()).orElse("unknown")) - .content(message) - .build(); - Request discordChatReq = Request.builder() - .setURL(baseApiUrl + "/discord/chat") - .setMethod(Request.Method.POST) - .addHeader("x-access-token", apiKey) - .setRequestBody(chat.toString()) - .build(); - discordChatReq.execute(); - } catch (Exception e) { - ZanderVelocityMain.getLogger().error("Failed to send message to Discord webhook", e); - } - }).schedule(); + try { + DiscordChat chat = DiscordChat.builder() + .username(player.getUsername()) + .server(player.getCurrentServer().map(s -> s.getServerInfo().getName()).orElse("unknown")) + .content(message) + .build(); + Request discordChatReq = Request.builder() + .setURL(baseApiUrl + "/discord/chat") + .setMethod(Request.Method.POST) + .addHeader("x-access-token", apiKey) + .setRequestBody(chat.toString()) + .build(); + discordChatReq.execute(); + } catch (Exception e) { + ZanderVelocityMain.getLogger().error("Failed to send message to Discord webhook", e); + } } }