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.
This commit is contained in:
akastijn 2026-08-09 19:39:55 +02:00
parent 0d2ac3763f
commit 9e647f83a8
26 changed files with 900 additions and 169 deletions

View File

@ -432,7 +432,8 @@ public final class Config {
getList(key + "servers", Collections.EMPTY_LIST), getList(key + "servers", Collections.EMPTY_LIST),
getList(key + "alias", Collections.EMPTY_LIST), getList(key + "alias", Collections.EMPTY_LIST),
getBoolean(key + "proxy", false), getBoolean(key + "proxy", false),
getBoolean(key + "local", false) getBoolean(key + "local", false),
getBoolean(key + "web", false)
); );
} }

View File

@ -2,6 +2,8 @@ package com.alttd.chat.objects;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; 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 org.bukkit.entity.Player;
import java.util.ArrayList; import java.util.ArrayList;
@ -60,4 +62,5 @@ public abstract class Toggleable {
public abstract void sendMessage(Player player, String message); public abstract void sendMessage(Player player, String message);
public abstract void sendMessage(User user, OfflinePlayer offlinePlayer, String message);
} }

View File

@ -1,50 +1,37 @@
package com.alttd.chat.objects.channels; package com.alttd.chat.objects.channels;
import lombok.Getter;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
@Getter
public class Channel { public class Channel {
public static HashMap<String, Channel> channels = new HashMap<>(); public static HashMap<String, Channel> channels = new HashMap<>();
protected String permission; private final String permission;
protected String channelName; private final String channelName;
protected String format; private final String format;
protected boolean proxy; private final boolean proxy;
protected boolean local; 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.permission = "chat.channel." + channelName.toLowerCase();
this.channelName = channelName; this.channelName = channelName;
this.format = format; this.format = format;
this.proxy = proxy; this.proxy = proxy;
this.local = local; this.local = local;
channels.put(channelName.toLowerCase(), this); channels.put(channelName.toLowerCase(), this);
this.web = web;
this.webPath = web ? "web_" + channelName + "_chat" : null;
} }
public static Collection<Channel> getChannels() { public static Collection<Channel> getChannels() {
return channels.values(); 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) { public static Channel getChatChannel(String channelName) {
return channels.get(channelName.toLowerCase()); return channels.get(channelName.toLowerCase());
} }

View File

@ -1,27 +1,19 @@
package com.alttd.chat.objects.channels; package com.alttd.chat.objects.channels;
import lombok.Getter;
import java.util.List; import java.util.List;
@Getter
public class CustomChannel extends Channel { public class CustomChannel extends Channel {
private final List<String> servers; private final List<String> servers;
private final List<String> aliases; private final List<String> aliases;
public CustomChannel(String channelName, String format, List<String> servers, List<String> aliases, boolean proxy, public CustomChannel(String channelName, String format, List<String> servers, List<String> aliases, boolean proxy,
boolean local) { boolean local, boolean web) {
super(channelName, format, proxy, local); super(channelName, format, proxy, local, web);
this.permission = "chat.channel." + channelName.toLowerCase();
this.channelName = channelName;
this.format = format;
this.servers = servers; this.servers = servers;
this.proxy = proxy;
this.aliases = aliases; this.aliases = aliases;
} }
public List<String> getServers() {
return servers;
}
public List<String> getAliases() {
return aliases;
}
} }

View File

@ -2,6 +2,6 @@ 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) { public DefaultChannel(String channelName, String format, boolean proxy) {
super(channelName, format, proxy, false); super(channelName, format, proxy, false, false);
} }
} }

View File

