Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions NOTES.md
Original file line number Diff line number Diff line change
@@ -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 `<group>` with the name of the LuckPerms group you want to modify.

### Set Display Name

```
lp group <group> meta set displayname "Rank Name"
```

**Example:**

```
lp group admin meta set displayname "Admin"
```

### Set Rank Description

```
lp group <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.
12 changes: 12 additions & 0 deletions zander-velocity/dependency-reduced-pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@
<version>3.4.0-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>net.luckperms</groupId>
<artifactId>api</artifactId>
<version>5.4</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>net.kyori</groupId>
<artifactId>adventure-text-minimessage</artifactId>
<version>4.17.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
<properties>
<maven.compiler.target>17</maven.compiler.target>
Expand Down
12 changes: 12 additions & 0 deletions zander-velocity/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -126,5 +126,17 @@
<artifactId>boosted-yaml</artifactId>
<version>1.3.1</version>
</dependency>
<dependency>
<groupId>net.luckperms</groupId>
<artifactId>api</artifactId>
<version>5.4</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>net.kyori</groupId>
<artifactId>adventure-text-minimessage</artifactId>
<version>4.17.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("<dark_gray>[</dark_gray><yellow>" + displayName + "</yellow><dark_gray>]");
} 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("<dark_gray>[</dark_gray><yellow>Member</yellow><dark_gray>]");
}
}

ZanderVelocityMain.getLogger().info("[FILTER] Response (" + phraseRes.getStatusCode() + "): " + phraseRes.getBody());
Component hoverComponent = miniMessage.deserialize("<gold>" + displayName + "</gold>\n<gray>" + rankDescription + "</gray>");

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);
}
}
}