From 9e647f83a876d43a79c098e1f4d56510525db263 Mon Sep 17 00:00:00 2001 From: akastijn Date: Sun, 9 Aug 2026 19:39:55 +0200 Subject: [PATCH] Introduce support for web chat integration. - Replaced synchronous permission checks with asynchronous LuckPerms user loading. - Added `getOrLoadUser` utility to simplify user-related operations. - Introduced web-originated chat command processing via new `PunishmentCommandBuilder` and `PunishFromWebHandler`. - Updated `MuteServer`, `ToggleGlobalChat`, and `ToggleableForCustomChannel` to use async permission checks. - Added test suite for `PunishmentCommandBuilder`. - Expanded chat handling to process party messages from web events. --- .../java/com/alttd/chat/config/Config.java | 3 +- .../com/alttd/chat/objects/Toggleable.java | 3 + .../alttd/chat/objects/channels/Channel.java | 39 +-- .../chat/objects/channels/CustomChannel.java | 18 +- .../chat/objects/channels/DefaultChannel.java | 4 +- .../java/com/alttd/chat/util/Utility.java | 23 +- .../web/handler_class/PartyChatFromWeb.java | 16 ++ .../web/handler_class/PrivateChatFromWeb.java | 16 ++ .../chat/web/handler_class/PunishFromWeb.java | 18 ++ .../main/java/com/alttd/chat/ChatPlugin.java | 7 +- .../chat/chat_web/ChatMessageSender.java | 17 +- .../handlers/WebChannelChatHandler.java | 22 ++ .../com/alttd/chat/commands/ChatChannel.java | 45 +++- .../com/alttd/chat/commands/MuteServer.java | 43 ++-- .../com/alttd/chat/commands/PartyChat.java | 7 + .../alttd/chat/commands/ToggleGlobalChat.java | 21 +- .../com/alttd/chat/handler/ChatHandler.java | 117 ++++++--- .../chat/util/ToggleableForCustomChannel.java | 22 +- velocity/build.gradle.kts | 9 + .../com/alttd/velocitychat/VelocityChat.java | 18 +- .../chat_web/PunishmentCommandBuilder.java | 120 +++++++++ .../handlers/PunishFromWebHandler.java | 42 ++++ .../handlers/WebPartyChatHandler.java | 49 ++++ .../velocitychat/handlers/ChatHandler.java | 147 ++++++++--- .../handlers/ParsedPartyMessage.java | 8 + .../PunishmentCommandBuilderTest.java | 235 ++++++++++++++++++ 26 files changed, 900 insertions(+), 169 deletions(-) create mode 100644 api/src/main/java/com/alttd/chat/web/handler_class/PartyChatFromWeb.java create mode 100644 api/src/main/java/com/alttd/chat/web/handler_class/PrivateChatFromWeb.java create mode 100644 api/src/main/java/com/alttd/chat/web/handler_class/PunishFromWeb.java create mode 100644 galaxy/src/main/java/com/alttd/chat/chat_web/handlers/WebChannelChatHandler.java create mode 100644 velocity/src/main/java/com/alttd/velocitychat/chat_web/PunishmentCommandBuilder.java create mode 100644 velocity/src/main/java/com/alttd/velocitychat/chat_web/handlers/PunishFromWebHandler.java create mode 100644 velocity/src/main/java/com/alttd/velocitychat/chat_web/handlers/WebPartyChatHandler.java create mode 100644 velocity/src/main/java/com/alttd/velocitychat/handlers/ParsedPartyMessage.java create mode 100644 velocity/src/test/java/com/alttd/velocitychat/chat_web/PunishmentCommandBuilderTest.java diff --git a/api/src/main/java/com/alttd/chat/config/Config.java b/api/src/main/java/com/alttd/chat/config/Config.java index 4ca394c..fed0d59 100755 --- a/api/src/main/java/com/alttd/chat/config/Config.java +++ b/api/src/main/java/com/alttd/chat/config/Config.java @@ -432,7 +432,8 @@ public final class Config { getList(key + "servers", Collections.EMPTY_LIST), getList(key + "alias", Collections.EMPTY_LIST), getBoolean(key + "proxy", false), - getBoolean(key + "local", false) + getBoolean(key + "local", false), + getBoolean(key + "web", false) ); } diff --git a/api/src/main/java/com/alttd/chat/objects/Toggleable.java b/api/src/main/java/com/alttd/chat/objects/Toggleable.java index 1aa7ffb..84bf009 100644 --- a/api/src/main/java/com/alttd/chat/objects/Toggleable.java +++ b/api/src/main/java/com/alttd/chat/objects/Toggleable.java @@ -2,6 +2,8 @@ package com.alttd.chat.objects; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.luckperms.api.model.user.User; +import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import java.util.ArrayList; @@ -60,4 +62,5 @@ public abstract class Toggleable { public abstract void sendMessage(Player player, String message); + public abstract void sendMessage(User user, OfflinePlayer offlinePlayer, String message); } diff --git a/api/src/main/java/com/alttd/chat/objects/channels/Channel.java b/api/src/main/java/com/alttd/chat/objects/channels/Channel.java index ab0e636..8354940 100644 --- a/api/src/main/java/com/alttd/chat/objects/channels/Channel.java +++ b/api/src/main/java/com/alttd/chat/objects/channels/Channel.java @@ -1,50 +1,37 @@ package com.alttd.chat.objects.channels; +import lombok.Getter; + import java.util.Collection; import java.util.HashMap; +@Getter public class Channel { public static HashMap channels = new HashMap<>(); - protected String permission; - protected String channelName; - protected String format; - protected boolean proxy; - protected boolean local; + private final String permission; + private final String channelName; + private final String format; + private final boolean proxy; + private final boolean local; + private final boolean web; + private final String webPath; - public Channel(String channelName, String format, boolean proxy, boolean local) { + public Channel(String channelName, String format, boolean proxy, boolean local, boolean web) { this.permission = "chat.channel." + channelName.toLowerCase(); this.channelName = channelName; this.format = format; this.proxy = proxy; this.local = local; channels.put(channelName.toLowerCase(), this); + this.web = web; + this.webPath = web ? "web_" + channelName + "_chat" : null; } public static Collection getChannels() { return channels.values(); } - public String getPermission() { - return permission; - } - - public String getChannelName() { - return channelName; - } - - public String getFormat() { - return format; - } - - public boolean isProxy() { - return proxy; - } - - public boolean isLocal() { - return local; - } - public static Channel getChatChannel(String channelName) { return channels.get(channelName.toLowerCase()); } diff --git a/api/src/main/java/com/alttd/chat/objects/channels/CustomChannel.java b/api/src/main/java/com/alttd/chat/objects/channels/CustomChannel.java index 0aa8010..b2eab4a 100644 --- a/api/src/main/java/com/alttd/chat/objects/channels/CustomChannel.java +++ b/api/src/main/java/com/alttd/chat/objects/channels/CustomChannel.java @@ -1,27 +1,19 @@ package com.alttd.chat.objects.channels; +import lombok.Getter; + import java.util.List; +@Getter public class CustomChannel extends Channel { private final List servers; private final List aliases; public CustomChannel(String channelName, String format, List servers, List aliases, boolean proxy, - boolean local) { - super(channelName, format, proxy, local); - this.permission = "chat.channel." + channelName.toLowerCase(); - this.channelName = channelName; - this.format = format; + boolean local, boolean web) { + super(channelName, format, proxy, local, web); this.servers = servers; - this.proxy = proxy; this.aliases = aliases; } - public List getServers() { - return servers; - } - - public List getAliases() { - return aliases; - } } diff --git a/api/src/main/java/com/alttd/chat/objects/channels/DefaultChannel.java b/api/src/main/java/com/alttd/chat/objects/channels/DefaultChannel.java index d71639b..323b64c 100644 --- a/api/src/main/java/com/alttd/chat/objects/channels/DefaultChannel.java +++ b/api/src/main/java/com/alttd/chat/objects/channels/DefaultChannel.java @@ -1,7 +1,7 @@ package com.alttd.chat.objects.channels; -public abstract class DefaultChannel extends Channel{ +public abstract class DefaultChannel extends Channel { public DefaultChannel(String channelName, String format, boolean proxy) { - super(channelName, format, proxy, false); + super(channelName, format, proxy, false, false); } } diff --git a/api/src/main/java/com/alttd/chat/util/Utility.java b/api/src/main/java/com/alttd/chat/util/Utility.java index 25b258d..da13302 100755 --- a/api/src/main/java/com/alttd/chat/util/Utility.java +++ b/api/src/main/java/com/alttd/chat/util/Utility.java @@ -9,11 +9,14 @@ import net.kyori.adventure.text.minimessage.MiniMessage; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.kyori.adventure.text.minimessage.tag.standard.StandardTags; import net.luckperms.api.LuckPerms; +import net.luckperms.api.context.ImmutableContextSet; import net.luckperms.api.model.group.Group; import net.luckperms.api.model.user.User; import net.luckperms.api.node.Node; +import net.luckperms.api.query.QueryOptions; import java.util.*; +import java.util.concurrent.CompletableFuture; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -180,13 +183,27 @@ public class Utility { }); } - public static boolean hasPermission(UUID uuid, String permission) { + public static CompletableFuture getOrLoadUser(UUID uuid) { + return ChatAPI.get().getLuckPerms().getUserManager().loadUser(uuid); + } + + public static CompletableFuture hasPermission(UUID uuid, String permission) { LuckPerms luckPerms = ChatAPI.get().getLuckPerms(); User user = luckPerms.getUserManager().getUser(uuid); if (user == null) { - return false; + return getOrLoadUser(uuid) + .thenApply(loadedUser -> hasPermission(loadedUser, permission)) + .exceptionally(throwable -> false); } - return user.getCachedData().getPermissionData().checkPermission(permission).asBoolean(); + return CompletableFuture.completedFuture(hasPermission(user, permission)); + } + + public static boolean hasPermission(User user, String permission) { + LuckPerms luckPerms = ChatAPI.get().getLuckPerms(); + return user.getCachedData() + .getPermissionData(QueryOptions.contextual(ImmutableContextSet.of("server", luckPerms.getServerName()))) + .checkPermission(permission) + .asBoolean(); } public static ComponentLike applyColor(String message) { diff --git a/api/src/main/java/com/alttd/chat/web/handler_class/PartyChatFromWeb.java b/api/src/main/java/com/alttd/chat/web/handler_class/PartyChatFromWeb.java new file mode 100644 index 0000000..4ffd37d --- /dev/null +++ b/api/src/main/java/com/alttd/chat/web/handler_class/PartyChatFromWeb.java @@ -0,0 +1,16 @@ +package com.alttd.chat.web.handler_class; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +public class PartyChatFromWeb { + + private UUID sender; + private String message; + private String partyId; + +} diff --git a/api/src/main/java/com/alttd/chat/web/handler_class/PrivateChatFromWeb.java b/api/src/main/java/com/alttd/chat/web/handler_class/PrivateChatFromWeb.java new file mode 100644 index 0000000..9198be8 --- /dev/null +++ b/api/src/main/java/com/alttd/chat/web/handler_class/PrivateChatFromWeb.java @@ -0,0 +1,16 @@ +package com.alttd.chat.web.handler_class; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +public class PrivateChatFromWeb { + + private UUID sender; + private String message; + private UUID recipient; + +} diff --git a/api/src/main/java/com/alttd/chat/web/handler_class/PunishFromWeb.java b/api/src/main/java/com/alttd/chat/web/handler_class/PunishFromWeb.java new file mode 100644 index 0000000..8da7304 --- /dev/null +++ b/api/src/main/java/com/alttd/chat/web/handler_class/PunishFromWeb.java @@ -0,0 +1,18 @@ +package com.alttd.chat.web.handler_class; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +public class PunishFromWeb { + + private UUID executor; + private UUID target; + private String type; + private String reason; + private String time; + +} diff --git a/galaxy/src/main/java/com/alttd/chat/ChatPlugin.java b/galaxy/src/main/java/com/alttd/chat/ChatPlugin.java index 8ccf5e4..4a23231 100755 --- a/galaxy/src/main/java/com/alttd/chat/ChatPlugin.java +++ b/galaxy/src/main/java/com/alttd/chat/ChatPlugin.java @@ -1,6 +1,7 @@ package com.alttd.chat; import com.alttd.chat.chat_web.ChatMessageSender; +import com.alttd.chat.chat_web.handlers.WebChannelChatHandler; import com.alttd.chat.chat_web.handlers.WebChatHandler; import com.alttd.chat.commands.*; import com.alttd.chat.config.Config; @@ -72,9 +73,13 @@ public class ChatPlugin extends JavaPlugin { if (!(channel instanceof CustomChannel customChannel)) { continue; } + ChatChannel chatChannel = new ChatChannel(customChannel, chatAPI.getLuckPerms()); this.getServer() .getCommandMap() - .register(channel.getChannelName().toLowerCase(), new ChatChannel(customChannel)); + .register(channel.getChannelName().toLowerCase(), chatChannel); + if (customChannel.getWebPath() != null) { + sseSubscribeClient.register(customChannel.getWebPath(), new WebChannelChatHandler(chatChannel)); + } } String messageChannel = Config.MESSAGECHANNEL; diff --git a/galaxy/src/main/java/com/alttd/chat/chat_web/ChatMessageSender.java b/galaxy/src/main/java/com/alttd/chat/chat_web/ChatMessageSender.java index 105b78d..caa27fd 100644 --- a/galaxy/src/main/java/com/alttd/chat/chat_web/ChatMessageSender.java +++ b/galaxy/src/main/java/com/alttd/chat/chat_web/ChatMessageSender.java @@ -25,9 +25,7 @@ import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.luckperms.api.LuckPerms; -import net.luckperms.api.context.ImmutableContextSet; import net.luckperms.api.model.user.User; -import net.luckperms.api.query.QueryOptions; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.Sound; @@ -76,7 +74,7 @@ public class ChatMessageSender { ALogger.error("OfflinePlayer is null"); return; } - if (ChatPlugin.getInstance().serverMuted() && !hasPermission(user, "chat.bypass-server-muted")) { + if (ChatPlugin.getInstance().serverMuted() && !Utility.hasPermission(user, "chat.bypass-server-muted")) { sendBlockNotifIfOnline(offlinePlayer, message); return; } @@ -105,7 +103,7 @@ public class ChatMessageSender { Stream stream = Bukkit.getOnlinePlayers().stream() .map(audience -> (Player) audience); - if (!hasPermission(user, "chat.ignorebypass")) { + if (!Utility.hasPermission(user, "chat.ignorebypass")) { stream = stream.filter(receiver -> { boolean isPlayerIgnored = ChatUserManager .getChatUser(receiver.getUniqueId()) @@ -205,7 +203,7 @@ public class ChatMessageSender { if (!ChatUserManager.getChatUser(onlinePlayer.getUniqueId()) .getIgnoredPlayers() .contains(offlinePlayer.getUniqueId()) - || hasPermission(user, "chat.ignorebypass")) { + || Utility.hasPermission(user, "chat.ignorebypass")) { playersToPing.add(onlinePlayer); } } else if (nickPattern.matcher(modifiableString.string()).find()) { @@ -217,7 +215,7 @@ public class ChatMessageSender { if (!ChatUserManager.getChatUser(onlinePlayer.getUniqueId()) .getIgnoredPlayers() .contains(offlinePlayer.getUniqueId()) - || hasPermission(user, "chat.ignorebypass")) { + || Utility.hasPermission(user, "chat.ignorebypass")) { playersToPing.add(onlinePlayer); } } @@ -272,11 +270,4 @@ public class ChatMessageSender { GalaxyUtility.sendBlockedNotification("Chat Muted", player, message, ""); } - private boolean hasPermission(User user, String permission) { - return user.getCachedData() - .getPermissionData(QueryOptions.contextual(ImmutableContextSet.of("server", luckPerms.getServerName()))) - .checkPermission(permission) - .asBoolean(); - } - } diff --git a/galaxy/src/main/java/com/alttd/chat/chat_web/handlers/WebChannelChatHandler.java b/galaxy/src/main/java/com/alttd/chat/chat_web/handlers/WebChannelChatHandler.java new file mode 100644 index 0000000..41fc3a3 --- /dev/null +++ b/galaxy/src/main/java/com/alttd/chat/chat_web/handlers/WebChannelChatHandler.java @@ -0,0 +1,22 @@ +package com.alttd.chat.chat_web.handlers; + +import com.alttd.chat.commands.ChatChannel; +import com.alttd.chat.web.WebHandler; +import com.alttd.chat.web.handler_class.ChatFromWeb; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class WebChannelChatHandler implements WebHandler { + + private final ChatChannel chatChannel; + + @Override + public Class type() { + return ChatFromWeb.class; + } + + @Override + public void handle(ChatFromWeb chatFromWeb) { + this.chatChannel.execute(chatFromWeb); + } +} diff --git a/galaxy/src/main/java/com/alttd/chat/commands/ChatChannel.java b/galaxy/src/main/java/com/alttd/chat/commands/ChatChannel.java index 845bd45..99b5cee 100644 --- a/galaxy/src/main/java/com/alttd/chat/commands/ChatChannel.java +++ b/galaxy/src/main/java/com/alttd/chat/commands/ChatChannel.java @@ -2,10 +2,17 @@ package com.alttd.chat.commands; import com.alttd.chat.config.Config; import com.alttd.chat.objects.channels.CustomChannel; +import com.alttd.chat.util.ALogger; import com.alttd.chat.util.ToggleableForCustomChannel; +import com.alttd.chat.util.Utility; +import com.alttd.chat.web.handler_class.ChatFromWeb; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import net.luckperms.api.LuckPerms; +import net.luckperms.api.model.user.User; import org.apache.commons.lang3.StringUtils; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; import org.bukkit.command.defaults.BukkitCommand; import org.bukkit.entity.Player; @@ -13,16 +20,19 @@ import org.jetbrains.annotations.NotNull; import java.util.ArrayList; import java.util.List; +import java.util.UUID; public class ChatChannel extends BukkitCommand { CustomChannel channel; String command; ToggleableForCustomChannel toggleableForCustomChannel; + private final LuckPerms luckPerms; private static final List activeCommands = new ArrayList<>(); - public ChatChannel(CustomChannel channel) { + public ChatChannel(CustomChannel channel, LuckPerms luckPerms) { super(channel.getChannelName().toLowerCase()); + this.luckPerms = luckPerms; this.channel = channel; this.command = channel.getChannelName().toLowerCase(); this.description = "Chat channel named " + channel.getChannelName() + "."; @@ -40,9 +50,12 @@ public class ChatChannel extends BukkitCommand { if (args.length == 0 && player.hasPermission(channel.getPermission())) { player.sendRichMessage(Config.CUSTOM_CHANNEL_TOGGLED, TagResolver.resolver( - Placeholder.unparsed("channel", channel.getChannelName()), - Placeholder.component("status", toggleableForCustomChannel.toggle(player.getUniqueId()) - ? Config.TOGGLED_ON : Config.TOGGLED_OFF))); + Placeholder.unparsed("channel", channel.getChannelName()), + Placeholder.component("status", toggleableForCustomChannel.toggle(player.getUniqueId()) + ? Config.TOGGLED_ON : Config.TOGGLED_OFF + ) + ) + ); return false; } @@ -52,4 +65,28 @@ public class ChatChannel extends BukkitCommand { return false; } + + public void execute(ChatFromWeb chatFromWeb) { + UUID uuid = chatFromWeb.getSender(); + OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(uuid); + if (luckPerms.getUserManager().isLoaded(uuid)) { + User user = luckPerms.getUserManager().getUser(uuid); + if (user == null) { + ALogger.error("Failed to load loaded user: " + uuid); + return; + } + if (!Utility.hasPermission(user, channel.getPermission())) { + ALogger.warn("Web user %s does not have permission to use this channel".formatted(uuid.toString())); + return; + } + toggleableForCustomChannel.sendMessage(user, offlinePlayer, chatFromWeb.getMessage()); + } else { + luckPerms.getUserManager().loadUser(uuid).whenComplete((user, throwable) -> { + if (throwable != null) { + ALogger.error("Failed to load user: " + uuid, throwable); + } + toggleableForCustomChannel.sendMessage(user, offlinePlayer, chatFromWeb.getMessage()); + }); + } + } } diff --git a/galaxy/src/main/java/com/alttd/chat/commands/MuteServer.java b/galaxy/src/main/java/com/alttd/chat/commands/MuteServer.java index 48618ba..cd7a16b 100644 --- a/galaxy/src/main/java/com/alttd/chat/commands/MuteServer.java +++ b/galaxy/src/main/java/com/alttd/chat/commands/MuteServer.java @@ -9,11 +9,8 @@ import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitRunnable; import org.jetbrains.annotations.NotNull; -import java.util.UUID; - public class MuteServer implements CommandExecutor { @Override @@ -21,27 +18,27 @@ public class MuteServer implements CommandExecutor { if (!(sender instanceof Player player)) { // must be a player return true; } - new BukkitRunnable() { - @Override - public void run() { - UUID uuid = player.getUniqueId(); - if (!Utility.hasPermission(uuid, Config.SERVERMUTEPERMISSION)) { - sender.sendRichMessage("You don't have permission to use this command."); - return; - } - - ChatPlugin.getInstance().toggleServerMuted(); - - ComponentLike component; - if (ChatPlugin.getInstance().serverMuted()) { - component = Utility.parseMiniMessage(Utility.getDisplayName(player.getUniqueId(), player.getName()) + " muted chat."); - } else { - component = Utility.parseMiniMessage(Utility.getDisplayName(player.getUniqueId(), player.getName()) + " un-muted chat."); - } - - Bukkit.getOnlinePlayers().forEach(player -> player.sendMessage(component)); + Utility.getOrLoadUser(player.getUniqueId()).thenAcceptAsync(user -> { + if (!Utility.hasPermission(user, Config.SERVERMUTEPERMISSION)) { + sender.sendRichMessage("You don't have permission to use this command."); + return; } - }.runTaskAsynchronously(ChatPlugin.getInstance()); + + ChatPlugin.getInstance().toggleServerMuted(); + + ComponentLike component; + if (ChatPlugin.getInstance().serverMuted()) { + component = Utility.parseMiniMessage(Utility.getDisplayName(player.getUniqueId(), + player.getName() + ) + " muted chat."); + } else { + component = Utility.parseMiniMessage(Utility.getDisplayName(player.getUniqueId(), + player.getName() + ) + " un-muted chat."); + } + + Bukkit.getOnlinePlayers().forEach(onlinePlayer -> onlinePlayer.sendMessage(component)); + }); return false; } diff --git a/galaxy/src/main/java/com/alttd/chat/commands/PartyChat.java b/galaxy/src/main/java/com/alttd/chat/commands/PartyChat.java index 05fc623..48c1820 100644 --- a/galaxy/src/main/java/com/alttd/chat/commands/PartyChat.java +++ b/galaxy/src/main/java/com/alttd/chat/commands/PartyChat.java @@ -5,7 +5,9 @@ import com.alttd.chat.config.Config; import com.alttd.chat.objects.Toggleable; import com.alttd.chat.util.ALogger; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.luckperms.api.model.user.User; import org.apache.commons.lang3.StringUtils; +import org.bukkit.OfflinePlayer; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; @@ -70,4 +72,9 @@ public class PartyChat extends Toggleable implements CommandExecutor { } }.runTaskAsynchronously(ChatPlugin.getInstance()); } + + @Override + public void sendMessage(User user, OfflinePlayer offlinePlayer, String message) { + //TODO [Stijn] [2026-08-09]: Implement + } } diff --git a/galaxy/src/main/java/com/alttd/chat/commands/ToggleGlobalChat.java b/galaxy/src/main/java/com/alttd/chat/commands/ToggleGlobalChat.java index 3fcb548..447602d 100755 --- a/galaxy/src/main/java/com/alttd/chat/commands/ToggleGlobalChat.java +++ b/galaxy/src/main/java/com/alttd/chat/commands/ToggleGlobalChat.java @@ -1,6 +1,5 @@ package com.alttd.chat.commands; -import com.alttd.chat.ChatPlugin; import com.alttd.chat.config.Config; import com.alttd.chat.managers.ChatUserManager; import com.alttd.chat.util.Utility; @@ -8,7 +7,6 @@ import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitRunnable; import org.jetbrains.annotations.NotNull; import java.util.UUID; @@ -17,18 +15,17 @@ public class ToggleGlobalChat implements CommandExecutor { @Override public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, String[] args) { - if (!(sender instanceof Player)) { + if (!(sender instanceof Player player)) { return true; } - new BukkitRunnable() { - @Override - public void run() { - UUID uuid = ((Player) sender).getUniqueId(); - ChatUserManager.getChatUser(uuid); - Utility.flipPermission(uuid, Config.GCPERMISSION); - sender.sendRichMessage("You have turned globalchat " + (!Utility.hasPermission(uuid, Config.GCPERMISSION) ? "on." : "off.")); // TODO load from config and minimessage - } - }.runTaskAsynchronously(ChatPlugin.getInstance()); + UUID uuid = player.getUniqueId(); + Utility.getOrLoadUser(uuid).thenAcceptAsync(user -> { + ChatUserManager.getChatUser(uuid); + Utility.flipPermission(uuid, Config.GCPERMISSION); + sender.sendRichMessage("You have turned globalchat " + (!Utility.hasPermission(user, + Config.GCPERMISSION + ) ? "on." : "off.")); // TODO load from config and minimessage + }); return false; } diff --git a/galaxy/src/main/java/com/alttd/chat/handler/ChatHandler.java b/galaxy/src/main/java/com/alttd/chat/handler/ChatHandler.java index ef0dbeb..8799b7c 100755 --- a/galaxy/src/main/java/com/alttd/chat/handler/ChatHandler.java +++ b/galaxy/src/main/java/com/alttd/chat/handler/ChatHandler.java @@ -16,6 +16,7 @@ import com.alttd.chat.util.ServerName; import com.alttd.chat.util.Utility; import com.google.common.io.ByteArrayDataOutput; import com.google.common.io.ByteStreams; +import lombok.extern.slf4j.Slf4j; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.ComponentLike; import net.kyori.adventure.text.TextReplacementConfig; @@ -24,20 +25,24 @@ import net.kyori.adventure.text.minimessage.MiniMessage; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; +import net.luckperms.api.model.user.User; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.OfflinePlayer; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.jspecify.annotations.Nullable; +import java.util.Collection; import java.util.List; import java.util.Objects; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; +@Slf4j public class ChatHandler { private final ChatPlugin plugin; @@ -52,11 +57,16 @@ public class ChatHandler { } public void continuePrivateMessage(Player player, String target, String message) { + Utility.getOrLoadUser(player.getUniqueId()) + .thenAccept(user -> continuePrivateMessage(user, player, target, message)); + } + + public void continuePrivateMessage(User user, Player player, String target, String message) { // ChatUser user = ChatUserManager.getChatUser(player.getUniqueId()); // user.setReplyTarget(target); TagResolver placeholders = TagResolver.resolver( - Placeholder.component("message", parseMessageContent(player, message)), + Placeholder.component("message", parseMessageContent(user, player, message)), Placeholder.component("sendername", player.name()), Placeholder.parsed("receivername", target) ); @@ -87,10 +97,15 @@ public class ChatHandler { } public void privateMessage(Player player, String target, String message) { + Utility.getOrLoadUser(player.getUniqueId()) + .thenAccept(user -> privateMessage(user, player, target, message)); + } + + public void privateMessage(User user, Player player, String target, String message) { // ChatUser user = ChatUserManager.getChatUser(player.getUniqueId()); // user.setReplyTarget(target); - Component messageComponent = parseMessageContent(player, message); + Component messageComponent = parseMessageContent(user, player, message); TagResolver placeholders = TagResolver.resolver( Placeholder.component("message", messageComponent), Placeholder.component("sendername", player.name()), @@ -124,17 +139,22 @@ public class ChatHandler { } public void globalChat(Player player, String message) { - ChatUser user = ChatUserManager.getChatUser(player.getUniqueId()); - if (!Utility.hasPermission(player.getUniqueId(), Config.GCPERMISSION)) { + Utility.getOrLoadUser(player.getUniqueId()) + .thenAccept(user -> globalChat(user, player, message)); + } + + public void globalChat(User user, Player player, String message) { + ChatUser chatUser = ChatUserManager.getChatUser(player.getUniqueId()); + if (!Utility.hasPermission(user, Config.GCPERMISSION)) { player.sendMessage(GCNOTENABLED);// GC IS OFF INFORM THEM ABOUT THIS and cancel return; } - if (isMuted(player, message, "[GC Muted] ")) { + if (isMuted(user, player, message, "[GC Muted] ")) { return; } - long timeLeft = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis() - user.getGcCooldown()); + long timeLeft = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis() - chatUser.getGcCooldown()); if (timeLeft <= Config.GCCOOLDOWN && !player.hasPermission("chat.globalchat.cooldownbypass")) { // player is on cooldown and should wait x seconds player.sendRichMessage(Config.GCONCOOLDOWN, Placeholder.parsed("cooldown", Config.GCCOOLDOWN - timeLeft + "") @@ -142,12 +162,12 @@ public class ChatHandler { return; } - ComponentLike senderName = user.getDisplayName(); - ComponentLike prefix = user.getPrefix(); + ComponentLike senderName = chatUser.getDisplayName(); + ComponentLike prefix = chatUser.getPrefix(); TagResolver placeholders = TagResolver.resolver( Placeholder.component("sender", senderName), Placeholder.component("prefix", prefix), - Placeholder.component("message", parseMessageContent(player, message)), + Placeholder.component("message", parseMessageContent(user, player, message)), Placeholder.parsed("server", ServerName.getServerName()) ); @@ -175,40 +195,47 @@ public class ChatHandler { false ); - user.setGcCooldown(System.currentTimeMillis()); + chatUser.setGcCooldown(System.currentTimeMillis()); sendPluginMessage(player, "globalchat", component); } - public void chatChannel(Player player, CustomChannel channel, String message) { - if (!player.hasPermission(channel.getPermission())) { - player.sendRichMessage("You don't have permission to use this channel."); + public void chatChannel(User user, OfflinePlayer offlinePlayer, CustomChannel channel, String message) { + if (!Utility.hasPermission(user, channel.getPermission())) { + if (offlinePlayer.isOnline()) { + Player onlinePlayer = offlinePlayer.getPlayer(); + if (onlinePlayer == null) { + log.error("Player is online but getPlayer() returned null"); + return; + } + onlinePlayer.sendRichMessage("You don't have permission to use this channel."); + } return; } - if (isMuted(player, message, "[" + channel.getChannelName() + " Muted] ")) { + if (isMuted(user, offlinePlayer, message, "[" + channel.getChannelName() + " Muted] ")) { ALogger.info("Refusing to send message by muted user"); return; } - ChatUser user = ChatUserManager.getChatUser(player.getUniqueId()); - ComponentLike senderName = user.getDisplayName(); + ChatUser chatUser = ChatUserManager.getChatUser(offlinePlayer.getUniqueId()); + ComponentLike senderName = chatUser.getDisplayName(); TagResolver placeholders = TagResolver.resolver( Placeholder.component("sender", senderName), - Placeholder.component("message", parseMessageContent(player, message)), + Placeholder.component("message", parseMessageContent(user, offlinePlayer, message)), Placeholder.parsed("server", ServerName.getServerName()), Placeholder.parsed("channel", channel.getChannelName()) ); Component component = Utility.parseMiniMessage(channel.getFormat(), placeholders).asComponent(); ModifiableString modifiableString = new ModifiableString(component); - if (!RegexManager.filterText(player.getName(), - player.getUniqueId(), + if (!RegexManager.filterText(offlinePlayer.getName(), + offlinePlayer.getUniqueId(), modifiableString, channel.getChannelName() )) { GalaxyUtility.sendBlockedNotification(channel.getChannelName() + " Language", - player, + offlinePlayer, Utility.parseMiniMessage(Utility.parseColors(modifiableString.string())), "" ); @@ -219,9 +246,9 @@ public class ChatHandler { component = modifiableString.component(); if (channel.isProxy()) { - sendChatChannelMessage(player, channel.getChannelName(), "chatchannel", component, message); + sendChatChannelMessage(offlinePlayer, channel.getChannelName(), "chatchannel", component, message); } else { - sendChatChannelMessage(channel, player.getUniqueId(), component, message); + sendChatChannelMessage(channel, offlinePlayer.getUniqueId(), component, message); } } @@ -366,27 +393,36 @@ public class ChatHandler { player.sendPluginMessage(plugin, Config.MESSAGECHANNEL, out.toByteArray()); } - public void sendChatChannelMessage(Player player, String chatChannelName, String channel, Component component, String ignored) { + public void sendChatChannelMessage(OfflinePlayer player, String chatChannelName, String channel, Component component, String ignored) { ByteArrayDataOutput out = ByteStreams.newDataOutput(); out.writeUTF(channel); out.writeUTF(chatChannelName); out.writeUTF(player.getUniqueId().toString()); out.writeUTF(GsonComponentSerializer.gson().serialize(component)); - player.sendPluginMessage(plugin, Config.MESSAGECHANNEL, out.toByteArray()); + Collection onlinePlayers = Bukkit.getOnlinePlayers(); + if (player.isOnline() && player.getPlayer() != null) { + player.getPlayer().sendPluginMessage(plugin, Config.MESSAGECHANNEL, out.toByteArray()); + } else { + //TODO [Stijn] [2026-08-09]: Validate that this works + onlinePlayers.stream().findFirst().ifPresent(p -> + p.sendPluginMessage(plugin, Config.MESSAGECHANNEL, out.toByteArray())); + } } // Start - move these to util - private boolean isMuted(Player player, String message, String prefix) { - ChatUser user = ChatUserManager.getChatUser(player.getUniqueId()); - if (user == null) { + private boolean isMuted(User user, OfflinePlayer offlinePlayer, String message, String prefix) { + ChatUser chatUser = ChatUserManager.getChatUser(offlinePlayer.getUniqueId()); + if (chatUser == null) { return false; } - if (user.isMuted() || (ChatPlugin.getInstance().serverMuted() && !player.hasPermission( - "chat.bypass-server-muted"))) { + if (chatUser.isMuted() || (ChatPlugin.getInstance() + .serverMuted() && !Utility.hasPermission(user, + "chat.bypass-server-muted" + ))) { // if (Database.get().isPlayerMuted(player.getUniqueId(), null) || (ChatPlugin.getInstance().serverMuted() && !player.hasPermission("chat.bypass-server-muted"))) { GalaxyUtility.sendBlockedNotification(prefix, - player, + offlinePlayer, Utility.parseMiniMessage(Utility.stripTokens(message)), "" ); @@ -437,11 +473,11 @@ public class ChatHandler { } // end - move these to util - private Component parseMessageContent(Player player, String rawMessage) { + private Component parseMessageContent(User user, OfflinePlayer offlinePlayer, String rawMessage) { TagResolver.Builder tagResolver = TagResolver.builder(); Utility.formattingPerms.forEach((perm, pair) -> { - if (player.hasPermission(perm)) { + if (Utility.hasPermission(user, perm)) { tagResolver.resolver(pair.getX()); } }); @@ -456,13 +492,16 @@ public class ChatHandler { .replacement(chatFilter.getReplacement()).build()); } - component = component - .replaceText( - TextReplacementConfig.builder() - .once() - .matchLiteral("[i]") - .replacement(ChatHandler.itemComponent(player.getInventory().getItemInMainHand())) - .build()); + if (offlinePlayer.isOnline() && offlinePlayer.getPlayer() != null) { + component = component + .replaceText( + TextReplacementConfig.builder() + .once() + .matchLiteral("[i]") + .replacement(ChatHandler.itemComponent(offlinePlayer.getPlayer().getInventory() + .getItemInMainHand())) + .build()); + } return component; diff --git a/galaxy/src/main/java/com/alttd/chat/util/ToggleableForCustomChannel.java b/galaxy/src/main/java/com/alttd/chat/util/ToggleableForCustomChannel.java index e265a3e..8e995d2 100644 --- a/galaxy/src/main/java/com/alttd/chat/util/ToggleableForCustomChannel.java +++ b/galaxy/src/main/java/com/alttd/chat/util/ToggleableForCustomChannel.java @@ -3,6 +3,8 @@ package com.alttd.chat.util; import com.alttd.chat.ChatPlugin; import com.alttd.chat.objects.Toggleable; import com.alttd.chat.objects.channels.CustomChannel; +import net.luckperms.api.model.user.User; +import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import org.bukkit.scheduler.BukkitRunnable; @@ -38,11 +40,27 @@ public class ToggleableForCustomChannel extends Toggleable { @Override public void sendMessage(Player player, String message) { + Utility.getOrLoadUser(player.getUniqueId()).thenAcceptAsync(user -> { + ALogger.info(String.format("%s sent %s message: %s", + player.getName(), + customChannel.getChannelName(), + message + )); + ChatPlugin.getInstance().getChatHandler().chatChannel(user, player, customChannel, message); + }); + } + + @Override + public void sendMessage(User user, OfflinePlayer offlinePlayer, String message) { new BukkitRunnable() { @Override public void run() { - ALogger.info(String.format("%s sent %s message: %s", player.getName(), customChannel.getChannelName(), message)); - ChatPlugin.getInstance().getChatHandler().chatChannel(player, customChannel, message); + ALogger.info(String.format("%s sent %s message: %s", + offlinePlayer.getName(), + customChannel.getChannelName(), + message + )); + ChatPlugin.getInstance().getChatHandler().chatChannel(user, offlinePlayer, customChannel, message); } }.runTaskAsynchronously(ChatPlugin.getInstance()); } diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 69c3ae8..fd5a784 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -14,10 +14,19 @@ dependencies { compileOnly("net.kyori:adventure-text-minimessage:4.23.0") compileOnly("com.gitlab.ruany:LiteBansAPI:0.3.5") compileOnly("com.alttd.proxydiscordlink:ProxyDiscordLink:1.0.1-SNAPSHOT") + compileOnly("net.luckperms:api:5.5") // Luckperms + + testImplementation(platform("org.junit:junit-bom:5.10.0")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } tasks { + test { + useJUnitPlatform() + } + shadowJar { archiveFileName.set("${rootProject.name}-${project.name}-${project.version}.jar") // minimize() diff --git a/velocity/src/main/java/com/alttd/velocitychat/VelocityChat.java b/velocity/src/main/java/com/alttd/velocitychat/VelocityChat.java index 5099685..f983030 100755 --- a/velocity/src/main/java/com/alttd/velocitychat/VelocityChat.java +++ b/velocity/src/main/java/com/alttd/velocitychat/VelocityChat.java @@ -10,6 +10,9 @@ import com.alttd.chat.objects.ChatUser; import com.alttd.chat.objects.chat_log.ChatLogHandler; import com.alttd.chat.objects.chat_log.WebHandler; import com.alttd.chat.util.ALogger; +import com.alttd.chat.web.SseSubscribeClient; +import com.alttd.velocitychat.chat_web.handlers.PunishFromWebHandler; +import com.alttd.velocitychat.chat_web.handlers.WebPartyChatHandler; import com.alttd.velocitychat.commands.*; import com.alttd.velocitychat.handlers.ChatHandler; import com.alttd.velocitychat.handlers.ServerHandler; @@ -50,6 +53,7 @@ public class VelocityChat { private ServerHandler serverHandler; private ChannelIdentifier channelIdentifier; + private SseSubscribeClient sseSubscribeClient; @Inject public VelocityChat(ProxyServer proxyServer, Logger proxyLogger, @DataDirectory Path proxydataDirectory) { @@ -71,7 +75,7 @@ public class VelocityChat { WebHandler webHandler = new WebHandler(); ChatLogHandler chatLogHandler = new ChatLogHandler(webHandler, true); - chatHandler = new ChatHandler(chatLogHandler); + chatHandler = new ChatHandler(chatLogHandler, chatAPI.getLuckPerms()); server.getEventManager().register(this, new ChatListener()); server.getEventManager().register(this, new ProxyPlayerListener()); new LiteBansListener().init(); // init the litebans api listeners @@ -84,6 +88,18 @@ public class VelocityChat { ChatUser console = new ChatUser(Config.CONSOLEUUID, -1, null); console.setDisplayName(Config.CONSOLENAME); ChatUserManager.addUser(console); + sseSubscribeClient = new SseSubscribeClient( + Config.CHAT_WEB_REGISTER_TO_BASE_URL, + "proxy", //TODO [Stijn] [2026-08-09]: Make configurable if needed + Config.CHAT_WEB_TOKEN + ); + new Thread(sseSubscribeClient).start(); + registerWebHandlers(sseSubscribeClient); + } + + private void registerWebHandlers(SseSubscribeClient sseSubscribeClient) { + sseSubscribeClient.register("web_party_chat", new WebPartyChatHandler(chatHandler)); + sseSubscribeClient.register("web_punish", new PunishFromWebHandler(server)); } public void reloadConfig() { diff --git a/velocity/src/main/java/com/alttd/velocitychat/chat_web/PunishmentCommandBuilder.java b/velocity/src/main/java/com/alttd/velocitychat/chat_web/PunishmentCommandBuilder.java new file mode 100644 index 0000000..5d81895 --- /dev/null +++ b/velocity/src/main/java/com/alttd/velocitychat/chat_web/PunishmentCommandBuilder.java @@ -0,0 +1,120 @@ +package com.alttd.velocitychat.chat_web; + +import com.alttd.chat.web.handler_class.PunishFromWeb; + +import java.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +public class PunishmentCommandBuilder { + + private static final Set ALLOWED_TYPES = Set.of("BAN", "MUTE", "WARN"); + private static final String WARN_DURATION = "30d"; + + public static String buildCommand(String executorName, PunishFromWeb event) { + String rawType = event.getType(); + UUID executorUuid = event.getExecutor(); + String target = event.getTarget().toString(); + String reason = event.getReason(); + + String type = validateType(rawType); + validateReason(reason); + String time = resolveTime(type, event.getTime()); + + StringBuilder commandBuilder = new StringBuilder(); + commandBuilder.append(type).append(" ").append(target); + + if (time != null) { + commandBuilder.append(" ").append(time); + } + + commandBuilder.append(" --sender=").append(executorName) + .append(" --sender-uuid=").append(executorUuid); + + commandBuilder.append(" ").append(reason); + + return commandBuilder.toString(); + } + + private static String validateType(String rawType) { + if (rawType == null || !ALLOWED_TYPES.contains(rawType.toUpperCase(Locale.ROOT))) { + throw new IllegalArgumentException("Invalid punishment type: " + rawType + + ". Allowed types are: " + ALLOWED_TYPES); + } + return rawType.toLowerCase(Locale.ROOT); + } + + private static void validateReason(String reason) { + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("A reason is required for all punishments"); + } + } + + /** + * Resolves the litebans-formatted time argument for a given (already-normalized, + * lowercase) punishment type, applying the per-type rules: + * - warn: always 30d, regardless of what was supplied + * - mute: a duration is required + * - ban: optional, permanent (null) if not supplied + */ + private static String resolveTime(String type, String rawTime) { + switch (type) { + case "warn": + return WARN_DURATION; + case "mute": + if (rawTime == null || rawTime.isBlank()) { + throw new IllegalArgumentException("Mutes must have a duration"); + } + return parseDuration(rawTime); + case "ban": + if (rawTime == null || rawTime.isBlank()) { + return null; // permanent ban + } + return parseDuration(rawTime); + default: + // unreachable, type is already validated before this is called + throw new IllegalArgumentException("Unsupported type: " + type); + } + } + + /** + * Parses an ISO-8601 duration (e.g. "PT30M", "P7D", "P1DT2H3M4S") as sent by the + * OpenAPI spec's `time` field, and converts it into a litebans-style duration + * string. Litebans only accepts a single unit of d, h, or m (no combined units, + * no seconds), so this picks the single largest whole unit and drops the rest, + * e.g. "P1DT2H3M4S" -> "1d", "PT2H3M" -> "2h", "PT45M" -> "45m". + */ + static String parseDuration(String isoDuration) { + Duration duration; + try { + duration = Duration.parse(isoDuration); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Invalid duration format: " + isoDuration, e); + } + + long totalSeconds = duration.getSeconds(); + if (totalSeconds <= 0) { + throw new IllegalArgumentException("Duration must be positive: " + isoDuration); + } + + long days = totalSeconds / 86400; + if (days > 0) { + return days + "d"; + } + + long hours = totalSeconds / 3600; + if (hours > 0) { + return hours + "h"; + } + + long minutes = totalSeconds / 60; + if (minutes > 0) { + return minutes + "m"; + } + + //Default minimum + return "1m"; + } +} diff --git a/velocity/src/main/java/com/alttd/velocitychat/chat_web/handlers/PunishFromWebHandler.java b/velocity/src/main/java/com/alttd/velocitychat/chat_web/handlers/PunishFromWebHandler.java new file mode 100644 index 0000000..9d2623f --- /dev/null +++ b/velocity/src/main/java/com/alttd/velocitychat/chat_web/handlers/PunishFromWebHandler.java @@ -0,0 +1,42 @@ +package com.alttd.velocitychat.chat_web.handlers; + +import com.alttd.chat.util.Utility; +import com.alttd.chat.web.WebHandler; +import com.alttd.chat.web.handler_class.PunishFromWeb; +import com.alttd.velocitychat.chat_web.PunishmentCommandBuilder; +import com.velocitypowered.api.proxy.ProxyServer; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class PunishFromWebHandler implements WebHandler { + private final ProxyServer server; + + public PunishFromWebHandler(ProxyServer server) { + this.server = server; + } + + @Override + public Class type() { + return PunishFromWeb.class; + } + + @Override + public void handle(PunishFromWeb event) { + String permission = "litebans." + event.getType().toLowerCase(); + Utility.getOrLoadUser(event.getExecutor()).thenAccept(user -> { + if (user == null) { + log.warn("User {} does not exist", event.getExecutor()); + return; + } + if (!Utility.hasPermission(user, permission)) { + log.warn("User {} does not have permission {}", user.getUsername(), permission); + return; + } + String executorName = (user.getUsername() != null) ? user.getUsername() : event.getExecutor().toString(); + server.getCommandManager() + .executeAsync(server.getConsoleCommandSource(), + PunishmentCommandBuilder.buildCommand(executorName, event) + ); + }); + } +} diff --git a/velocity/src/main/java/com/alttd/velocitychat/chat_web/handlers/WebPartyChatHandler.java b/velocity/src/main/java/com/alttd/velocitychat/chat_web/handlers/WebPartyChatHandler.java new file mode 100644 index 0000000..521609d --- /dev/null +++ b/velocity/src/main/java/com/alttd/velocitychat/chat_web/handlers/WebPartyChatHandler.java @@ -0,0 +1,49 @@ +package com.alttd.velocitychat.chat_web.handlers; + +import com.alttd.chat.managers.ChatUserManager; +import com.alttd.chat.managers.PartyManager; +import com.alttd.chat.objects.ChatUser; +import com.alttd.chat.objects.Party; +import com.alttd.chat.web.WebHandler; +import com.alttd.chat.web.handler_class.PartyChatFromWeb; +import com.alttd.velocitychat.handlers.ChatHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.UUID; + +@Slf4j +@RequiredArgsConstructor +public class WebPartyChatHandler implements WebHandler { + + private final ChatHandler chatHandler; + + @Override + public Class type() { + return PartyChatFromWeb.class; + } + + @Override + public void handle(PartyChatFromWeb event) { + int partyId; + try { + partyId = Integer.parseInt(event.getPartyId()); + } catch (NumberFormatException e) { + log.error("Invalid party id: {}", event.getPartyId()); + return; + } + UUID sender = event.getSender(); + String message = event.getMessage(); + Party party = PartyManager.getParty(sender); + if (party == null) { + log.error("Party not found for sender: {}", sender); + return; + } + if (party.getPartyId() != partyId) { + log.error("Party id mismatch: {} != {}", party.getPartyId(), partyId); + return; + } + ChatUser chatUser = ChatUserManager.getChatUser(sender);//TODO [Stijn] [2026-08-09]: Async since it can do a query + chatHandler.sendPartyMessageFromWeb(sender, party, chatUser.getDisplayName().asComponent(), message); + } +} diff --git a/velocity/src/main/java/com/alttd/velocitychat/handlers/ChatHandler.java b/velocity/src/main/java/com/alttd/velocitychat/handlers/ChatHandler.java index 5a614ea..dd5b04d 100755 --- a/velocity/src/main/java/com/alttd/velocitychat/handlers/ChatHandler.java +++ b/velocity/src/main/java/com/alttd/velocitychat/handlers/ChatHandler.java @@ -16,6 +16,7 @@ import com.google.common.io.ByteStreams; import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ServerConnection; +import lombok.extern.slf4j.Slf4j; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.ComponentLike; import net.kyori.adventure.text.TextReplacementConfig; @@ -24,7 +25,10 @@ import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.luckperms.api.LuckPerms; +import net.luckperms.api.model.user.User; import org.jetbrains.annotations.Nullable; +import org.jspecify.annotations.NonNull; import java.time.Duration; import java.util.Date; @@ -32,12 +36,15 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +@Slf4j public class ChatHandler { private final ChatLogHandler chatLogHandler; + private final LuckPerms luckPerms; - public ChatHandler(ChatLogHandler chatLogHandler) { + public ChatHandler(ChatLogHandler chatLogHandler, LuckPerms luckPerms) { this.chatLogHandler = chatLogHandler; + this.luckPerms = luckPerms; } public void privateMessage(String sender, String target, String message) { @@ -137,58 +144,125 @@ public class ChatHandler { }); } + public void sendPartyMessageFromWeb(UUID uuid, Party party, Component senderName, String message) { + Utility.getOrLoadUser(uuid).thenAccept(user -> sendPartyMessageFromWeb(uuid, party, senderName, message, user)); + } + + public void sendPartyMessageFromWeb(UUID uuid, Party party, Component senderName, String message, User user) { + Optional optionalParsedPartyMessage = getResult(uuid, + message, + null, + null, + senderName, + party, + user.getUsername(), + user, + null + ); + + if (optionalParsedPartyMessage.isEmpty()) { + log.error("Failed to parse party message: {}", message); + return; + } + + ParsedPartyMessage parsedPartyMessage = optionalParsedPartyMessage.get(); + + chatLogHandler.addChatLog(uuid, + "web", + message, + ChatLogType.PARTY, + null, + null, + parsedPartyMessage.partyMessage(), + false + ); + } + public void sendPartyMessage(UUID uuid, String message, Component item, ServerConnection serverConnection) { + Utility.getOrLoadUser(uuid).thenAccept(user -> sendPartyMessage(uuid, message, item, serverConnection, user)); + } + + public void sendPartyMessage(UUID uuid, String message, Component item, ServerConnection serverConnection, User user) { Optional optionalPlayer = VelocityChat.getPlugin().getProxy().getPlayer(uuid); if (optionalPlayer.isEmpty()) { return; } Player player = optionalPlayer.get(); - ChatUser user = ChatUserManager.getChatUser(uuid); - Party party = PartyManager.getParty(user.getPartyId()); + ChatUser chatUser = ChatUserManager.getChatUser(uuid); + Party party = PartyManager.getParty(chatUser.getPartyId()); if (party == null) { player.sendMessage(Utility.parseMiniMessage(Config.NOT_IN_A_PARTY)); return; } - ComponentLike senderName = user.getDisplayName(); + ComponentLike senderName = chatUser.getDisplayName(); - TagResolver placeholders = TagResolver.resolver( - Placeholder.component("sender", senderName), - Placeholder.component("sendername", senderName), - Placeholder.unparsed("partyname", party.getPartyName()), - Placeholder.component("message", parseMessageContent(player, message)), - Placeholder.unparsed("server", serverConnection.getServer().getServerInfo().getName()) + Optional optionalParsedPartyMessage = getResult(uuid, + message, + item, + serverConnection, + senderName, + party, + player.getUsername(), + user, + player ); - - Component partyMessage = Utility.parseMiniMessage(Config.PARTY_FORMAT, placeholders).asComponent() - .replaceText(TextReplacementConfig.builder().once().matchLiteral("[i]").replacement(item).build()); - - ModifiableString modifiableString = new ModifiableString(partyMessage); - if (!RegexManager.filterText(player.getUsername(), uuid, modifiableString, "party")) { - sendBlockedNotification("Party Language", player, message, "", serverConnection); + if (optionalParsedPartyMessage.isEmpty()) { return; // the message was blocked } - partyMessage = modifiableString.component(); + ParsedPartyMessage parsedPartyMessage = optionalParsedPartyMessage.get(); - sendPartyMessage(party, partyMessage, user.getIgnoredBy()); + sendPartyMessage(party, parsedPartyMessage.partyMessage(), chatUser.getIgnoredBy()); chatLogHandler.addChatLog(uuid, serverConnection.getServer().getServerInfo().getName(), - PlainTextComponentSerializer.plainText().serialize(partyMessage), + PlainTextComponentSerializer.plainText().serialize(parsedPartyMessage.partyMessage()), ChatLogType.PARTY, String.valueOf(party.getPartyId()), null, - partyMessage, + parsedPartyMessage.partyMessage(), false ); - ComponentLike spyMessage = Utility.parseMiniMessage(Config.PARTY_SPY, placeholders); + ComponentLike spyMessage = Utility.parseMiniMessage(Config.PARTY_SPY, parsedPartyMessage.placeholders()); for (Player pl : serverConnection.getServer().getPlayersConnected()) { if (pl.hasPermission(Config.SPYPERMISSION) && !party.getPartyUsersUuid().contains(pl.getUniqueId())) { pl.sendMessage(spyMessage); } } - ALogger.info(PlainTextComponentSerializer.plainText().serialize(partyMessage)); + ALogger.info(PlainTextComponentSerializer.plainText().serialize(parsedPartyMessage.partyMessage())); + } + + private Optional getResult(UUID uuid, String message, @Nullable Component item, + @Nullable ServerConnection serverConnection, ComponentLike senderName, + Party party, String playerName, User user, @Nullable Player player) { + TagResolver placeholders = TagResolver.resolver( + Placeholder.component("sender", senderName), + Placeholder.component("sendername", senderName), + Placeholder.unparsed("partyname", party.getPartyName()), + Placeholder.component("message", parseMessageContent(user, message)), + Placeholder.unparsed("server", + serverConnection != null ? serverConnection.getServer().getServerInfo().getName() : "web" + ) + ); + Component partyMessage; + if (item != null) { + partyMessage = Utility.parseMiniMessage(Config.PARTY_FORMAT, placeholders).asComponent() + .replaceText(TextReplacementConfig.builder().once().matchLiteral("[i]").replacement(item).build()); + } else { + partyMessage = Utility.parseMiniMessage(Config.PARTY_FORMAT, placeholders).asComponent(); + } + + ModifiableString modifiableString = new ModifiableString(partyMessage); + if (!RegexManager.filterText(playerName, uuid, modifiableString, "party")) { + if (serverConnection != null && player != null) { + sendBlockedNotification("Party Language", player, message, "", serverConnection); + } + return Optional.empty(); + } + + partyMessage = modifiableString.component(); + return Optional.of(new ParsedPartyMessage(placeholders, partyMessage)); } public void globalAdminChat(String message) { @@ -215,11 +289,11 @@ public class ChatHandler { ComponentLike senderName = Component.text(Config.CONSOLENAME); String serverName = "Altitude"; if (commandSource instanceof Player sender) { - ChatUser user = ChatUserManager.getChatUser(sender.getUniqueId()); - if (user == null) { + ChatUser chatUser = ChatUserManager.getChatUser(sender.getUniqueId()); + if (chatUser == null) { return; } - senderName = user.getDisplayName(); + senderName = chatUser.getDisplayName(); serverName = sender.getCurrentServer().isPresent() ? sender.getCurrentServer() .get() .getServerInfo() @@ -347,15 +421,31 @@ public class ChatHandler { return stringBuilder.toString(); } - private Component parseMessageContent(CommandSource source, String rawMessage) { + private Component parseMessageContent(User user, String rawMessage) { TagResolver.Builder tagResolver = TagResolver.builder(); Utility.formattingPerms.forEach((perm, pair) -> { - if (source.hasPermission(perm)) { + if (Utility.hasPermission(user, perm)) { tagResolver.resolver(pair.getX()); } }); + return getComponent(rawMessage, tagResolver); + } + + private Component parseMessageContent(CommandSource commandSource, String rawMessage) { + TagResolver.Builder tagResolver = TagResolver.builder(); + + Utility.formattingPerms.forEach((perm, pair) -> { + if (commandSource.hasPermission(perm)) { + tagResolver.resolver(pair.getX()); + } + }); + + return getComponent(rawMessage, tagResolver); + } + + private static @NonNull Component getComponent(String rawMessage, TagResolver.Builder tagResolver) { MiniMessage miniMessage = MiniMessage.builder().tags(tagResolver.build()).build(); Component component = miniMessage.deserialize(rawMessage); for (ChatFilter chatFilter : RegexManager.getEmoteFilters()) { @@ -367,6 +457,5 @@ public class ChatHandler { } return component; - } } diff --git a/velocity/src/main/java/com/alttd/velocitychat/handlers/ParsedPartyMessage.java b/velocity/src/main/java/com/alttd/velocitychat/handlers/ParsedPartyMessage.java new file mode 100644 index 0000000..d354e7a --- /dev/null +++ b/velocity/src/main/java/com/alttd/velocitychat/handlers/ParsedPartyMessage.java @@ -0,0 +1,8 @@ +package com.alttd.velocitychat.handlers; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; + +public record ParsedPartyMessage(TagResolver placeholders, Component partyMessage) { +} + diff --git a/velocity/src/test/java/com/alttd/velocitychat/chat_web/PunishmentCommandBuilderTest.java b/velocity/src/test/java/com/alttd/velocitychat/chat_web/PunishmentCommandBuilderTest.java new file mode 100644 index 0000000..f2cec37 --- /dev/null +++ b/velocity/src/test/java/com/alttd/velocitychat/chat_web/PunishmentCommandBuilderTest.java @@ -0,0 +1,235 @@ +package com.alttd.velocitychat.chat_web; + +import com.alttd.chat.web.handler_class.PunishFromWeb; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class PunishmentCommandBuilderTest { + + private PunishFromWeb baseEvent(String type, UUID executorUuid, UUID targetUuid) { + PunishFromWeb event = new PunishFromWeb(); + event.setExecutor(executorUuid); + event.setTarget(targetUuid); + event.setType(type); + return event; + } + + // ---- BAN ---- + + @Test + public void testBuildBanCommandWithTime() { + UUID executorUuid = UUID.randomUUID(); + UUID targetUuid = UUID.randomUUID(); + PunishFromWeb event = baseEvent("ban", executorUuid, targetUuid); + event.setTime("P7D"); // ISO-8601 duration: 7 days + event.setReason("Griefing"); + + String command = PunishmentCommandBuilder.buildCommand("ExecutorName", event); + + String expected = "ban " + targetUuid + " 7d --sender=ExecutorName --sender-uuid=" + executorUuid + " Griefing"; + assertEquals(expected, command); + } + + @Test + public void testBuildBanCommandPermanentWhenNoTime() { + UUID executorUuid = UUID.randomUUID(); + UUID targetUuid = UUID.randomUUID(); + PunishFromWeb event = baseEvent("ban", executorUuid, targetUuid); + event.setTime(null); + event.setReason("Griefing"); + + String command = PunishmentCommandBuilder.buildCommand("ExecutorName", event); + + String expected = "ban " + targetUuid + " --sender=ExecutorName --sender-uuid=" + executorUuid + " Griefing"; + assertEquals(expected, command); + } + + @Test + public void testBuildBanCommandPermanentWhenBlankTime() { + UUID executorUuid = UUID.randomUUID(); + UUID targetUuid = UUID.randomUUID(); + PunishFromWeb event = baseEvent("ban", executorUuid, targetUuid); + event.setTime(" "); + event.setReason("Griefing"); + + String command = PunishmentCommandBuilder.buildCommand("ExecutorName", event); + + String expected = "ban " + targetUuid + " --sender=ExecutorName --sender-uuid=" + executorUuid + " Griefing"; + assertEquals(expected, command); + } + + @Test + public void testBuildBanCommandMissingReasonThrows() { + UUID executorUuid = UUID.randomUUID(); + UUID targetUuid = UUID.randomUUID(); + PunishFromWeb event = baseEvent("ban", executorUuid, targetUuid); + event.setTime(" "); + event.setReason(" "); + + assertThrows(IllegalArgumentException.class, + () -> PunishmentCommandBuilder.buildCommand("ExecutorName", event) + ); + } + + // ---- MUTE ---- + + @Test + public void testBuildMuteCommand() { + UUID executorUuid = UUID.randomUUID(); + UUID targetUuid = UUID.randomUUID(); + PunishFromWeb event = baseEvent("mute", executorUuid, targetUuid); + event.setTime("PT30M"); // 30 minutes + event.setReason("Spamming"); + + String command = PunishmentCommandBuilder.buildCommand("ExecutorName", event); + + String expected = "mute " + targetUuid + " 30m --sender=ExecutorName --sender-uuid=" + executorUuid + " Spamming"; + assertEquals(expected, command); + } + + @Test + public void testBuildMuteCommandNoTimeThrows() { + UUID executorUuid = UUID.randomUUID(); + UUID targetUuid = UUID.randomUUID(); + PunishFromWeb event = baseEvent("mute", executorUuid, targetUuid); + event.setTime(null); + event.setReason("Spamming"); + + assertThrows(IllegalArgumentException.class, + () -> PunishmentCommandBuilder.buildCommand("ExecutorName", event) + ); + } + + @Test + public void testBuildMuteCommandBlankTimeThrows() { + UUID executorUuid = UUID.randomUUID(); + UUID targetUuid = UUID.randomUUID(); + PunishFromWeb event = baseEvent("mute", executorUuid, targetUuid); + event.setTime(" "); + event.setReason("Spamming"); + + assertThrows(IllegalArgumentException.class, + () -> PunishmentCommandBuilder.buildCommand("ExecutorName", event) + ); + } + + // ---- WARN ---- + + @Test + public void testBuildWarnCommandAlwaysUses30d() { + UUID executorUuid = UUID.randomUUID(); + UUID targetUuid = UUID.randomUUID(); + PunishFromWeb event = baseEvent("warn", executorUuid, targetUuid); + event.setTime(null); // not supplied for warns, should default to 30d anyway + event.setReason("Bad attitude"); + + String command = PunishmentCommandBuilder.buildCommand("ExecutorName", event); + + String expected = "warn " + targetUuid + " 30d --sender=ExecutorName --sender-uuid=" + executorUuid + " Bad attitude"; + assertEquals(expected, command); + } + + @Test + public void testBuildWarnCommandIgnoresSuppliedTime() { + UUID executorUuid = UUID.randomUUID(); + UUID targetUuid = UUID.randomUUID(); + PunishFromWeb event = baseEvent("warn", executorUuid, targetUuid); + event.setTime("P1D"); // should be ignored; warns are always 30d + event.setReason("Bad attitude"); + + String command = PunishmentCommandBuilder.buildCommand("ExecutorName", event); + + String expected = "warn " + targetUuid + " 30d --sender=ExecutorName --sender-uuid=" + executorUuid + " Bad attitude"; + assertEquals(expected, command); + } + + @Test + public void testBuildWarnCommandNoReasonThrows() { + UUID executorUuid = UUID.randomUUID(); + UUID targetUuid = UUID.randomUUID(); + PunishFromWeb event = baseEvent("warn", executorUuid, targetUuid); + event.setTime(null); + event.setReason(""); + + assertThrows(IllegalArgumentException.class, + () -> PunishmentCommandBuilder.buildCommand("ExecutorName", event) + ); + } + + // ---- Type validation ---- + + @Test + public void testInvalidTypeThrows() { + UUID executorUuid = UUID.randomUUID(); + UUID targetUuid = UUID.randomUUID(); + PunishFromWeb event = baseEvent("kick", executorUuid, targetUuid); + event.setReason("Bad attitude"); + + assertThrows(IllegalArgumentException.class, + () -> PunishmentCommandBuilder.buildCommand("ExecutorName", event) + ); + } + + @Test + public void testTypeIsCaseInsensitive() { + UUID executorUuid = UUID.randomUUID(); + UUID targetUuid = UUID.randomUUID(); + PunishFromWeb event = baseEvent("BAN", executorUuid, targetUuid); + event.setTime(null); + event.setReason("Griefing"); + + String command = PunishmentCommandBuilder.buildCommand("ExecutorName", event); + + String expected = "ban " + targetUuid + " --sender=ExecutorName --sender-uuid=" + executorUuid + " Griefing"; + assertEquals(expected, command); + } + + // ---- Duration parsing ---- + + @Test + public void testParseDurationDaysOnly() { + assertEquals("7d", PunishmentCommandBuilder.parseDuration("P7D")); + } + + @Test + public void testParseDurationMinutesOnly() { + assertEquals("45m", PunishmentCommandBuilder.parseDuration("PT45M")); + } + + @Test + public void testParseDurationTruncatesToDaysWhenDaysPresent() { + // days take priority over everything smaller, which is dropped entirely + assertEquals("1d", PunishmentCommandBuilder.parseDuration("P1DT2H3M4S")); + } + + @Test + public void testParseDurationTruncatesToHoursWhenNoDays() { + // hours take priority over minutes, which is dropped + assertEquals("2h", PunishmentCommandBuilder.parseDuration("PT2H3M")); + } + + @Test + public void testParseDurationInvalidFormatThrows() { + assertThrows(IllegalArgumentException.class, + () -> PunishmentCommandBuilder.parseDuration("7d") + ); + } + + @Test + public void testParseDurationZeroThrows() { + // duration must be positive + assertThrows(IllegalArgumentException.class, + () -> PunishmentCommandBuilder.parseDuration("PT0S") + ); + } + + @Test + public void testParseDurationSubMinuteThrows() { + // litebans has no second-level precision defaults to 1m + assertEquals("1m", PunishmentCommandBuilder.parseDuration("PT30S")); + } +}