@ -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.resolver.TagResolver;
import net.kyori.adventure.text.minimessage.tag.standard.StandardTags; import net.kyori.adventure.text.minimessage.tag.standard.StandardTags;
import net.luckperms.api.LuckPerms; import net.luckperms.api.LuckPerms;
import net.luckperms.api.context.ImmutableContextSet;
import net.luckperms.api.model.group.Group; import net.luckperms.api.model.group.Group;
import net.luckperms.api.model.user.User; import net.luckperms.api.model.user.User;
import net.luckperms.api.node.Node; import net.luckperms.api.node.Node;
import net.luckperms.api.query.QueryOptions;
import java.util.*; import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -180,13 +183,27 @@ public class Utility {
}); });
} }
public static boolean hasPermission(UUID uuid, String permission) { public static CompletableFuture<User> getOrLoadUser(UUID uuid) {
return ChatAPI.get().getLuckPerms().getUserManager().loadUser(uuid);
}
public static CompletableFuture<Boolean> hasPermission(UUID uuid, String permission) {
LuckPerms luckPerms = ChatAPI.get().getLuckPerms(); LuckPerms luckPerms = ChatAPI.get().getLuckPerms();
User user = luckPerms.getUserManager().getUser(uuid); User user = luckPerms.getUserManager().getUser(uuid);
if (user == null) { 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) { public static ComponentLike applyColor(String message) {

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
package com.alttd.chat; package com.alttd.chat;
import com.alttd.chat.chat_web.ChatMessageSender; 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.chat_web.handlers.WebChatHandler;
import com.alttd.chat.commands.*; import com.alttd.chat.commands.*;
import com.alttd.chat.config.Config; import com.alttd.chat.config.Config;
@ -72,9 +73,13 @@ public class ChatPlugin extends JavaPlugin {
if (!(channel instanceof CustomChannel customChannel)) { if (!(channel instanceof CustomChannel customChannel)) {
continue; continue;
} }
ChatChannel chatChannel = new ChatChannel(customChannel, chatAPI.getLuckPerms());
this.getServer() this.getServer()
.getCommandMap() .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; String messageChannel = Config.MESSAGECHANNEL;

View File

@ -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.minimessage.tag.resolver.TagResolver;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import net.luckperms.api.LuckPerms; import net.luckperms.api.LuckPerms;
import net.luckperms.api.context.ImmutableContextSet;
import net.luckperms.api.model.user.User; import net.luckperms.api.model.user.User;
import net.luckperms.api.query.QueryOptions;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer; import org.bukkit.OfflinePlayer;
import org.bukkit.Sound; import org.bukkit.Sound;
@ -76,7 +74,7 @@ public class ChatMessageSender {
ALogger.error("OfflinePlayer is null"); ALogger.error("OfflinePlayer is null");
return; 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); sendBlockNotifIfOnline(offlinePlayer, message);
return; return;
} }
@ -105,7 +103,7 @@ public class ChatMessageSender {
Stream<Player> stream = Bukkit.getOnlinePlayers().stream() Stream<Player> stream = Bukkit.getOnlinePlayers().stream()
.map(audience -> (Player) audience); .map(audience -> (Player) audience);
if (!hasPermission(user, "chat.ignorebypass")) { if (!Utility.hasPermission(user, "chat.ignorebypass")) {
stream = stream.filter(receiver -> { stream = stream.filter(receiver -> {
boolean isPlayerIgnored = ChatUserManager boolean isPlayerIgnored = ChatUserManager
.getChatUser(receiver.getUniqueId()) .getChatUser(receiver.getUniqueId())
@ -205,7 +203,7 @@ public class ChatMessageSender {
if (!ChatUserManager.getChatUser(onlinePlayer.getUniqueId()) if (!ChatUserManager.getChatUser(onlinePlayer.getUniqueId())
.getIgnoredPlayers() .getIgnoredPlayers()
.contains(offlinePlayer.getUniqueId()) .contains(offlinePlayer.getUniqueId())
|| hasPermission(user, "chat.ignorebypass")) { || Utility.hasPermission(user, "chat.ignorebypass")) {
playersToPing.add(onlinePlayer); playersToPing.add(onlinePlayer);
} }
} else if (nickPattern.matcher(modifiableString.string()).find()) { } else if (nickPattern.matcher(modifiableString.string()).find()) {
@ -217,7 +215,7 @@ public class ChatMessageSender {
if (!ChatUserManager.getChatUser(onlinePlayer.getUniqueId()) if (!ChatUserManager.getChatUser(onlinePlayer.getUniqueId())
.getIgnoredPlayers() .getIgnoredPlayers()
.contains(offlinePlayer.getUniqueId()) .contains(offlinePlayer.getUniqueId())
|| hasPermission(user, "chat.ignorebypass")) { || Utility.hasPermission(user, "chat.ignorebypass")) {
playersToPing.add(onlinePlayer); playersToPing.add(onlinePlayer);
} }
} }
@ -272,11 +270,4 @@ public class ChatMessageSender {
GalaxyUtility.sendBlockedNotification("Chat Muted", player, message, ""); 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();
}
} }

View File

@ -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<ChatFromWeb> {
private final ChatChannel chatChannel;
@Override
public Class<ChatFromWeb> type() {
return ChatFromWeb.class;
}
@Override
public void handle(ChatFromWeb chatFromWeb) {
this.chatChannel.execute(chatFromWeb);
}
}

View File

@ -2,10 +2,17 @@ package com.alttd.chat.commands;
import com.alttd.chat.config.Config; import com.alttd.chat.config.Config;
import com.alttd.chat.objects.channels.CustomChannel; import com.alttd.chat.objects.channels.CustomChannel;
import com.alttd.chat.util.ALogger;
import com.alttd.chat.util.ToggleableForCustomChannel; 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.Placeholder;
import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; 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.apache.commons.lang3.StringUtils;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.command.defaults.BukkitCommand; import org.bukkit.command.defaults.BukkitCommand;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
@ -13,16 +20,19 @@ import org.jetbrains.annotations.NotNull;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
public class ChatChannel extends BukkitCommand { public class ChatChannel extends BukkitCommand {
CustomChannel channel; CustomChannel channel;
String command; String command;
ToggleableForCustomChannel toggleableForCustomChannel; ToggleableForCustomChannel toggleableForCustomChannel;
private final LuckPerms luckPerms;
private static final List<ChatChannel> activeCommands = new ArrayList<>(); private static final List<ChatChannel> activeCommands = new ArrayList<>();
public ChatChannel(CustomChannel channel) { public ChatChannel(CustomChannel channel, LuckPerms luckPerms) {
super(channel.getChannelName().toLowerCase()); super(channel.getChannelName().toLowerCase());
this.luckPerms = luckPerms;
this.channel = channel; this.channel = channel;
this.command = channel.getChannelName().toLowerCase(); this.command = channel.getChannelName().toLowerCase();
this.description = "Chat channel named " + channel.getChannelName() + "."; this.description = "Chat channel named " + channel.getChannelName() + ".";
@ -42,7 +52,10 @@ public class ChatChannel extends BukkitCommand {
player.sendRichMessage(Config.CUSTOM_CHANNEL_TOGGLED, TagResolver.resolver( player.sendRichMessage(Config.CUSTOM_CHANNEL_TOGGLED, TagResolver.resolver(
Placeholder.unparsed("channel", channel.getChannelName()), Placeholder.unparsed("channel", channel.getChannelName()),
Placeholder.component("status", toggleableForCustomChannel.toggle(player.getUniqueId()) Placeholder.component("status", toggleableForCustomChannel.toggle(player.getUniqueId())
? Config.TOGGLED_ON : Config.TOGGLED_OFF))); ? Config.TOGGLED_ON : Config.TOGGLED_OFF
)
)
);
return false; return false;
} }
@ -52,4 +65,28 @@ public class ChatChannel extends BukkitCommand {
return false; 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());
});
}
}
} }

View File

@ -9,11 +9,8 @@ import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitRunnable;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.util.UUID;
public class MuteServer implements CommandExecutor { public class MuteServer implements CommandExecutor {
@Override @Override
@ -21,11 +18,8 @@ public class MuteServer implements CommandExecutor {
if (!(sender instanceof Player player)) { // must be a player if (!(sender instanceof Player player)) { // must be a player
return true; return true;
} }
new BukkitRunnable() { Utility.getOrLoadUser(player.getUniqueId()).thenAcceptAsync(user -> {
@Override if (!Utility.hasPermission(user, Config.SERVERMUTEPERMISSION)) {
public void run() {
UUID uuid = player.getUniqueId();
if (!Utility.hasPermission(uuid, Config.SERVERMUTEPERMISSION)) {
sender.sendRichMessage("<red>You don't have permission to use this command.</red>"); sender.sendRichMessage("<red>You don't have permission to use this command.</red>");
return; return;
} }
@ -34,14 +28,17 @@ public class MuteServer implements CommandExecutor {
ComponentLike component; ComponentLike component;
if (ChatPlugin.getInstance().serverMuted()) { if (ChatPlugin.getInstance().serverMuted()) {
component = Utility.parseMiniMessage(Utility.getDisplayName(player.getUniqueId(), player.getName()) + " <red>muted</red><white> chat."); component = Utility.parseMiniMessage(Utility.getDisplayName(player.getUniqueId(),
player.getName()
) + " <red>muted</red><white> chat.");
} else { } else {
component = Utility.parseMiniMessage(Utility.getDisplayName(player.getUniqueId(), player.getName()) + " <green>un-muted</green><white> chat."); component = Utility.parseMiniMessage(Utility.getDisplayName(player.getUniqueId(),
player.getName()
) + " <green>un-muted</green><white> chat.");
} }
Bukkit.getOnlinePlayers().forEach(player -> player.sendMessage(component)); Bukkit.getOnlinePlayers().forEach(onlinePlayer -> onlinePlayer.sendMessage(component));
} });
}.runTaskAsynchronously(ChatPlugin.getInstance());
return false; return false;
} }

View File

@ -5,7 +5,9 @@ import com.alttd.chat.config.Config;
import com.alttd.chat.objects.Toggleable; import com.alttd.chat.objects.Toggleable;
import com.alttd.chat.util.ALogger; import com.alttd.chat.util.ALogger;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
import net.luckperms.api.model.user.User;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
@ -70,4 +72,9 @@ public class PartyChat extends Toggleable implements CommandExecutor {
} }
}.runTaskAsynchronously(ChatPlugin.getInstance()); }.runTaskAsynchronously(ChatPlugin.getInstance());
} }
@Override
public void sendMessage(User user, OfflinePlayer offlinePlayer, String message) {
//TODO [Stijn] [2026-08-09]: Implement
}
} }

