diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..0ad7200 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,49 @@ +# 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." +``` + +## 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/dependency-reduced-pom.xml b/zander-velocity/dependency-reduced-pom.xml index fb7ee13..1d1cd3f 100644 --- a/zander-velocity/dependency-reduced-pom.xml +++ b/zander-velocity/dependency-reduced-pom.xml @@ -71,6 +71,18 @@ 3.4.0-SNAPSHOT provided + + net.luckperms + api + 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 8b37212..b7d2f86 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 + provided + \ 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..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 @@ -9,67 +9,140 @@ 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.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; 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 { - @Subscribe - public void UserChatEvent(PlayerChatEvent event) { - Player player = event.getPlayer(); - String BaseAPIURL = ZanderVelocityMain.getConfig().getString(Route.from("BaseAPIURL")); - String APIKey = ZanderVelocityMain.getConfig().getString(Route.from("APIKey")); + private final LuckPerms luckPerms; + private final MiniMessage miniMessage = MiniMessage.miniMessage(); + private final LegacyComponentSerializer legacySerializer = LegacyComponentSerializer.builder().character('&').hexColors().build(); - // Filter out commands. - if (event.getMessage().startsWith("/")) return; + public UserChatEvent(LuckPerms luckPerms) { + this.luckPerms = luckPerms; + } - // Check chat for blocked content - try { - Filter phrase = Filter.builder() - .content(event.getMessage().toString()) - .build(); + private Component buildRankPrefix(User user) { + final CachedMetaData metaData = user.getCachedData().getMetaData(); - Request phraseReq = Request.builder() - .setURL(BaseAPIURL + "/filter") - .setMethod(Request.Method.POST) - .addHeader("x-access-token", APIKey) - .setRequestBody(phrase.toString()) - .build(); + String displayName = metaData.getMetaValue("displayname"); + String rankDescription = metaData.getMetaValue("rank_description"); - Response phraseRes = phraseReq.execute(); - String phraseJson = phraseRes.getBody(); + if (rankDescription == null || rankDescription.isEmpty()) { + rankDescription = "No description set for this rank."; + } - Boolean success = JsonPath.parse(phraseJson).read("$.success"); - String phraseCaughtMessage = JsonPath.read(phraseJson, "$.message"); + 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]"); + } + } - ZanderVelocityMain.getLogger().info("[FILTER] Response (" + phraseRes.getStatusCode() + "): " + phraseRes.getBody()); + Component hoverComponent = miniMessage.deserialize("" + displayName + "\n" + rankDescription + ""); - if (!success) { - Component builder = Component.text(phraseCaughtMessage).color(NamedTextColor.RED); - player.sendMessage(builder); - event.setResult(PlayerChatEvent.ChatResult.denied()); - } else { - DiscordChat chat = DiscordChat.builder() - .username(player.getUsername()) - .server(player.getCurrentServer().get().getServer().getServerInfo().getName()) - .content(event.getMessage().toString()) - .build(); + return prefixComponent.hoverEvent(hoverComponent); + } - Request discordChatReq = Request.builder() - .setURL(BaseAPIURL + "/discord/chat") + @Subscribe + public void onPlayerChat(PlayerChatEvent event) { + Player player = event.getPlayer(); + String message = event.getMessage(); + + if (message.startsWith("/")) { + return; + } + + event.setResult(PlayerChatEvent.ChatResult.denied()); + + CompletableFuture.runAsync(() -> { + String baseApiUrl = ZanderVelocityMain.getConfig().getString(Route.from("BaseAPIURL")); + String apiKey = ZanderVelocityMain.getConfig().getString(Route.from("APIKey")); + + try { + Filter phrase = Filter.builder().content(message).build(); + Request phraseReq = Request.builder() + .setURL(baseApiUrl + "/filter") .setMethod(Request.Method.POST) - .addHeader("x-access-token", String.valueOf(APIKey)) - .setRequestBody(chat.toString()) + .addHeader("x-access-token", apiKey) + .setRequestBody(phrase.toString()) .build(); + Response phraseRes = phraseReq.execute(); + String phraseJson = phraseRes.getBody(); + boolean success = JsonPath.parse(phraseJson).read("$.success"); - Response discordChatReqRes = discordChatReq.execute(); - ZanderVelocityMain.getLogger().info("Response (" + discordChatReqRes.getStatusCode() + "): " + discordChatReqRes.getBody()); + if (!success) { + String phraseCaughtMessage = JsonPath.read(phraseJson, "$.message"); + player.sendMessage(Component.text(phraseCaughtMessage, NamedTextColor.RED)); + return; + } + 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); } + + luckPerms.getUserManager().loadUser(player.getUniqueId()).thenAcceptAsync(user -> { + Component prefix = buildRankPrefix(user); + Component finalMessage = prefix + .append(Component.text(" " + player.getUsername() + ": ")) + .append(legacySerializer.deserialize(message)); + + 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(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) { + 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) { - 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); + ZanderVelocityMain.getLogger().error("Failed to send message to Discord webhook", e); } } }