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:
2026-08-09 19:39:55 +02:00
parent 0d2ac3763f
commit 9e647f83a8
26 changed files with 900 additions and 169 deletions
+9
View File
@@ -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()
@@ -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() {
@@ -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";
}
}
@@ -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)
);
});
}
}
@@ -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);
}
}
@@ -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<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) {
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);
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<ParsedPartyMessage> 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<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) {
@@ -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;
}
}
@@ -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) {
}
@@ -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"));
}
}