View File

@ -1,6 +1,5 @@
package com.alttd.chat.commands; package com.alttd.chat.commands;
import com.alttd.chat.ChatPlugin;
import com.alttd.chat.config.Config; import com.alttd.chat.config.Config;
import com.alttd.chat.managers.ChatUserManager; import com.alttd.chat.managers.ChatUserManager;
import com.alttd.chat.util.Utility; import com.alttd.chat.util.Utility;
@ -8,7 +7,6 @@ import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitRunnable;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.util.UUID; import java.util.UUID;
@ -17,18 +15,17 @@ public class ToggleGlobalChat implements CommandExecutor {
@Override @Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, String[] args) { 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; return true;
} }
new BukkitRunnable() { UUID uuid = player.getUniqueId();
@Override Utility.getOrLoadUser(uuid).thenAcceptAsync(user -> {
public void run() {
UUID uuid = ((Player) sender).getUniqueId();
ChatUserManager.getChatUser(uuid); ChatUserManager.getChatUser(uuid);
Utility.flipPermission(uuid, Config.GCPERMISSION); Utility.flipPermission(uuid, Config.GCPERMISSION);
sender.sendRichMessage("You have turned globalchat " + (!Utility.hasPermission(uuid, Config.GCPERMISSION) ? "<green>on." : "<red>off.")); // TODO load from config and minimessage sender.sendRichMessage("You have turned globalchat " + (!Utility.hasPermission(user,
} Config.GCPERMISSION
}.runTaskAsynchronously(ChatPlugin.getInstance()); ) ? "<green>on." : "<red>off.")); // TODO load from config and minimessage
});
return false; return false;
} }

