diff --git a/README.md b/README.md index fef1ab8..284c534 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ # zander -Documentation: [https://modularsoft.org/docs/products/zander](https://modularsoft.org/docs/products/zander) \ No newline at end of file +Documentation: [https://modularsoft.org/docs/products/zander](https://modularsoft.org/docs/products/zander) + +Product docs: +- [Private messaging (Zander Velocity)](docs/private-messaging.md) diff --git a/docs/private-messaging.md b/docs/private-messaging.md new file mode 100644 index 0000000..6eed037 --- /dev/null +++ b/docs/private-messaging.md @@ -0,0 +1,85 @@ +# Private Messaging (Zander Velocity) + +Zander Velocity provides a private messaging system with direct messages, reply shortcuts, ignore lists, and per-player message toggles. + +## Commands + +### /message + +Aliases: `m`, `msg`, `w`, `whisper`, `tell`, `t` +Permission: `zander.command.message` + +Usage: + +``` +/msg +/tell +``` + +Behavior: + +- Sends a private message from the sender to the target player. +- Blocks messaging yourself. +- Rejects offline or unknown targets. +- Respects the target's `/togglemessages` preference and ignore list. +- Updates the reply mapping for both players when a message is sent. + +Sender sees: `To : ` +Target sees: `From : ` + +### /reply + +Alias: `r` +Permission: `zander.command.reply` + +Usage: + +``` +/r +``` + +Behavior: + +- Sends a private message to the last player you messaged (or who messaged you). +- If no target exists, returns `No one to reply to.` +- Re-checks all message rules (online, toggle, ignore). +- Clears the reply target when the target is offline. + +### /ignore + +Aliases: `ignores` +Base permission: `zander.command.ignore` + +Subcommands: + +``` +/ignore add # permission: zander.command.ignore.add +/ignore remove # permission: zander.command.ignore.remove +/ignore list # permission: zander.command.ignore.list +``` + +Behavior: + +- Ignore lists store UUIDs and persist across restarts. +- Prevents ignoring yourself and duplicate entries. +- `/ignore list` shows up to 10 entries and includes a summary if more exist. +- If player B ignores player A, player A cannot message or reply to player B. + +### /togglemessages + +Alias: `toggle-messages` +Permission: `zander.command.togglemessages` + +Behavior: + +- Toggles whether you can receive private messages. +- When disabled, inbound `/message` and `/reply` attempts are blocked. +- Outbound messaging remains allowed by default. + +## Data Storage + +- Persistent: + - `messagesDisabled[uuid]` (boolean) + - `ignoreList[uuid]` (set of UUIDs) +- In-memory (resets on restart): + - `lastConversation[uuid]` (UUID) 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..824f1b9 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 @@ -26,6 +26,7 @@ import org.modularsoft.zander.velocity.events.session.UserOnSwitch; import org.modularsoft.zander.velocity.util.announcement.TipChatter; import org.modularsoft.zander.velocity.util.api.Heartbeat; +import org.modularsoft.zander.velocity.util.messaging.PrivateMessageService; import org.slf4j.Logger; import java.io.File; @@ -52,6 +53,10 @@ public class ZanderVelocityMain { @Getter private static YamlDocument config; @Getter + private static Path dataDirectory; + @Getter + private static PrivateMessageService privateMessageService; + @Getter private final CommandManager commandManager; @Subscribe @@ -76,6 +81,26 @@ public void onProxyInitialization(ProxyInitializeEvent event) { commandManager.register(commandManager.metaBuilder("report").build(), new report()); commandManager.register(commandManager.metaBuilder("clearchat").build(), new clearchat()); commandManager.register(commandManager.metaBuilder("freezechat").build(), new freezechat()); + message messageCommand = new message(); + commandManager.register(commandManager.metaBuilder("message").build(), messageCommand); + commandManager.register(commandManager.metaBuilder("m").build(), messageCommand); + commandManager.register(commandManager.metaBuilder("msg").build(), messageCommand); + commandManager.register(commandManager.metaBuilder("w").build(), messageCommand); + commandManager.register(commandManager.metaBuilder("whisper").build(), messageCommand); + commandManager.register(commandManager.metaBuilder("tell").build(), messageCommand); + commandManager.register(commandManager.metaBuilder("t").build(), messageCommand); + + reply replyCommand = new reply(); + commandManager.register(commandManager.metaBuilder("reply").build(), replyCommand); + commandManager.register(commandManager.metaBuilder("r").build(), replyCommand); + + ignore ignoreCommand = new ignore(); + commandManager.register(commandManager.metaBuilder("ignore").build(), ignoreCommand); + commandManager.register(commandManager.metaBuilder("ignores").build(), ignoreCommand); + + togglemessages toggleMessagesCommand = new togglemessages(); + commandManager.register(commandManager.metaBuilder("togglemessages").build(), toggleMessagesCommand); + commandManager.register(commandManager.metaBuilder("toggle-messages").build(), toggleMessagesCommand); // Start the Heartbeat task Heartbeat.startHeartbeatTask(); @@ -94,6 +119,7 @@ public ZanderVelocityMain( this.proxy = proxy; this.logger = logger; this.commandManager = commandManager; + this.dataDirectory = dataDirectory; // Create configuration file try { @@ -114,5 +140,6 @@ public ZanderVelocityMain( } logger.info("Zander Proxy has started."); + privateMessageService = new PrivateMessageService(dataDirectory, logger); } } diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/commands/ignore.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/commands/ignore.java new file mode 100644 index 0000000..03f1545 --- /dev/null +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/commands/ignore.java @@ -0,0 +1,159 @@ +package org.modularsoft.zander.velocity.commands; + +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.SimpleCommand; +import com.velocitypowered.api.proxy.Player; +import lombok.NonNull; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.modularsoft.zander.velocity.ZanderVelocityMain; +import org.modularsoft.zander.velocity.util.messaging.PrivateMessageService; +import org.modularsoft.zander.velocity.util.messaging.VanishStatusResolver; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import java.util.Set; +import java.util.UUID; + +public class ignore implements SimpleCommand { + + private static final String BASE_PERMISSION = "zander.command.ignore"; + private static final String ADD_PERMISSION = "zander.command.ignore.add"; + private static final String REMOVE_PERMISSION = "zander.command.ignore.remove"; + private static final String LIST_PERMISSION = "zander.command.ignore.list"; + private static final int MAX_LIST_SIZE = 10; + private final PrivateMessageService messageService = ZanderVelocityMain.getPrivateMessageService(); + + @Override + public void execute(@NonNull Invocation invocation) { + CommandSource source = invocation.source(); + if (!(source instanceof Player player)) { + source.sendMessage(Component.text("This command can only be used by players.").color(NamedTextColor.RED)); + return; + } + if (!player.hasPermission(BASE_PERMISSION)) { + player.sendMessage(Component.text("You do not have permission to use this command.").color(NamedTextColor.RED)); + return; + } + String[] args = invocation.arguments(); + if (args.length == 0) { + player.sendMessage(Component.text("Usage: /ignore [player]").color(NamedTextColor.RED)); + return; + } + + String subcommand = args[0].toLowerCase(); + switch (subcommand) { + case "add" -> handleAdd(player, args); + case "remove" -> handleRemove(player, args); + case "list" -> handleList(player); + default -> player.sendMessage(Component.text("Usage: /ignore [player]").color(NamedTextColor.RED)); + } + } + + @Override + public List suggest(@NonNull Invocation invocation) { + String[] args = invocation.arguments(); + if (args.length <= 1) { + String prefix = args.length == 1 ? args[0].toLowerCase() : ""; + return Stream.of("add", "remove", "list") + .filter(option -> option.startsWith(prefix)) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } + if (args.length == 2 && ("add".equalsIgnoreCase(args[0]) || "remove".equalsIgnoreCase(args[0]))) { + String prefix = args[1].toLowerCase(); + return ZanderVelocityMain.getProxy().getAllPlayers().stream() + .filter(player -> !VanishStatusResolver.isVanished(player)) + .map(Player::getUsername) + .filter(name -> name.toLowerCase().startsWith(prefix)) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } + return List.of(); + } + + private void handleAdd(Player player, String[] args) { + if (!player.hasPermission(ADD_PERMISSION)) { + player.sendMessage(Component.text("You do not have permission to use this command.").color(NamedTextColor.RED)); + return; + } + if (args.length < 2) { + player.sendMessage(Component.text("Usage: /ignore add ").color(NamedTextColor.RED)); + return; + } + String targetName = args[1]; + if (targetName.equalsIgnoreCase(player.getUsername())) { + player.sendMessage(Component.text("You cannot ignore yourself.").color(NamedTextColor.RED)); + return; + } + Optional targetUuid = messageService.resolveUuid(targetName, ZanderVelocityMain.getProxy()); + if (targetUuid.isEmpty()) { + player.sendMessage(Component.text("Player not found.").color(NamedTextColor.RED)); + return; + } + if (targetUuid.get().equals(player.getUniqueId())) { + player.sendMessage(Component.text("You cannot ignore yourself.").color(NamedTextColor.RED)); + return; + } + boolean added = messageService.addIgnore(player.getUniqueId(), targetUuid.get()); + String displayName = messageService.getCachedName(targetUuid.get()).orElse(targetName); + if (!added) { + player.sendMessage(Component.text("You are already ignoring " + displayName + ".").color(NamedTextColor.YELLOW)); + return; + } + player.sendMessage(Component.text("You are now ignoring " + displayName + ".").color(NamedTextColor.GREEN)); + } + + private void handleRemove(Player player, String[] args) { + if (!player.hasPermission(REMOVE_PERMISSION)) { + player.sendMessage(Component.text("You do not have permission to use this command.").color(NamedTextColor.RED)); + return; + } + if (args.length < 2) { + player.sendMessage(Component.text("Usage: /ignore remove ").color(NamedTextColor.RED)); + return; + } + String targetName = args[1]; + Optional targetUuid = messageService.resolveUuid(targetName, ZanderVelocityMain.getProxy()); + if (targetUuid.isEmpty()) { + player.sendMessage(Component.text("Player not found.").color(NamedTextColor.RED)); + return; + } + boolean removed = messageService.removeIgnore(player.getUniqueId(), targetUuid.get()); + String displayName = messageService.getCachedName(targetUuid.get()).orElse(targetName); + if (!removed) { + player.sendMessage(Component.text(displayName + " is not on your ignore list.").color(NamedTextColor.YELLOW)); + return; + } + player.sendMessage(Component.text("You are no longer ignoring " + displayName + ".").color(NamedTextColor.GREEN)); + } + + private void handleList(Player player) { + if (!player.hasPermission(LIST_PERMISSION)) { + player.sendMessage(Component.text("You do not have permission to use this command.").color(NamedTextColor.RED)); + return; + } + Set ignores = messageService.getIgnoreList(player.getUniqueId()); + if (ignores.isEmpty()) { + player.sendMessage(Component.text("You are not ignoring anyone.").color(NamedTextColor.YELLOW)); + return; + } + List names = new ArrayList<>(); + for (UUID uuid : ignores) { + names.add(messageService.getCachedName(uuid).orElse(uuid.toString())); + } + names.sort(Comparator.comparing(String::toLowerCase)); + int total = names.size(); + List displayNames = names.subList(0, Math.min(MAX_LIST_SIZE, total)); + StringBuilder builder = new StringBuilder(); + builder.append("Ignored players (").append(total).append("): ") + .append(String.join(", ", displayNames)); + if (total > MAX_LIST_SIZE) { + builder.append(" and ").append(total - MAX_LIST_SIZE).append(" more..."); + } + player.sendMessage(Component.text(builder.toString()).color(NamedTextColor.GRAY)); + } +} diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/commands/message.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/commands/message.java new file mode 100644 index 0000000..6f87539 --- /dev/null +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/commands/message.java @@ -0,0 +1,105 @@ +package org.modularsoft.zander.velocity.commands; + +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.SimpleCommand; +import com.velocitypowered.api.proxy.Player; +import lombok.NonNull; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.modularsoft.zander.velocity.ZanderVelocityMain; +import org.modularsoft.zander.velocity.util.messaging.MessageDisplayNameResolver; +import org.modularsoft.zander.velocity.util.messaging.PrivateMessageService; +import org.modularsoft.zander.velocity.util.messaging.VanishStatusResolver; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +public class message implements SimpleCommand { + + private static final String PERMISSION = "zander.command.message"; + private final PrivateMessageService messageService = ZanderVelocityMain.getPrivateMessageService(); + + @Override + public void execute(@NonNull Invocation invocation) { + CommandSource source = invocation.source(); + if (!(source instanceof Player player)) { + source.sendMessage(Component.text("This command can only be used by players.").color(NamedTextColor.RED)); + return; + } + if (!player.hasPermission(PERMISSION)) { + player.sendMessage(Component.text("You do not have permission to use this command.").color(NamedTextColor.RED)); + return; + } + String[] args = invocation.arguments(); + if (args.length < 2) { + player.sendMessage(Component.text("Usage: /msg ").color(NamedTextColor.RED)); + return; + } + String targetName = args[0]; + if (targetName.equalsIgnoreCase(player.getUsername())) { + player.sendMessage(Component.text("You cannot message yourself.").color(NamedTextColor.RED)); + return; + } + Optional targetOpt = findOnlinePlayer(targetName); + if (targetOpt.isEmpty()) { + player.sendMessage(Component.text("That player is offline or not found.").color(NamedTextColor.RED)); + return; + } + Player target = targetOpt.get(); + if (target.getUniqueId().equals(player.getUniqueId())) { + player.sendMessage(Component.text("You cannot message yourself.").color(NamedTextColor.RED)); + return; + } + if (messageService.isMessagesDisabled(target.getUniqueId())) { + player.sendMessage(Component.text("That player is not accepting private messages.").color(NamedTextColor.RED)); + return; + } + if (messageService.isIgnoring(target.getUniqueId(), player.getUniqueId())) { + player.sendMessage(Component.text("You cannot message that player.").color(NamedTextColor.RED)); + return; + } + + String message = String.join(" ", Arrays.copyOfRange(args, 1, args.length)); + Component senderMessage = Component.text("To ").color(NamedTextColor.GRAY) + .append(MessageDisplayNameResolver.resolve(target)) + .append(Component.text(": ").color(NamedTextColor.GRAY)) + .append(Component.text(message).color(NamedTextColor.WHITE)); + Component targetMessage = Component.text("From ").color(NamedTextColor.GRAY) + .append(MessageDisplayNameResolver.resolve(player)) + .append(Component.text(": ").color(NamedTextColor.GRAY)) + .append(Component.text(message).color(NamedTextColor.WHITE)); + + player.sendMessage(senderMessage); + target.sendMessage(targetMessage); + + messageService.updateNameCache(player.getUniqueId(), player.getUsername()); + messageService.updateNameCache(target.getUniqueId(), target.getUsername()); + messageService.setLastConversation(player.getUniqueId(), target.getUniqueId()); + } + + @Override + public List suggest(@NonNull Invocation invocation) { + String[] args = invocation.arguments(); + if (args.length <= 1) { + String prefix = args.length == 1 ? args[0].toLowerCase() : ""; + return ZanderVelocityMain.getProxy().getAllPlayers().stream() + .filter(player -> !VanishStatusResolver.isVanished(player)) + .map(Player::getUsername) + .filter(name -> name.toLowerCase().startsWith(prefix)) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } + return List.of(); + } + + private Optional findOnlinePlayer(String name) { + Optional direct = ZanderVelocityMain.getProxy().getPlayer(name); + if (direct.isPresent()) { + return direct; + } + return ZanderVelocityMain.getProxy().getAllPlayers().stream() + .filter(player -> player.getUsername().equalsIgnoreCase(name)) + .findFirst(); + } +} diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/commands/reply.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/commands/reply.java new file mode 100644 index 0000000..dd06248 --- /dev/null +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/commands/reply.java @@ -0,0 +1,83 @@ +package org.modularsoft.zander.velocity.commands; + +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.SimpleCommand; +import com.velocitypowered.api.proxy.Player; +import lombok.NonNull; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.modularsoft.zander.velocity.ZanderVelocityMain; +import org.modularsoft.zander.velocity.util.messaging.MessageDisplayNameResolver; +import org.modularsoft.zander.velocity.util.messaging.PrivateMessageService; + +import java.util.Arrays; +import java.util.Optional; +import java.util.UUID; + +public class reply implements SimpleCommand { + + private static final String PERMISSION = "zander.command.reply"; + private final PrivateMessageService messageService = ZanderVelocityMain.getPrivateMessageService(); + + @Override + public void execute(@NonNull Invocation invocation) { + CommandSource source = invocation.source(); + if (!(source instanceof Player player)) { + source.sendMessage(Component.text("This command can only be used by players.").color(NamedTextColor.RED)); + return; + } + if (!player.hasPermission(PERMISSION)) { + player.sendMessage(Component.text("You do not have permission to use this command.").color(NamedTextColor.RED)); + return; + } + String[] args = invocation.arguments(); + if (args.length < 1) { + player.sendMessage(Component.text("Usage: /r ").color(NamedTextColor.RED)); + return; + } + + Optional lastTarget = messageService.getLastConversation(player.getUniqueId()); + if (lastTarget.isEmpty()) { + player.sendMessage(Component.text("No one to reply to.").color(NamedTextColor.RED)); + return; + } + + Optional targetOpt = ZanderVelocityMain.getProxy().getPlayer(lastTarget.get()); + if (targetOpt.isEmpty()) { + messageService.clearLastConversation(player.getUniqueId()); + player.sendMessage(Component.text("That player is offline or not found.").color(NamedTextColor.RED)); + return; + } + + Player target = targetOpt.get(); + if (target.getUniqueId().equals(player.getUniqueId())) { + player.sendMessage(Component.text("You cannot message yourself.").color(NamedTextColor.RED)); + return; + } + if (messageService.isMessagesDisabled(target.getUniqueId())) { + player.sendMessage(Component.text("That player is not accepting private messages.").color(NamedTextColor.RED)); + return; + } + if (messageService.isIgnoring(target.getUniqueId(), player.getUniqueId())) { + player.sendMessage(Component.text("You cannot message that player.").color(NamedTextColor.RED)); + return; + } + + String message = String.join(" ", Arrays.copyOfRange(args, 0, args.length)); + Component senderMessage = Component.text("To ").color(NamedTextColor.GRAY) + .append(MessageDisplayNameResolver.resolve(target)) + .append(Component.text(": ").color(NamedTextColor.GRAY)) + .append(Component.text(message).color(NamedTextColor.WHITE)); + Component targetMessage = Component.text("From ").color(NamedTextColor.GRAY) + .append(MessageDisplayNameResolver.resolve(player)) + .append(Component.text(": ").color(NamedTextColor.GRAY)) + .append(Component.text(message).color(NamedTextColor.WHITE)); + + player.sendMessage(senderMessage); + target.sendMessage(targetMessage); + + messageService.updateNameCache(player.getUniqueId(), player.getUsername()); + messageService.updateNameCache(target.getUniqueId(), target.getUsername()); + messageService.setLastConversation(player.getUniqueId(), target.getUniqueId()); + } +} diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/commands/togglemessages.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/commands/togglemessages.java new file mode 100644 index 0000000..564a94f --- /dev/null +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/commands/togglemessages.java @@ -0,0 +1,36 @@ +package org.modularsoft.zander.velocity.commands; + +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.SimpleCommand; +import com.velocitypowered.api.proxy.Player; +import lombok.NonNull; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.modularsoft.zander.velocity.ZanderVelocityMain; +import org.modularsoft.zander.velocity.util.messaging.PrivateMessageService; + +public class togglemessages implements SimpleCommand { + + private static final String PERMISSION = "zander.command.togglemessages"; + private final PrivateMessageService messageService = ZanderVelocityMain.getPrivateMessageService(); + + @Override + public void execute(@NonNull Invocation invocation) { + CommandSource source = invocation.source(); + if (!(source instanceof Player player)) { + source.sendMessage(Component.text("This command can only be used by players.").color(NamedTextColor.RED)); + return; + } + if (!player.hasPermission(PERMISSION)) { + player.sendMessage(Component.text("You do not have permission to use this command.").color(NamedTextColor.RED)); + return; + } + boolean currentlyDisabled = messageService.isMessagesDisabled(player.getUniqueId()); + messageService.setMessagesDisabled(player.getUniqueId(), !currentlyDisabled); + if (currentlyDisabled) { + player.sendMessage(Component.text("Private messages are now enabled.").color(NamedTextColor.GREEN)); + } else { + player.sendMessage(Component.text("Private messages are now disabled.").color(NamedTextColor.YELLOW)); + } + } +} 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..ee8b566 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 @@ -8,26 +8,38 @@ import io.github.ModularEnigma.Request; import io.github.ModularEnigma.Response; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import org.modularsoft.zander.velocity.ZanderVelocityMain; import org.modularsoft.zander.velocity.model.Filter; import org.modularsoft.zander.velocity.model.discord.DiscordChat; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.Optional; + public class UserChatEvent { + private final MiniMessage miniMessage = MiniMessage.miniMessage(); + @Subscribe public void UserChatEvent(PlayerChatEvent event) { Player player = event.getPlayer(); + String rawMessage = event.getMessage(); + Component originalMessage = Component.text(rawMessage); String BaseAPIURL = ZanderVelocityMain.getConfig().getString(Route.from("BaseAPIURL")); String APIKey = ZanderVelocityMain.getConfig().getString(Route.from("APIKey")); // Filter out commands. - if (event.getMessage().startsWith("/")) return; + if (rawMessage.startsWith("/")) return; // Check chat for blocked content try { Filter phrase = Filter.builder() - .content(event.getMessage().toString()) + .content(rawMessage) .build(); Request phraseReq = Request.builder() @@ -49,11 +61,12 @@ public void UserChatEvent(PlayerChatEvent event) { Component builder = Component.text(phraseCaughtMessage).color(NamedTextColor.RED); player.sendMessage(builder); event.setResult(PlayerChatEvent.ChatResult.denied()); + return; } else { DiscordChat chat = DiscordChat.builder() .username(player.getUsername()) .server(player.getCurrentServer().get().getServer().getServerInfo().getName()) - .content(event.getMessage().toString()) + .content(rawMessage) .build(); Request discordChatReq = Request.builder() @@ -66,10 +79,181 @@ public void UserChatEvent(PlayerChatEvent event) { Response discordChatReqRes = discordChatReq.execute(); ZanderVelocityMain.getLogger().info("Response (" + discordChatReqRes.getStatusCode() + "): " + discordChatReqRes.getBody()); } + + Component formattedMessage = formatChatMessage(player, originalMessage); + player.getCurrentServer() + .map(serverConnection -> serverConnection.getServer()) + .ifPresentOrElse( + server -> server.getPlayersConnected().forEach(target -> target.sendMessage(formattedMessage)), + () -> player.sendMessage(formattedMessage) + ); + event.setResult(PlayerChatEvent.ChatResult.denied()); } 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); } } + + private Component formatChatMessage(Player player, Component originalMessage) { + LuckPermsMeta metaData = resolveLuckPermsMeta(player).orElse(null); + Component rankPrefix = buildRankPrefix(metaData); + + return Component.text() + .append(rankPrefix) + .append(Component.space()) + .append(Component.text(player.getUsername())) + .append(Component.text(": ")) + .append(originalMessage) + .build(); + } + + private Component buildRankPrefix(LuckPermsMeta metaData) { + String prefix = metaData != null ? metaData.prefix : null; + String rankNameMeta = getMetaValue(metaData, "displayname"); + String rankDescriptionMeta = getMetaValue(metaData, "rank_description"); + + String rankName = (rankNameMeta != null && !rankNameMeta.isBlank()) + ? rankNameMeta + : (prefix != null && !prefix.isBlank() ? prefix : "Member"); + String rankDescription = (rankDescriptionMeta != null && !rankDescriptionMeta.isBlank()) + ? rankDescriptionMeta + : "No description set for this rank."; + + rankName = stripLegacy(rankName); + rankDescription = stripLegacy(rankDescription); + + Component prefixComponent = buildPrefixComponent(prefix, rankName); + Component hoverText = Component.text() + .append(prefixComponent) + .append(Component.space()) + .append(Component.text(rankName).color(NamedTextColor.GOLD)) + .append(Component.newline()) + .append(Component.text(rankDescription).color(NamedTextColor.GRAY)) + .build(); + + return prefixComponent.hoverEvent(HoverEvent.showText(hoverText)); + } + + private String getMetaValue(LuckPermsMeta metaData, String baseKey) { + if (metaData == null) { + return null; + } + String scopedKey = null; + if (metaData.primaryGroup != null && !metaData.primaryGroup.isBlank()) { + scopedKey = baseKey + "." + metaData.primaryGroup; + } + if (scopedKey != null) { + String scopedValue = metaData.metaValues.get(scopedKey); + if (scopedValue != null && !scopedValue.isBlank()) { + return scopedValue; + } + } + String directValue = metaData.metaValues.get(baseKey); + if (directValue != null && !directValue.isBlank()) { + return directValue; + } + return null; + } + + private Component buildPrefixComponent(String prefix, String rankName) { + if (prefix != null && !prefix.isBlank()) { + return LegacyComponentSerializer.legacyAmpersand().deserialize(prefix); + } + + String miniMessagePrefix = "[" + + escapeMiniMessageContent(rankName) + + "]"; + return miniMessage.deserialize(miniMessagePrefix); + } + + private String escapeMiniMessageContent(String input) { + return input.replace("<", "\\<").replace(">", "\\>"); + } + + private String stripLegacy(String input) { + return input.replaceAll("ยง.", "").replaceAll("&.", ""); + } + + private Optional resolveLuckPermsMeta(Player player) { + try { + Class providerClass = Class.forName("net.luckperms.api.LuckPermsProvider"); + Object luckPerms = providerClass.getMethod("get").invoke(null); + Object playerAdapter = luckPerms.getClass().getMethod("getPlayerAdapter", Class.class) + .invoke(luckPerms, Player.class); + Object user = playerAdapter.getClass().getMethod("getUser", Player.class).invoke(playerAdapter, player); + Object metaData = playerAdapter.getClass().getMethod("getMetaData", Player.class).invoke(playerAdapter, player); + + String prefix = null; + String primaryGroup = null; + Map> metaMap = Map.of(); + + if (user != null) { + Method primaryGroupMethod = user.getClass().getMethod("getPrimaryGroup"); + Object primaryGroupResult = primaryGroupMethod.invoke(user); + if (primaryGroupResult instanceof String) { + primaryGroup = (String) primaryGroupResult; + } + } + + if (metaData != null) { + Method prefixMethod = metaData.getClass().getMethod("getPrefix"); + Object prefixResult = prefixMethod.invoke(metaData); + if (prefixResult instanceof String) { + prefix = (String) prefixResult; + } + Method metaMethod = metaData.getClass().getMethod("getMeta"); + Object metaResult = metaMethod.invoke(metaData); + if (metaResult instanceof Map rawMeta) { + metaMap = castMetaMap(rawMeta); + } + } + + return Optional.of(new LuckPermsMeta(prefix, primaryGroup, flattenMeta(metaMap))); + } catch (ReflectiveOperationException | LinkageError ignored) { + return Optional.empty(); + } + } + + private Map flattenMeta(Map> metaMap) { + Map flattened = new java.util.HashMap<>(); + for (Map.Entry> entry : metaMap.entrySet()) { + List values = entry.getValue(); + if (values == null) { + continue; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + flattened.put(entry.getKey(), value); + break; + } + } + } + return flattened; + } + + @SuppressWarnings("unchecked") + private Map> castMetaMap(Map rawMeta) { + Map> casted = new java.util.HashMap<>(); + for (Map.Entry entry : rawMeta.entrySet()) { + Object key = entry.getKey(); + Object value = entry.getValue(); + if (key instanceof String && value instanceof List listValue) { + casted.put((String) key, (List) listValue); + } + } + return casted; + } + + private static class LuckPermsMeta { + private final String prefix; + private final String primaryGroup; + private final Map metaValues; + + private LuckPermsMeta(String prefix, String primaryGroup, Map metaValues) { + this.prefix = prefix; + this.primaryGroup = primaryGroup; + this.metaValues = metaValues; + } + } } diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserCommandSpyEvent.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserCommandSpyEvent.java index 79311f5..ce33dd5 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserCommandSpyEvent.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserCommandSpyEvent.java @@ -25,9 +25,8 @@ public void onPlayerCommand(CommandExecuteEvent event) { String APIKey = ZanderVelocityMain.getConfig().getString(Route.from("APIKey")); String command = event.getCommand(); // Get the full command - ZanderVelocityMain.getLogger().info("Command: {}", command); - // Check if the command is one we need to log (ignore direct message commands) + ZanderVelocityMain.getLogger().info("Command: {}", command); if (command.startsWith("msg") || command.startsWith("tell") || command.startsWith("w") || command.startsWith("message") || command.startsWith("r")) { return; @@ -58,4 +57,5 @@ public void onPlayerCommand(CommandExecuteEvent event) { System.out.println(e); } } -} \ No newline at end of file + +} diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserSocialSpyEvent.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserSocialSpyEvent.java index 2320caa..780eaeb 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserSocialSpyEvent.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserSocialSpyEvent.java @@ -85,4 +85,5 @@ public void onUserChatDMEvent(CommandExecuteEvent event) { } } } -} \ No newline at end of file + +} diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/session/UserOnLogin.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/session/UserOnLogin.java index 6eea4a9..f80cc34 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/session/UserOnLogin.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/session/UserOnLogin.java @@ -19,6 +19,7 @@ public void UserLoginEvent (PostLoginEvent event) { Player player = event.getPlayer(); String BaseAPIURL = ZanderVelocityMain.getConfig().getString(Route.from("BaseAPIURL")); String APIKey = ZanderVelocityMain.getConfig().getString(Route.from("APIKey")); + ZanderVelocityMain.getPrivateMessageService().updateNameCache(player.getUniqueId(), player.getUsername()); try { // diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/util/messaging/MessageDisplayNameResolver.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/util/messaging/MessageDisplayNameResolver.java new file mode 100644 index 0000000..e9b295e --- /dev/null +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/util/messaging/MessageDisplayNameResolver.java @@ -0,0 +1,61 @@ +package org.modularsoft.zander.velocity.util.messaging; + +import com.velocitypowered.api.proxy.Player; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; + +import java.lang.reflect.Method; +import java.util.UUID; + +public class MessageDisplayNameResolver { + + private MessageDisplayNameResolver() { + } + + public static Component resolve(Player player) { + Component prefix = resolveLuckPermsPrefix(player); + if (prefix != null) { + return prefix.append(Component.text(player.getUsername())); + } + Component displayName = resolveVelocityDisplayName(player); + if (displayName != null) { + return displayName; + } + return Component.text(player.getUsername()); + } + + private static Component resolveVelocityDisplayName(Player player) { + try { + Method method = player.getClass().getMethod("getDisplayName"); + Object result = method.invoke(player); + if (result instanceof Component) { + return (Component) result; + } + } catch (ReflectiveOperationException ignored) { + return null; + } + return null; + } + + private static Component resolveLuckPermsPrefix(Player player) { + try { + Class providerClass = Class.forName("net.luckperms.api.LuckPermsProvider"); + Object luckPerms = providerClass.getMethod("get").invoke(null); + Object userManager = luckPerms.getClass().getMethod("getUserManager").invoke(luckPerms); + UUID uuid = player.getUniqueId(); + Object user = userManager.getClass().getMethod("getUser", UUID.class).invoke(userManager, uuid); + if (user == null) { + return null; + } + Object cachedData = user.getClass().getMethod("getCachedData").invoke(user); + Object metaData = cachedData.getClass().getMethod("getMetaData").invoke(cachedData); + String prefix = (String) metaData.getClass().getMethod("getPrefix").invoke(metaData); + if (prefix == null || prefix.isBlank()) { + return null; + } + return LegacyComponentSerializer.legacyAmpersand().deserialize(prefix); + } catch (ReflectiveOperationException | LinkageError ignored) { + return null; + } + } +} diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/util/messaging/PrivateMessageService.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/util/messaging/PrivateMessageService.java new file mode 100644 index 0000000..44f9b33 --- /dev/null +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/util/messaging/PrivateMessageService.java @@ -0,0 +1,237 @@ +package org.modularsoft.zander.velocity.util.messaging; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class PrivateMessageService { + + private static final String STORAGE_FILE = "private-messages.json"; + + private final Path storagePath; + private final Gson gson; + private final Logger logger; + private final Object lock = new Object(); + private final Map lastConversation = new ConcurrentHashMap<>(); + private MessagingData data; + + public PrivateMessageService(Path dataDirectory, Logger logger) { + this.storagePath = dataDirectory.resolve(STORAGE_FILE); + this.gson = new GsonBuilder().setPrettyPrinting().create(); + this.logger = logger; + load(); + } + + public void updateNameCache(UUID uuid, String name) { + if (uuid == null || name == null) { + return; + } + synchronized (lock) { + String key = uuid.toString(); + String existing = data.nameCache.get(key); + if (!name.equals(existing)) { + data.nameCache.put(key, name); + save(); + } + } + } + + public Optional resolveUuid(String name, ProxyServer proxy) { + if (name == null) { + return Optional.empty(); + } + Optional online = proxy.getPlayer(name); + if (online.isPresent()) { + Player player = online.get(); + updateNameCache(player.getUniqueId(), player.getUsername()); + return Optional.of(player.getUniqueId()); + } + synchronized (lock) { + for (Map.Entry entry : data.nameCache.entrySet()) { + if (entry.getValue() != null && entry.getValue().equalsIgnoreCase(name)) { + try { + return Optional.of(UUID.fromString(entry.getKey())); + } catch (IllegalArgumentException ignored) { + return Optional.empty(); + } + } + } + } + return Optional.empty(); + } + + public Optional getCachedName(UUID uuid) { + if (uuid == null) { + return Optional.empty(); + } + synchronized (lock) { + return Optional.ofNullable(data.nameCache.get(uuid.toString())); + } + } + + public boolean isMessagesDisabled(UUID uuid) { + if (uuid == null) { + return false; + } + synchronized (lock) { + return Boolean.TRUE.equals(data.messagesDisabled.get(uuid.toString())); + } + } + + public void setMessagesDisabled(UUID uuid, boolean disabled) { + if (uuid == null) { + return; + } + synchronized (lock) { + String key = uuid.toString(); + if (disabled) { + data.messagesDisabled.put(key, true); + } else { + data.messagesDisabled.remove(key); + } + save(); + } + } + + public boolean isIgnoring(UUID owner, UUID target) { + if (owner == null || target == null) { + return false; + } + synchronized (lock) { + Set ignores = data.ignoreList.get(owner.toString()); + return ignores != null && ignores.contains(target.toString()); + } + } + + public boolean addIgnore(UUID owner, UUID target) { + if (owner == null || target == null) { + return false; + } + synchronized (lock) { + Set ignores = data.ignoreList.computeIfAbsent(owner.toString(), key -> new HashSet<>()); + boolean added = ignores.add(target.toString()); + if (added) { + save(); + } + return added; + } + } + + public boolean removeIgnore(UUID owner, UUID target) { + if (owner == null || target == null) { + return false; + } + synchronized (lock) { + Set ignores = data.ignoreList.get(owner.toString()); + if (ignores == null) { + return false; + } + boolean removed = ignores.remove(target.toString()); + if (removed) { + if (ignores.isEmpty()) { + data.ignoreList.remove(owner.toString()); + } + save(); + } + return removed; + } + } + + public Set getIgnoreList(UUID owner) { + if (owner == null) { + return Collections.emptySet(); + } + synchronized (lock) { + Set ignores = data.ignoreList.get(owner.toString()); + if (ignores == null || ignores.isEmpty()) { + return Collections.emptySet(); + } + Set results = new HashSet<>(); + for (String entry : ignores) { + try { + results.add(UUID.fromString(entry)); + } catch (IllegalArgumentException ignored) { + // ignore invalid entries + } + } + return results; + } + } + + public void setLastConversation(UUID sender, UUID target) { + if (sender == null || target == null) { + return; + } + lastConversation.put(sender, target); + lastConversation.put(target, sender); + } + + public Optional getLastConversation(UUID sender) { + if (sender == null) { + return Optional.empty(); + } + return Optional.ofNullable(lastConversation.get(sender)); + } + + public void clearLastConversation(UUID sender) { + if (sender == null) { + return; + } + lastConversation.remove(sender); + } + + private void load() { + synchronized (lock) { + data = new MessagingData(); + if (!Files.exists(storagePath)) { + return; + } + try (Reader reader = Files.newBufferedReader(storagePath)) { + MessagingData loaded = gson.fromJson(reader, MessagingData.class); + if (loaded != null) { + data = loaded; + if (data.messagesDisabled == null) { + data.messagesDisabled = new HashMap<>(); + } + if (data.ignoreList == null) { + data.ignoreList = new HashMap<>(); + } + if (data.nameCache == null) { + data.nameCache = new HashMap<>(); + } + } + } catch (IOException e) { + logger.error("Failed to load private message storage.", e); + } + } + } + + private void save() { + synchronized (lock) { + try { + Files.createDirectories(storagePath.getParent()); + try (Writer writer = Files.newBufferedWriter(storagePath)) { + gson.toJson(data, writer); + } + } catch (IOException e) { + logger.error("Failed to save private message storage.", e); + } + } + } + + private static class MessagingData { + private Map messagesDisabled = new HashMap<>(); + private Map> ignoreList = new HashMap<>(); + private Map nameCache = new HashMap<>(); + } +} diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/util/messaging/VanishStatusResolver.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/util/messaging/VanishStatusResolver.java new file mode 100644 index 0000000..632ee70 --- /dev/null +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/util/messaging/VanishStatusResolver.java @@ -0,0 +1,28 @@ +package org.modularsoft.zander.velocity.util.messaging; + +import com.velocitypowered.api.proxy.Player; + +import java.lang.reflect.Method; + +public class VanishStatusResolver { + + private VanishStatusResolver() { + } + + public static boolean isVanished(Player player) { + if (player == null) { + return false; + } + try { + Class apiClass = Class.forName("de.myzelyam.api.vanish.VanishAPI"); + Method method = apiClass.getMethod("isInvisible", Player.class); + Object result = method.invoke(null, player); + if (result instanceof Boolean) { + return (Boolean) result; + } + } catch (ReflectiveOperationException | LinkageError ignored) { + return false; + } + return false; + } +}