View File

@ -16,6 +16,7 @@ import com.alttd.chat.util.ServerName;
import com.alttd.chat.util.Utility; import com.alttd.chat.util.Utility;
import com.google.common.io.ByteArrayDataOutput; import com.google.common.io.ByteArrayDataOutput;
import com.google.common.io.ByteStreams; import com.google.common.io.ByteStreams;
import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.ComponentLike; import net.kyori.adventure.text.ComponentLike;
import net.kyori.adventure.text.TextReplacementConfig; 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.Placeholder;
import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import net.luckperms.api.model.user.User;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Entity; import org.bukkit.entity.Entity;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Stream; import java.util.stream.Stream;
@Slf4j
public class ChatHandler { public class ChatHandler {
private final ChatPlugin plugin; private final ChatPlugin plugin;
@ -52,11 +57,16 @@ public class ChatHandler {
} }
public void continuePrivateMessage(Player player, String target, String message) { 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()); // ChatUser user = ChatUserManager.getChatUser(player.getUniqueId());
// user.setReplyTarget(target); // user.setReplyTarget(target);
TagResolver placeholders = TagResolver.resolver( TagResolver placeholders = TagResolver.resolver(
Placeholder.component("message", parseMessageContent(player, message)), Placeholder.component("message", parseMessageContent(user, player, message)),
Placeholder.component("sendername", player.name()), Placeholder.component("sendername", player.name()),
Placeholder.parsed("receivername", target) Placeholder.parsed("receivername", target)
); );
@ -87,10 +97,15 @@ public class ChatHandler {
} }
public void privateMessage(Player player, String target, String message) { 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()); // ChatUser user = ChatUserManager.getChatUser(player.getUniqueId());
// user.setReplyTarget(target); // user.setReplyTarget(target);
Component messageComponent = parseMessageContent(player, message); Component messageComponent = parseMessageContent(user, player, message);
TagResolver placeholders = TagResolver.resolver( TagResolver placeholders = TagResolver.resolver(
Placeholder.component("message", messageComponent), Placeholder.component("message", messageComponent),
Placeholder.component("sendername", player.name()), Placeholder.component("sendername", player.name()),
@ -124,17 +139,22 @@ public class ChatHandler {
} }
public void globalChat(Player player, String message) { public void globalChat(Player player, String message) {
ChatUser user = ChatUserManager.getChatUser(player.getUniqueId()); Utility.getOrLoadUser(player.getUniqueId())
if (!Utility.hasPermission(player.getUniqueId(), Config.GCPERMISSION)) { .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 player.sendMessage(GCNOTENABLED);// GC IS OFF INFORM THEM ABOUT THIS and cancel
return; return;
} }
if (isMuted(player, message, "[GC Muted] ")) { if (isMuted(user, player, message, "[GC Muted] ")) {
return; 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 if (timeLeft <= Config.GCCOOLDOWN && !player.hasPermission("chat.globalchat.cooldownbypass")) { // player is on cooldown and should wait x seconds
player.sendRichMessage(Config.GCONCOOLDOWN, player.sendRichMessage(Config.GCONCOOLDOWN,
Placeholder.parsed("cooldown", Config.GCCOOLDOWN - timeLeft + "") Placeholder.parsed("cooldown", Config.GCCOOLDOWN - timeLeft + "")
@ -142,12 +162,12 @@ public class ChatHandler {
return; return;
} }
ComponentLike senderName = user.getDisplayName(); ComponentLike senderName = chatUser.getDisplayName();
ComponentLike prefix = user.getPrefix(); ComponentLike prefix = chatUser.getPrefix();
TagResolver placeholders = TagResolver.resolver( TagResolver placeholders = TagResolver.resolver(
Placeholder.component("sender", senderName), Placeholder.component("sender", senderName),
Placeholder.component("prefix", prefix), Placeholder.component("prefix", prefix),
Placeholder.component("message", parseMessageContent(player, message)), Placeholder.component("message", parseMessageContent(user, player, message)),
Placeholder.parsed("server", ServerName.getServerName()) Placeholder.parsed("server", ServerName.getServerName())
); );
@ -175,40 +195,47 @@ public class ChatHandler {
false false
); );
user.setGcCooldown(System.currentTimeMillis()); chatUser.setGcCooldown(System.currentTimeMillis());
sendPluginMessage(player, "globalchat", component); sendPluginMessage(player, "globalchat", component);
} }
public void chatChannel(Player player, CustomChannel channel, String message) { public void chatChannel(User user, OfflinePlayer offlinePlayer, CustomChannel channel, String message) {
if (!player.hasPermission(channel.getPermission())) { if (!Utility.hasPermission(user, channel.getPermission())) {
player.sendRichMessage("<red>You don't have permission to use this channel.</red>"); if (offlinePlayer.isOnline()) {
Player onlinePlayer = offlinePlayer.getPlayer();
if (onlinePlayer == null) {
log.error("Player is online but getPlayer() returned null");
return;
}
onlinePlayer.sendRichMessage("<red>You don't have permission to use this channel.</red>");
}
return; 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"); ALogger.info("Refusing to send message by muted user");
return; return;
} }
ChatUser user = ChatUserManager.getChatUser(player.getUniqueId()); ChatUser chatUser = ChatUserManager.getChatUser(offlinePlayer.getUniqueId());
ComponentLike senderName = user.getDisplayName(); ComponentLike senderName = chatUser.getDisplayName();
TagResolver placeholders = TagResolver.resolver( TagResolver placeholders = TagResolver.resolver(
Placeholder.component("sender", senderName), Placeholder.component("sender", senderName),
Placeholder.component("message", parseMessageContent(player, message)), Placeholder.component("message", parseMessageContent(user, offlinePlayer, message)),
Placeholder.parsed("server", ServerName.getServerName()), Placeholder.parsed("server", ServerName.getServerName()),
Placeholder.parsed("channel", channel.getChannelName()) Placeholder.parsed("channel", channel.getChannelName())
); );
Component component = Utility.parseMiniMessage(channel.getFormat(), placeholders).asComponent(); Component component = Utility.parseMiniMessage(channel.getFormat(), placeholders).asComponent();
ModifiableString modifiableString = new ModifiableString(component); ModifiableString modifiableString = new ModifiableString(component);
if (!RegexManager.filterText(player.getName(), if (!RegexManager.filterText(offlinePlayer.getName(),
player.getUniqueId(), offlinePlayer.getUniqueId(),
modifiableString, modifiableString,
channel.getChannelName() channel.getChannelName()
)) { )) {
GalaxyUtility.sendBlockedNotification(channel.getChannelName() + " Language", GalaxyUtility.sendBlockedNotification(channel.getChannelName() + " Language",
player, offlinePlayer,
Utility.parseMiniMessage(Utility.parseColors(modifiableString.string())), Utility.parseMiniMessage(Utility.parseColors(modifiableString.string())),
"" ""
); );
@ -219,9 +246,9 @@ public class ChatHandler {
component = modifiableString.component(); component = modifiableString.component();
if (channel.isProxy()) { if (channel.isProxy()) {
sendChatChannelMessage(player, channel.getChannelName(), "chatchannel", component, message); sendChatChannelMessage(offlinePlayer, channel.getChannelName(), "chatchannel", component, message);
} else { } 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()); 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(); ByteArrayDataOutput out = ByteStreams.newDataOutput();
out.writeUTF(channel); out.writeUTF(channel);
out.writeUTF(chatChannelName); out.writeUTF(chatChannelName);
out.writeUTF(player.getUniqueId().toString()); out.writeUTF(player.getUniqueId().toString());
out.writeUTF(GsonComponentSerializer.gson().serialize(component)); out.writeUTF(GsonComponentSerializer.gson().serialize(component));
player.sendPluginMessage(plugin, Config.MESSAGECHANNEL, out.toByteArray()); Collection<? extends Player> 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 // Start - move these to util
private boolean isMuted(Player player, String message, String prefix) { private boolean isMuted(User user, OfflinePlayer offlinePlayer, String message, String prefix) {
ChatUser user = ChatUserManager.getChatUser(player.getUniqueId()); ChatUser chatUser = ChatUserManager.getChatUser(offlinePlayer.getUniqueId());
if (user == null) { if (chatUser == null) {
return false; return false;
} }
if (user.isMuted() || (ChatPlugin.getInstance().serverMuted() && !player.hasPermission( if (chatUser.isMuted() || (ChatPlugin.getInstance()
"chat.bypass-server-muted"))) { .serverMuted() && !Utility.hasPermission(user,
"chat.bypass-server-muted"
))) {
// if (Database.get().isPlayerMuted(player.getUniqueId(), null) || (ChatPlugin.getInstance().serverMuted() && !player.hasPermission("chat.bypass-server-muted"))) { // if (Database.get().isPlayerMuted(player.getUniqueId(), null) || (ChatPlugin.getInstance().serverMuted() && !player.hasPermission("chat.bypass-server-muted"))) {
GalaxyUtility.sendBlockedNotification(prefix, GalaxyUtility.sendBlockedNotification(prefix,
player, offlinePlayer,
Utility.parseMiniMessage(Utility.stripTokens(message)), Utility.parseMiniMessage(Utility.stripTokens(message)),
"" ""
); );
@ -437,11 +473,11 @@ public class ChatHandler {
} }
// end - move these to util // 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(); TagResolver.Builder tagResolver = TagResolver.builder();
Utility.formattingPerms.forEach((perm, pair) -> { Utility.formattingPerms.forEach((perm, pair) -> {
if (player.hasPermission(perm)) { if (Utility.hasPermission(user, perm)) {
tagResolver.resolver(pair.getX()); tagResolver.resolver(pair.getX());
} }
}); });
@ -456,13 +492,16 @@ public class ChatHandler {
.replacement(chatFilter.getReplacement()).build()); .replacement(chatFilter.getReplacement()).build());
} }
if (offlinePlayer.isOnline() && offlinePlayer.getPlayer() != null) {
component = component component = component
.replaceText( .replaceText(
TextReplacementConfig.builder() TextReplacementConfig.builder()
.once() .once()
.matchLiteral("[i]") .matchLiteral("[i]")
.replacement(ChatHandler.itemComponent(player.getInventory().getItemInMainHand())) .replacement(ChatHandler.itemComponent(offlinePlayer.getPlayer().getInventory()
.getItemInMainHand()))
.build()); .build());
}
return component; return component;

View File

@ -3,6 +3,8 @@ package com.alttd.chat.util;
import com.alttd.chat.ChatPlugin; import com.alttd.chat.ChatPlugin;
import com.alttd.chat.objects.Toggleable; import com.alttd.chat.objects.Toggleable;
import com.alttd.chat.objects.channels.CustomChannel; 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.entity.Player;
import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.scheduler.BukkitRunnable;
@ -38,11 +40,27 @@ public class ToggleableForCustomChannel extends Toggleable {
@Override @Override
public void sendMessage(Player player, String message) { 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() { new BukkitRunnable() {
@Override @Override
public void run() { public void run() {
ALogger.info(String.format("%s sent %s message: %s", player.getName(), customChannel.getChannelName(), message)); ALogger.info(String.format("%s sent %s message: %s",
ChatPlugin.getInstance().getChatHandler().chatChannel(player, customChannel, message); offlinePlayer.getName(),
customChannel.getChannelName(),
message
));
ChatPlugin.getInstance().getChatHandler().chatChannel(user, offlinePlayer, customChannel, message);
} }
}.runTaskAsynchronously(ChatPlugin.getInstance()); }.runTaskAsynchronously(ChatPlugin.getInstance());
} }

View File

@ -14,10 +14,19 @@ dependencies {
compileOnly("net.kyori:adventure-text-minimessage:4.23.0") compileOnly("net.kyori:adventure-text-minimessage:4.23.0")
compileOnly("com.gitlab.ruany:LiteBansAPI:0.3.5") compileOnly("com.gitlab.ruany:LiteBansAPI:0.3.5")
compileOnly("com.alttd.proxydiscordlink:ProxyDiscordLink:1.0.1-SNAPSHOT") 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 { tasks {
test {
useJUnitPlatform()
}
shadowJar { shadowJar {
archiveFileName.set("${rootProject.name}-${project.name}-${project.version}.jar") archiveFileName.set("${rootProject.name}-${project.name}-${project.version}.jar")
// minimize() // minimize()

View File

@ -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.ChatLogHandler;
import com.alttd.chat.objects.chat_log.WebHandler; import com.alttd.chat.objects.chat_log.WebHandler;
import com.alttd.chat.util.ALogger; 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.commands.*;
import com.alttd.velocitychat.handlers.ChatHandler; import com.alttd.velocitychat.handlers.ChatHandler;
import com.alttd.velocitychat.handlers.ServerHandler; import com.alttd.velocitychat.handlers.ServerHandler;
@ -50,6 +53,7 @@ public class VelocityChat {
private ServerHandler serverHandler; private ServerHandler serverHandler;
private ChannelIdentifier channelIdentifier; private ChannelIdentifier channelIdentifier;
private SseSubscribeClient sseSubscribeClient;
@Inject @Inject
public VelocityChat(ProxyServer proxyServer, Logger proxyLogger, @DataDirectory Path proxydataDirectory) { public VelocityChat(ProxyServer proxyServer, Logger proxyLogger, @DataDirectory Path proxydataDirectory) {
@ -71,7 +75,7 @@ public class VelocityChat {
WebHandler webHandler = new WebHandler(); WebHandler webHandler = new WebHandler();
ChatLogHandler chatLogHandler = new ChatLogHandler(webHandler, true); 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 ChatListener());
server.getEventManager().register(this, new ProxyPlayerListener()); server.getEventManager().register(this, new ProxyPlayerListener());
new LiteBansListener().init(); // init the litebans api listeners new LiteBansListener().init(); // init the litebans api listeners
@ -84,6 +88,18 @@ public class VelocityChat {
ChatUser console = new ChatUser(Config.CONSOLEUUID, -1, null); ChatUser console = new ChatUser(Config.CONSOLEUUID, -1, null);
console.setDisplayName(Config.CONSOLENAME); console.setDisplayName(Config.CONSOLENAME);
ChatUserManager.addUser(console); 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() { public void reloadConfig() {

View File

@ -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<String> 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";
}
}

View File

@ -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<PunishFromWeb> {
private final ProxyServer server;
public PunishFromWebHandler(ProxyServer server) {
this.server = server;
}
@Override
public Class<PunishFromWeb> 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)
);
});
}
}

View File

@ -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<PartyChatFromWeb> {
private final ChatHandler chatHandler;
@Override
public Class<PartyChatFromWeb> 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);
}
}

View File

@ -16,6 +16,7 @@ import com.google.common.io.ByteStreams;
import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.command.CommandSource;
import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ServerConnection; import com.velocitypowered.api.proxy.ServerConnection;
import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.ComponentLike; import net.kyori.adventure.text.ComponentLike;
import net.kyori.adventure.text.TextReplacementConfig; 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.minimessage.tag.resolver.TagResolver;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; 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.jetbrains.annotations.Nullable;
import org.jspecify.annotations.NonNull;
import java.time.Duration; import java.time.Duration;
import java.util.Date; import java.util.Date;
@ -32,12 +36,15 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@Slf4j
public class ChatHandler { public class ChatHandler {
private final ChatLogHandler chatLogHandler; private final ChatLogHandler chatLogHandler;
private final LuckPerms luckPerms;
public ChatHandler(ChatLogHandler chatLogHandler) { public ChatHandler(ChatLogHandler chatLogHandler, LuckPerms luckPerms) {
this.chatLogHandler = chatLogHandler; this.chatLogHandler = chatLogHandler;
this.luckPerms = luckPerms;
} }
public void privateMessage(String sender, String target, String message) { 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<ParsedPartyMessage> 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) { 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<Player> optionalPlayer = VelocityChat.getPlugin().getProxy().getPlayer(uuid); Optional<Player> optionalPlayer = VelocityChat.getPlugin().getProxy().getPlayer(uuid);
if (optionalPlayer.isEmpty()) { if (optionalPlayer.isEmpty()) {
return; return;
} }
Player player = optionalPlayer.get(); Player player = optionalPlayer.get();
ChatUser user = ChatUserManager.getChatUser(uuid); ChatUser chatUser = ChatUserManager.getChatUser(uuid);
Party party = PartyManager.getParty(user.getPartyId()); Party party = PartyManager.getParty(chatUser.getPartyId());
if (party == null) { if (party == null) {
player.sendMessage(Utility.parseMiniMessage(Config.NOT_IN_A_PARTY)); player.sendMessage(Utility.parseMiniMessage(Config.NOT_IN_A_PARTY));
return; return;
} }
ComponentLike senderName = user.getDisplayName(); ComponentLike senderName = chatUser.getDisplayName();
TagResolver placeholders = TagResolver.resolver( Optional<ParsedPartyMessage> optionalParsedPartyMessage = getResult(uuid,
Placeholder.component("sender", senderName), message,
Placeholder.component("sendername", senderName), item,
Placeholder.unparsed("partyname", party.getPartyName()), serverConnection,
Placeholder.component("message", parseMessageContent(player, message)), senderName,
Placeholder.unparsed("server", serverConnection.getServer().getServerInfo().getName()) party,
player.getUsername(),
user,
player
); );
if (optionalParsedPartyMessage.isEmpty()) {
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);
return; // the message was blocked 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, chatLogHandler.addChatLog(uuid,
serverConnection.getServer().getServerInfo().getName(), serverConnection.getServer().getServerInfo().getName(),
PlainTextComponentSerializer.plainText().serialize(partyMessage), PlainTextComponentSerializer.plainText().serialize(parsedPartyMessage.partyMessage()),
ChatLogType.PARTY, ChatLogType.PARTY,
String.valueOf(party.getPartyId()), String.valueOf(party.getPartyId()),
null, null,
partyMessage, parsedPartyMessage.partyMessage(),
false false
); );
ComponentLike spyMessage = Utility.parseMiniMessage(Config.PARTY_SPY, placeholders); ComponentLike spyMessage = Utility.parseMiniMessage(Config.PARTY_SPY, parsedPartyMessage.placeholders());
for (Player pl : serverConnection.getServer().getPlayersConnected()) { for (Player pl : serverConnection.getServer().getPlayersConnected()) {
if (pl.hasPermission(Config.SPYPERMISSION) && !party.getPartyUsersUuid().contains(pl.getUniqueId())) { if (pl.hasPermission(Config.SPYPERMISSION) && !party.getPartyUsersUuid().contains(pl.getUniqueId())) {
pl.sendMessage(spyMessage); pl.sendMessage(spyMessage);
} }
} }
ALogger.info(PlainTextComponentSerializer.plainText().serialize(partyMessage)); ALogger.info(PlainTextComponentSerializer.plainText().serialize(parsedPartyMessage.partyMessage()));
}
private Optional<ParsedPartyMessage> 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) { public void globalAdminChat(String message) {
@ -215,11 +289,11 @@ public class ChatHandler {
ComponentLike senderName = Component.text(Config.CONSOLENAME); ComponentLike senderName = Component.text(Config.CONSOLENAME);
String serverName = "Altitude"; String serverName = "Altitude";
if (commandSource instanceof Player sender) { if (commandSource instanceof Player sender) {
ChatUser user = ChatUserManager.getChatUser(sender.getUniqueId()); ChatUser chatUser = ChatUserManager.getChatUser(sender.getUniqueId());
if (user == null) { if (chatUser == null) {
return; return;
} }
senderName = user.getDisplayName(); senderName = chatUser.getDisplayName();
serverName = sender.getCurrentServer().isPresent() ? sender.getCurrentServer() serverName = sender.getCurrentServer().isPresent() ? sender.getCurrentServer()
.get() .get()
.getServerInfo() .getServerInfo()
@ -347,15 +421,31 @@ public class ChatHandler {
return stringBuilder.toString(); return stringBuilder.toString();
} }
private Component parseMessageContent(CommandSource source, String rawMessage) { private Component parseMessageContent(User user, String rawMessage) {
TagResolver.Builder tagResolver = TagResolver.builder(); TagResolver.Builder tagResolver = TagResolver.builder();
Utility.formattingPerms.forEach((perm, pair) -> { Utility.formattingPerms.forEach((perm, pair) -> {
if (source.hasPermission(perm)) { if (Utility.hasPermission(user, perm)) {
tagResolver.resolver(pair.getX()); 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(); MiniMessage miniMessage = MiniMessage.builder().tags(tagResolver.build()).build();
Component component = miniMessage.deserialize(rawMessage); Component component = miniMessage.deserialize(rawMessage);
for (ChatFilter chatFilter : RegexManager.getEmoteFilters()) { for (ChatFilter chatFilter : RegexManager.getEmoteFilters()) {
@ -367,6 +457,5 @@ public class ChatHandler {
} }
return component; return component;
} }
} }

View File

@ -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) {
}

View File

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