diff --git a/build.gradle b/build.gradle index 09e72c1..836e9f0 100644 --- a/build.gradle +++ b/build.gradle @@ -19,6 +19,21 @@ configurations { loom { accessWidenerPath = file("src/main/resources/pingspam.accesswidener") + + runs { + clientTester1 { + client() + ideConfigGenerated true + name = "Client (Tester 1)" + programArgs("--username", "Tester1") + } + clientTester2 { + client() + ideConfigGenerated true + name = "Client (Tester 2)" + programArgs("--username", "Tester2") + } + } } repositories { diff --git a/src/main/java/me/basiqueevangelist/pechkin/ConfigManager.java b/src/main/java/me/basiqueevangelist/pechkin/ConfigManager.java new file mode 100644 index 0000000..571794a --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/ConfigManager.java @@ -0,0 +1,52 @@ +package me.basiqueevangelist.pechkin; + + +import blue.endless.jankson.Jankson; +import blue.endless.jankson.api.SyntaxError; +import net.fabricmc.loader.api.FabricLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +public class ConfigManager { + private static final Jankson JANKSON = Jankson.builder().build(); + private static final Logger LOGGER = LoggerFactory.getLogger("Pechkin/ConfigManager"); + + private PechkinConfig config = new PechkinConfig(); + + public ConfigManager() { + load(); + } + + public PechkinConfig getConfig() { + return config; + } + + public void load() { + Path confPath = FabricLoader.getInstance().getConfigDir().resolve("pechkin.json5"); + if (Files.exists(confPath)) { + try { + config = JANKSON.fromJson(JANKSON.load(confPath.toFile()), PechkinConfig.class); + } catch (IOException | SyntaxError e) { + LOGGER.error("Could not load config file!", e); + } + } else { + save(); + } + } + + public void save() { + Path confPath = FabricLoader.getInstance().getConfigDir().resolve("pechkin.json5"); + try { + try (BufferedWriter bw = Files.newBufferedWriter(confPath)) { + bw.write(JANKSON.toJson(config).toJson(true, true)); + } + } catch (IOException e) { + LOGGER.error("Could not load config file!", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/me/basiqueevangelist/pechkin/Pechkin.java b/src/main/java/me/basiqueevangelist/pechkin/Pechkin.java new file mode 100644 index 0000000..c1fe281 --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/Pechkin.java @@ -0,0 +1,36 @@ +package me.basiqueevangelist.pechkin; + +import me.basiqueevangelist.onedatastore.api.Component; +import me.basiqueevangelist.onedatastore.api.PlayerDataEntry; +import me.basiqueevangelist.pechkin.command.*; +import me.basiqueevangelist.pechkin.data.PechkinPlayerData; +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.Identifier; + +import java.lang.ref.WeakReference; + +public class Pechkin implements ModInitializer { + public static final ConfigManager CONFIG = new ConfigManager(); + public static WeakReference server; + + public static final Component PLAYER_DATA = Component.registerPlayer(new Identifier("pechkin", "player_data"), PechkinPlayerData::new); + + @Override + public void onInitialize() { + ServerLifecycleEvents.SERVER_STARTING.register(s -> { + server = new WeakReference<>(s); + }); + + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> { + SendCommand.register(dispatcher); + ListCommand.register(dispatcher); + DeleteCommand.register(dispatcher); + IgnoreCommand.register(dispatcher); + ClearCommand.register(dispatcher); + ReloadCommand.register(dispatcher); + }); + } +} diff --git a/src/main/java/me/basiqueevangelist/pechkin/PechkinConfig.java b/src/main/java/me/basiqueevangelist/pechkin/PechkinConfig.java new file mode 100644 index 0000000..e6af792 --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/PechkinConfig.java @@ -0,0 +1,17 @@ +package me.basiqueevangelist.pechkin; + +import blue.endless.jankson.Comment; + +public class PechkinConfig { + @Comment("The maximum amount of messages a player's inbox can have.") + public int maxInboxMessages = 100; + + @Comment("The amount of players that will be stored in the player's correspondents list. Used for command suggestions.") + public int maxCorrespondents = 10; + + @Comment("The maximum amount of time debt a player's leaky bucket can have.") + public int maxTimeDebt = 120; + + @Comment("The cost of sending a single message.") + public int sendCost = 30; +} diff --git a/src/main/java/me/basiqueevangelist/pechkin/command/ClearCommand.java b/src/main/java/me/basiqueevangelist/pechkin/command/ClearCommand.java new file mode 100644 index 0000000..c09a12c --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/command/ClearCommand.java @@ -0,0 +1,68 @@ +package me.basiqueevangelist.pechkin.command; + +import com.mojang.authlib.GameProfile; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import me.basiqueevangelist.onedatastore.api.DataStore; +import me.basiqueevangelist.pechkin.Pechkin; +import me.basiqueevangelist.pechkin.data.PechkinPlayerData; +import me.basiqueevangelist.pechkin.util.CommandUtil; +import me.basiqueevangelist.pingspam.utils.NameUtil; +import me.lucko.fabric.api.permissions.v0.Permissions; +import net.minecraft.command.argument.GameProfileArgumentType; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.*; +import net.minecraft.util.Formatting; + +import static net.minecraft.server.command.CommandManager.argument; +import static net.minecraft.server.command.CommandManager.literal; + +public final class ClearCommand { + private ClearCommand() { + + } + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(literal("mail") + .then(literal("clear") + .executes(ClearCommand::clear) + .then(argument("player", GameProfileArgumentType.gameProfile()) + .requires(Permissions.require("pechkin.clear.other", 2)) + .suggests(CommandUtil::suggestPlayersExceptSelf) + .executes(ClearCommand::clearOther)))); + } + + private static int clear(CommandContext ctx) throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + ServerPlayerEntity player = src.getPlayer(); + PechkinPlayerData data = DataStore.getFor(src.getServer()).getPlayer(player.getUuid(), Pechkin.PLAYER_DATA); + + Text sent = Text.literal("Deleted " + data.messages().size() + " message" + (data.messages().size() == 1 ? "" : "s") + " from your inbox.") + .formatted(Formatting.GREEN); + + data.messages().clear(); + + src.sendFeedback(() -> sent, false); + + return 1; + } + + private static int clearOther(CommandContext ctx) throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + GameProfile player = CommandUtil.getOnePlayer(ctx, "player"); + PechkinPlayerData data = DataStore.getFor(src.getServer()).getPlayer(player.getId(), Pechkin.PLAYER_DATA); + + Text sent = Text.literal("Deleted " + data.messages().size() + " message" + (data.messages().size() == 1 ? "" : "s") + " from ") + .append(Text.literal(NameUtil.getNameFromUUID(player.getId())).formatted(Formatting.AQUA)) + .append("'s inbox.") + .formatted(Formatting.GREEN); + + data.messages().clear(); + + src.sendFeedback(() -> sent, true); + + return 1; + } +} diff --git a/src/main/java/me/basiqueevangelist/pechkin/command/DeleteCommand.java b/src/main/java/me/basiqueevangelist/pechkin/command/DeleteCommand.java new file mode 100644 index 0000000..a7e2bb8 --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/command/DeleteCommand.java @@ -0,0 +1,96 @@ +package me.basiqueevangelist.pechkin.command; + +import com.mojang.authlib.GameProfile; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; +import me.basiqueevangelist.onedatastore.api.DataStore; +import me.basiqueevangelist.pechkin.Pechkin; +import me.basiqueevangelist.pechkin.data.PechkinPlayerData; +import me.basiqueevangelist.pechkin.hack.StateTracker; +import me.basiqueevangelist.pechkin.util.CommandUtil; +import me.lucko.fabric.api.permissions.v0.Permissions; +import net.minecraft.command.argument.GameProfileArgumentType; +import net.minecraft.command.argument.UuidArgumentType; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; + +import java.util.UUID; + +import static net.minecraft.server.command.CommandManager.argument; +import static net.minecraft.server.command.CommandManager.literal; + +public final class DeleteCommand { + private static final SimpleCommandExceptionType MESSAGE_DOESNT_EXIST = new SimpleCommandExceptionType(Text.literal("No such message")); + + private DeleteCommand() { + + } + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(literal("mail") + .then(literal("internal") + .requires(x -> !StateTracker.IS_IN_COMMAND_TREE_CREATION) + .then(literal("delete_list") + .then(argument("message", UuidArgumentType.uuid()) + .executes(DeleteCommand::deleteList))) + .then(literal("delete_list_other") + .requires(Permissions.require("pechkin.list.other", 2)) + .requires(Permissions.require("pechkin.delete.other", 2)) + .then(argument("player", GameProfileArgumentType.gameProfile()) + .then(argument("message", UuidArgumentType.uuid()) + .executes(DeleteCommand::deleteListOther)))) + .then(literal("delete_silent") + .then(argument("message", UuidArgumentType.uuid()) + .executes(DeleteCommand::deleteSilent))))); + } + + private static int deleteSilent(CommandContext ctx) throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + ServerPlayerEntity player = src.getPlayer(); + UUID messageId = UuidArgumentType.getUuid(ctx, "message"); + PechkinPlayerData data = DataStore.getFor(src.getServer()).getPlayer(player.getUuid(), Pechkin.PLAYER_DATA); + + data.messages().removeIf(x -> x.messageId().equals(messageId)); + + return 1; + } + + private static int deleteList(CommandContext ctx) throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + ServerPlayerEntity player = src.getPlayer(); + UUID messageId = UuidArgumentType.getUuid(ctx, "message"); + PechkinPlayerData data = DataStore.getFor(src.getServer()).getPlayer(player.getUuid(), Pechkin.PLAYER_DATA); + + if (!data.messages().removeIf(x -> x.messageId().equals(messageId))) { + throw MESSAGE_DOESNT_EXIST.create(); + } + + src.sendFeedback(() -> Text.literal("\n\n"), false); + + // Resend the list, since this command will only be invoked via list anyway 🚎 + ListCommand.list(ctx); + + return 1; + } + + private static int deleteListOther(CommandContext ctx) throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + UUID messageId = UuidArgumentType.getUuid(ctx, "message"); + GameProfile player = CommandUtil.getOnePlayer(ctx, "player"); + PechkinPlayerData data = DataStore.getFor(src.getServer()).getPlayer(player.getId(), Pechkin.PLAYER_DATA); + + if (!data.messages().removeIf(x -> x.messageId().equals(messageId))) { + throw MESSAGE_DOESNT_EXIST.create(); + } + + src.sendFeedback(() -> Text.literal("\n\n"), false); + + // Resend the list, since this command will only be invoked via list anyway 🚎 + ListCommand.listOther(ctx); + + return 1; + } +} diff --git a/src/main/java/me/basiqueevangelist/pechkin/command/IgnoreCommand.java b/src/main/java/me/basiqueevangelist/pechkin/command/IgnoreCommand.java new file mode 100644 index 0000000..5337918 --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/command/IgnoreCommand.java @@ -0,0 +1,146 @@ +package me.basiqueevangelist.pechkin.command; + +import com.mojang.authlib.GameProfile; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import me.basiqueevangelist.onedatastore.api.DataStore; +import me.basiqueevangelist.pechkin.Pechkin; +import me.basiqueevangelist.pechkin.data.PechkinPlayerData; +import me.basiqueevangelist.pechkin.util.CommandUtil; +import me.basiqueevangelist.pingspam.utils.NameUtil; +import net.minecraft.command.argument.GameProfileArgumentType; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import static net.minecraft.server.command.CommandManager.argument; +import static net.minecraft.server.command.CommandManager.literal; + +public final class IgnoreCommand { + private static SimpleCommandExceptionType SELF_IGNORE = new SimpleCommandExceptionType(Text.literal("Can't (un)ignore yourself!")); + private static SimpleCommandExceptionType ALREADY_IGNORED = new SimpleCommandExceptionType(Text.literal("You are already ignoring that player.")); + private static SimpleCommandExceptionType NOT_IGNORED = new SimpleCommandExceptionType(Text.literal("You aren't ignoring that player!")); + + private IgnoreCommand() { + + } + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(literal("mail") + .then(literal("ignore") + .then(literal("add") + .then(argument("player", GameProfileArgumentType.gameProfile()) + .suggests(IgnoreCommand::ignoreAddSuggest) + .executes(IgnoreCommand::ignoreAdd))) + .then(literal("remove") + .then(argument("player", GameProfileArgumentType.gameProfile()) + .suggests(IgnoreCommand::ignoreRemoveSuggest) + .executes(IgnoreCommand::ignoreRemove))) + .then(literal("list") + .executes(IgnoreCommand::ignoreList)))); + } + + private static int ignoreAdd(CommandContext ctx) throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + ServerPlayerEntity player = src.getPlayer(); + GameProfile offender = CommandUtil.getOnePlayer(ctx, "player"); + + if (offender.getId().equals(player.getUuid())) + throw SELF_IGNORE.create(); + + PechkinPlayerData data = DataStore.getFor(src.getServer()).getPlayer(player.getUuid(), Pechkin.PLAYER_DATA); + + if (data.ignoredPlayers().contains(offender.getId())) + throw ALREADY_IGNORED.create(); + + data.ignoredPlayers().add(offender.getId()); + + src.sendFeedback(() -> Text.literal("Ignoring any further messages from ") + .formatted(Formatting.GREEN) + .append(Text.literal(NameUtil.getNameFromUUID(offender.getId())).formatted(Formatting.AQUA)) + .append("."), false); + + return 1; + } + + private static int ignoreRemove(CommandContext ctx) throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + ServerPlayerEntity player = src.getPlayer(); + GameProfile offender = CommandUtil.getOnePlayer(ctx, "player"); + + if (offender.getId().equals(player.getUuid())) + throw SELF_IGNORE.create(); + + PechkinPlayerData data = DataStore.getFor(src.getServer()).getPlayer(player.getUuid(), Pechkin.PLAYER_DATA); + + if (!data.ignoredPlayers().remove(offender.getId())) + throw NOT_IGNORED.create(); + + src.sendFeedback(() -> Text.literal("Stopped ignoring messages from ") + .formatted(Formatting.YELLOW) + .append(Text.literal(NameUtil.getNameFromUUID(offender.getId())).formatted(Formatting.AQUA)) + .append("."), false); + + return 1; + } + + private static CompletableFuture ignoreAddSuggest(CommandContext ctx, SuggestionsBuilder builder) throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + ServerPlayerEntity player = src.getPlayer(); + PechkinPlayerData data = DataStore.getFor(src.getServer()).getPlayer(player.getUuid(), Pechkin.PLAYER_DATA); + + for (var playerId : data.lastCorrespondents()) { + builder.suggest(NameUtil.getNameFromUUID(playerId)); + } + + return builder.buildFuture(); + } + + private static CompletableFuture ignoreRemoveSuggest(CommandContext ctx, SuggestionsBuilder builder) throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + ServerPlayerEntity player = src.getPlayer(); + PechkinPlayerData data = DataStore.getFor(src.getServer()).getPlayer(player.getUuid(), Pechkin.PLAYER_DATA); + + for (var playerId : data.ignoredPlayers()) { + builder.suggest(NameUtil.getNameFromUUID(playerId)); + } + + return builder.buildFuture(); + } + + private static int ignoreList(CommandContext ctx) throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + ServerPlayerEntity player = src.getPlayer(); + + PechkinPlayerData data = DataStore.getFor(src.getServer()).getPlayer(player.getUuid(), Pechkin.PLAYER_DATA); + + MutableText playersBuilder = Text.literal(""); + boolean isFirst = true; + + for (UUID ignoredPlayer : data.ignoredPlayers()) { + if (!isFirst) + playersBuilder.append(", "); + isFirst = false; + playersBuilder.append(Text.literal(NameUtil.getNameFromUUID(ignoredPlayer)).formatted(Formatting.AQUA)); + } + + if (isFirst) + src.sendFeedback(() -> Text.literal("You aren't ignoring messages from anybody.").formatted(Formatting.GREEN), false); + else + src.sendFeedback(() -> Text.literal("You are ignoring messages from ") + .formatted(Formatting.GREEN) + .append(playersBuilder) + .append("."), false); + + return 1; + } +} diff --git a/src/main/java/me/basiqueevangelist/pechkin/command/ListCommand.java b/src/main/java/me/basiqueevangelist/pechkin/command/ListCommand.java new file mode 100644 index 0000000..cd023ff --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/command/ListCommand.java @@ -0,0 +1,98 @@ +package me.basiqueevangelist.pechkin.command; + +import com.mojang.authlib.GameProfile; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import me.basiqueevangelist.onedatastore.api.DataStore; +import me.basiqueevangelist.pechkin.Pechkin; +import me.basiqueevangelist.pechkin.data.MailMessage; +import me.basiqueevangelist.pechkin.data.PechkinPlayerData; +import me.basiqueevangelist.pechkin.util.CommandUtil; +import me.basiqueevangelist.pechkin.util.TimeUtils; +import me.basiqueevangelist.pingspam.utils.NameUtil; +import me.lucko.fabric.api.permissions.v0.Permissions; +import net.minecraft.command.argument.GameProfileArgumentType; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.*; +import net.minecraft.util.Formatting; + +import static net.minecraft.server.command.CommandManager.argument; +import static net.minecraft.server.command.CommandManager.literal; + +public final class ListCommand { + private ListCommand() { + + } + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(literal("mail") + .then(literal("list") + .executes(ListCommand::list) + .then(argument("player", GameProfileArgumentType.gameProfile()) + .suggests(CommandUtil::suggestPlayersExceptSelf) + .requires(Permissions.require("pechkin.list.other", 2)) + .executes(ListCommand::listOther))) + .executes(ListCommand::list)); + } + + public static int listOther(CommandContext ctx) throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + GameProfile player = CommandUtil.getOnePlayer(ctx, "player"); + PechkinPlayerData data = DataStore.getFor(src.getServer()).getPlayer(player.getId(), Pechkin.PLAYER_DATA); + Text playerName = Text.literal(player.getName()) + .formatted(Formatting.AQUA); + + MutableText complete = Text.literal("") + .append(playerName) + .append(" has " + data.messages().size() + " message" + (data.messages().size() != 1 ? "s" : "") + " stored:"); + + for (var message : data.messages()) { + complete.append(writeMessageDesc(message, playerName, "/mail internal delete_list_other " + player.getName() + " ")); + } + + src.sendFeedback(() -> complete, false); + + return 1; + } + + public static int list(CommandContext ctx) throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + ServerPlayerEntity player = src.getPlayer(); + PechkinPlayerData data = DataStore.getFor(src.getServer()).getPlayer(player.getUuid(), Pechkin.PLAYER_DATA); + + MutableText complete = Text.literal("You have " + data.messages().size() + " message" + (data.messages().size() != 1 ? "s" : "") + " stored:"); + + for (var message : data.messages()) { + complete.append(writeMessageDesc(message, player.getDisplayName(), "/mail internal delete_list ")); + } + + src.sendFeedback(() -> complete, false); + + return 1; + } + + private static Text writeMessageDesc(MailMessage msg, Text playerName, String deleteCmdPrefix) { + return Text.literal("\n[") + .append(Text.literal("✘") + .formatted(Formatting.RED) + .styled(x -> x + .withClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, deleteCmdPrefix + msg.messageId())) + .withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Text.literal("Delete message"))))) + .append(" ") + .append(Text.literal("i") + .formatted(Formatting.BLUE) + .styled(x -> x.withHoverEvent(HoverEvent.Action.SHOW_TEXT.buildHoverEvent( + Text.literal("Sent ") + .append(TimeUtils.formatTime(msg.sentAt())) + .append(" ago\nUUID: " + msg.messageId()) + )))) + .append("] ") + .append(Text.literal(NameUtil.getNameFromUUID(msg.sender())).formatted(Formatting.AQUA)) + .append(Text.literal(" -> ").formatted(Formatting.WHITE)) + .append(playerName.copy().formatted(Formatting.AQUA)) + .append(Text.literal(": ").formatted(Formatting.WHITE)) + .append(msg.contents()); + } +} diff --git a/src/main/java/me/basiqueevangelist/pechkin/command/ReloadCommand.java b/src/main/java/me/basiqueevangelist/pechkin/command/ReloadCommand.java new file mode 100644 index 0000000..cf9b027 --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/command/ReloadCommand.java @@ -0,0 +1,28 @@ +package me.basiqueevangelist.pechkin.command; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.context.CommandContext; +import me.basiqueevangelist.pechkin.Pechkin; +import me.lucko.fabric.api.permissions.v0.Permissions; +import net.minecraft.server.command.ServerCommandSource; + +import static net.minecraft.server.command.CommandManager.literal; + +public final class ReloadCommand { + private ReloadCommand() { + + } + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(literal("mail") + .then(literal("reload") + .requires(Permissions.require("pechkin.reload", 2)) + .executes(ReloadCommand::reload))); + } + + private static int reload(CommandContext ctx) { + Pechkin.CONFIG.load(); + + return 0; + } +} diff --git a/src/main/java/me/basiqueevangelist/pechkin/command/SendCommand.java b/src/main/java/me/basiqueevangelist/pechkin/command/SendCommand.java new file mode 100644 index 0000000..1064910 --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/command/SendCommand.java @@ -0,0 +1,105 @@ +package me.basiqueevangelist.pechkin.command; + +import com.mojang.authlib.GameProfile; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; +import me.basiqueevangelist.onedatastore.api.DataStore; +import me.basiqueevangelist.pechkin.Pechkin; +import me.basiqueevangelist.pechkin.data.MailMessage; +import me.basiqueevangelist.pechkin.data.PechkinPlayerData; +import me.basiqueevangelist.pechkin.logic.MailLogic; +import me.basiqueevangelist.pechkin.util.CommandUtil; +import me.lucko.fabric.api.permissions.v0.Permissions; +import net.minecraft.command.argument.GameProfileArgumentType; +import net.minecraft.command.argument.MessageArgumentType; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; + +import java.time.Instant; +import java.util.UUID; + +import static net.minecraft.server.command.CommandManager.argument; +import static net.minecraft.server.command.CommandManager.literal; + +public final class SendCommand { + private static final SimpleCommandExceptionType IGNORED = new SimpleCommandExceptionType(Text.literal("That player has ignored you.")); + private static final SimpleCommandExceptionType RATELIMIT = new SimpleCommandExceptionType(Text.literal("You are being rate limited.")); + private static final SimpleCommandExceptionType NO_CORRESPONDENT = new SimpleCommandExceptionType(Text.literal("No correspondents recorded yet")); + + private SendCommand() { + + } + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(literal("mail") + .then(literal("send") + .then(argument("player", GameProfileArgumentType.gameProfile()) + .suggests(CommandUtil::suggestPlayers) + .then(argument("message", MessageArgumentType.message()) + .requires(Permissions.require("pechkin.send", true)) + .executes(SendCommand::send))))); + + dispatcher.register(literal("r") + .then(argument("message", MessageArgumentType.message()) + .requires(Permissions.require("pechkin.send", true)) + .executes(SendCommand::reply))); + } + + private static int send(CommandContext ctx) throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + ServerPlayerEntity sender = src.getPlayer(); + GameProfile recipient = CommandUtil.getOnePlayer(ctx, "player"); + Text message = MessageArgumentType.getMessage(ctx, "message"); + + sendMessage(src, sender, recipient.getId(), message); + + return 1; + } + + private static int reply(CommandContext ctx) throws CommandSyntaxException { + ServerCommandSource src = ctx.getSource(); + ServerPlayerEntity sender = src.getPlayer(); + PechkinPlayerData senderData = DataStore.getFor(src.getServer()).getPlayer(sender.getUuid(), Pechkin.PLAYER_DATA); + Text message = MessageArgumentType.getMessage(ctx, "message"); + + if (senderData.lastCorrespondents().size() <= 0) + throw NO_CORRESPONDENT.create(); + + UUID recipientId = senderData.lastCorrespondents().get(0); + + sendMessage(src, sender, recipientId, message); + + return 1; + } + + private static void sendMessage(ServerCommandSource src, ServerPlayerEntity sender, UUID recipientId, Text message) throws CommandSyntaxException { + PechkinPlayerData senderData = DataStore.getFor(src.getServer()).getPlayer(sender.getUuid(), Pechkin.PLAYER_DATA); + PechkinPlayerData recipientData = DataStore.getFor(src.getServer()).getPlayer(recipientId, Pechkin.PLAYER_DATA); + + if (recipientData.ignoredPlayers().contains(sender.getUuid())) + throw IGNORED.create(); + + if (!Permissions.check(sender, "pechkin.bypass.cooldown", 2)) { + int sendCost = Pechkin.CONFIG.getConfig().sendCost; + + if (!senderData.leakyBucket().hasEnoughFor(sendCost)) + throw RATELIMIT.create(); + + senderData.leakyBucket().addTime(sendCost); + } + + if (!recipientId.equals(sender.getUuid())) + recipientData.addCorrespondent(sender.getUuid()); + + MailMessage mail = new MailMessage(message, sender.getUuid(), UUID.randomUUID(), Instant.now()); + recipientData.addMessage(mail); + MailLogic.notifyMailSent(recipientId, sender, mail); + + if (sender.getUuid().equals(recipientId)) return; + + MailLogic.notifyMailReceived(src.getServer(), recipientId, sender, mail); + } +} diff --git a/src/main/java/me/basiqueevangelist/pechkin/data/LeakyBucket.java b/src/main/java/me/basiqueevangelist/pechkin/data/LeakyBucket.java new file mode 100644 index 0000000..fd44ca5 --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/data/LeakyBucket.java @@ -0,0 +1,49 @@ +package me.basiqueevangelist.pechkin.data; + +import me.basiqueevangelist.pechkin.Pechkin; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.nbt.NbtElement; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +public final class LeakyBucket { + private Instant debtExpiryTime; + + public LeakyBucket() { + this(Instant.now()); + } + + public LeakyBucket(Instant debtExpiryTime) { + this.debtExpiryTime = debtExpiryTime; + } + + public void addTime(int cost) { + Instant now = Instant.now(); + if (debtExpiryTime.isBefore(now)) + debtExpiryTime = now.plus(cost, ChronoUnit.SECONDS); + else + debtExpiryTime = debtExpiryTime.plus(cost, ChronoUnit.SECONDS); + } + + public boolean hasEnoughFor(int cost) { + return Pechkin.CONFIG.getConfig().maxTimeDebt - Duration.between(Instant.now(), debtExpiryTime).get(ChronoUnit.SECONDS) > cost; + } + + public boolean isFull() { + return debtExpiryTime.isBefore(Instant.now()); + } + + public void fromTag(NbtCompound tag) { + if (tag.contains("LeakyBucketFillTime", NbtElement.LONG_TYPE)) { + debtExpiryTime = Instant.ofEpochMilli(tag.getLong("LeakyBucketFillTime")); + } + } + + public void toTag(NbtCompound tag) { + if (debtExpiryTime.isAfter(Instant.now())) { + tag.putLong("LeakyBucketFillTime", debtExpiryTime.toEpochMilli()); + } + } +} diff --git a/src/main/java/me/basiqueevangelist/pechkin/data/MailMessage.java b/src/main/java/me/basiqueevangelist/pechkin/data/MailMessage.java new file mode 100644 index 0000000..6524bf0 --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/data/MailMessage.java @@ -0,0 +1,27 @@ +package me.basiqueevangelist.pechkin.data; + +import net.minecraft.nbt.NbtCompound; +import net.minecraft.nbt.NbtHelper; +import net.minecraft.text.Text; + +import java.time.Instant; +import java.util.UUID; + +public record MailMessage(Text contents, UUID sender, UUID messageId, Instant sentAt) { + public static MailMessage fromTag(NbtCompound tag) { + Text contents = Text.Serializer.fromJson(tag.getString("Contents")); + UUID sender = tag.getUuid("Sender"); + UUID messageId = tag.getUuid("UUID"); + Instant sentAt = Instant.ofEpochMilli(tag.getLong("SentAt")); + + return new MailMessage(contents, sender, messageId, sentAt); + } + + public NbtCompound toTag(NbtCompound tag) { + tag.putString("Contents", Text.Serializer.toJson(contents)); + tag.put("Sender", NbtHelper.fromUuid(sender)); + tag.put("UUID", NbtHelper.fromUuid(messageId)); + tag.putLong("SentAt", sentAt.toEpochMilli()); + return tag; + } +} diff --git a/src/main/java/me/basiqueevangelist/pechkin/data/PechkinPlayerData.java b/src/main/java/me/basiqueevangelist/pechkin/data/PechkinPlayerData.java new file mode 100644 index 0000000..12de38a --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/data/PechkinPlayerData.java @@ -0,0 +1,100 @@ +package me.basiqueevangelist.pechkin.data; + +import me.basiqueevangelist.onedatastore.api.ComponentInstance; +import me.basiqueevangelist.onedatastore.api.PlayerDataEntry; +import me.basiqueevangelist.pechkin.Pechkin; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.nbt.NbtElement; +import net.minecraft.nbt.NbtHelper; +import net.minecraft.nbt.NbtList; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public record PechkinPlayerData( + List messages, + List ignoredPlayers, + List lastCorrespondents, + LeakyBucket leakyBucket +) implements ComponentInstance { + public PechkinPlayerData(PlayerDataEntry ignored) { + this(new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), new LeakyBucket()); + } + + public void fromTag(NbtCompound tag) { + var messagesTag = tag.getList("Messages", NbtElement.COMPOUND_TYPE); + + for (int i = 0; i < messagesTag.size(); i++) { + messages.add(MailMessage.fromTag(messagesTag.getCompound(i))); + } + + var ignoredPlayersTag = tag.getList("IgnoredPlayers", NbtElement.INT_ARRAY_TYPE); + + for (var ignoredPlayerTag : ignoredPlayersTag) { + ignoredPlayers.add(NbtHelper.toUuid(ignoredPlayerTag)); + } + + var lastCorrespondentsTag = tag.getList("LastCorrespondents", NbtElement.INT_ARRAY_TYPE); + + for (var correspondentTag : lastCorrespondentsTag) { + lastCorrespondents.add(NbtHelper.toUuid(correspondentTag)); + } + + leakyBucket.fromTag(tag); + } + + public NbtCompound toTag(NbtCompound tag) { + if (!messages.isEmpty()) { + var messagesTag = new NbtList(); + tag.put("Messages", messagesTag); + for (var message : messages) { + messagesTag.add(message.toTag(new NbtCompound())); + } + } + + if (!ignoredPlayers.isEmpty()) { + var ignoresTag = new NbtList(); + tag.put("IgnoredPlayers", ignoresTag); + for (var ignoredPlayer : ignoredPlayers) { + ignoresTag.add(NbtHelper.fromUuid(ignoredPlayer)); + } + } + + if (!lastCorrespondents.isEmpty()) { + var lastCorrespondentsTag = new NbtList(); + tag.put("LastCorrespondents", lastCorrespondentsTag); + for (var correspondent : lastCorrespondents) { + lastCorrespondentsTag.add(NbtHelper.fromUuid(correspondent)); + } + } + + leakyBucket.toTag(tag); + + return tag; + } + + public void addCorrespondent(UUID id) { + int maxCorrespondents = Pechkin.CONFIG.getConfig().maxCorrespondents; + + if (!lastCorrespondents.contains(id)) { + if (lastCorrespondents.size() >= maxCorrespondents) + lastCorrespondents.remove(maxCorrespondents - 1); + lastCorrespondents.add(0, id); + } else { + int index = lastCorrespondents.indexOf(id); + if (index != 0) { + lastCorrespondents.remove(index); + lastCorrespondents.add(0, id); + } + } + } + + public void addMessage(MailMessage msg) { + int maxMessages = Pechkin.CONFIG.getConfig().maxInboxMessages; + + if (messages.size() >= maxMessages) + messages.remove(maxMessages - 1); + messages.add(0, msg); + } +} diff --git a/src/main/java/me/basiqueevangelist/pechkin/hack/StateTracker.java b/src/main/java/me/basiqueevangelist/pechkin/hack/StateTracker.java new file mode 100644 index 0000000..fb27510 --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/hack/StateTracker.java @@ -0,0 +1,10 @@ +package me.basiqueevangelist.pechkin.hack; + +// HACK HACK HACK PLEASE FIX +public final class StateTracker { + private StateTracker() { + + } + + public static boolean IS_IN_COMMAND_TREE_CREATION = false; +} diff --git a/src/main/java/me/basiqueevangelist/pechkin/logic/MailLogic.java b/src/main/java/me/basiqueevangelist/pechkin/logic/MailLogic.java new file mode 100644 index 0000000..be83b46 --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/logic/MailLogic.java @@ -0,0 +1,52 @@ +package me.basiqueevangelist.pechkin.logic; + +import me.basiqueevangelist.pechkin.data.MailMessage; +import me.basiqueevangelist.pingspam.utils.NameUtil; +import me.basiqueevangelist.pingspam.utils.PingLogic; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.ClickEvent; +import net.minecraft.text.HoverEvent; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +import java.util.UUID; + +public final class MailLogic { + private MailLogic() { + + } + + public static void notifyMailReceived(MinecraftServer server, UUID receiverId, ServerPlayerEntity sender, MailMessage message) { + Text fancyMessage = Text.literal("") + .append(sender.getDisplayName().copy().formatted(Formatting.AQUA)) + .append(Text.literal(" -> ").formatted(Formatting.WHITE)) + .append(Text.literal(NameUtil.getNameFromUUID(receiverId)).formatted(Formatting.AQUA)) + .append(Text.literal(": ").formatted(Formatting.WHITE)) + .append(message.contents()) + .append(" [") + .append(Text.literal("✔") + .formatted(Formatting.GREEN) + .styled(x -> x + .withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Text.literal("Acknowledge and delete message"))) + .withClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, "/mail internal delete_silent " + message.messageId())))) + .append("]"); + + ServerPlayerEntity onlinePlayer = server.getPlayerManager().getPlayer(receiverId); + + if (onlinePlayer != null) { + onlinePlayer.sendMessage(fancyMessage); + } + + PingLogic.sendNotification(server, receiverId, fancyMessage); + } + + public static void notifyMailSent(UUID recipient, ServerPlayerEntity sender, MailMessage message) { + sender.sendMessage(Text.literal("") + .append(sender.getDisplayName().copy().formatted(Formatting.AQUA)) + .append(Text.literal(" -> ").formatted(Formatting.WHITE)) + .append(Text.literal(NameUtil.getNameFromUUID(recipient)).formatted(Formatting.AQUA)) + .append(Text.literal(": ").formatted(Formatting.WHITE)) + .append(message.contents())); + } +} diff --git a/src/main/java/me/basiqueevangelist/pechkin/util/CommandUtil.java b/src/main/java/me/basiqueevangelist/pechkin/util/CommandUtil.java new file mode 100644 index 0000000..3a6495c --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/util/CommandUtil.java @@ -0,0 +1,58 @@ +package me.basiqueevangelist.pechkin.util; + +import com.mojang.authlib.GameProfile; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import me.basiqueevangelist.onedatastore.api.DataStore; +import me.basiqueevangelist.pingspam.utils.NameUtil; +import net.minecraft.command.CommandSource; +import net.minecraft.command.argument.GameProfileArgumentType; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.text.Text; + +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public final class CommandUtil { + private static final SimpleCommandExceptionType TOO_MANY_PLAYERS = new SimpleCommandExceptionType(Text.literal("Can't mention many players at once!")); + + private CommandUtil() { + + } + + public static GameProfile getOnePlayer(CommandContext ctx, String argName) throws CommandSyntaxException { + Collection profiles = GameProfileArgumentType.getProfileArgument(ctx, "player"); + + if (profiles.size() > 1) + throw TOO_MANY_PLAYERS.create(); + + return profiles.iterator().next(); + } + + public static CompletableFuture suggestPlayersExceptSelf(CommandContext ctx, SuggestionsBuilder builder) throws CommandSyntaxException { + var playerNames = new HashSet<>(List.of(ctx.getSource().getServer().getPlayerNames())); + + for (var entry : DataStore.getFor(ctx.getSource().getServer()).players()) { + playerNames.add(NameUtil.getNameFromUUID(entry.playerId())); + } + + playerNames.remove(ctx.getSource().getPlayer().getEntityName()); + + return CommandSource.suggestMatching(playerNames, builder); + } + + public static CompletableFuture suggestPlayers(CommandContext ctx, SuggestionsBuilder builder) { + var playerNames = new HashSet<>(List.of(ctx.getSource().getServer().getPlayerNames())); + + for (var entry : DataStore.getFor(ctx.getSource().getServer()).players()) { + playerNames.add(NameUtil.getNameFromUUID(entry.playerId())); + } + + return CommandSource.suggestMatching(playerNames, builder); + } +} diff --git a/src/main/java/me/basiqueevangelist/pechkin/util/TimeUtils.java b/src/main/java/me/basiqueevangelist/pechkin/util/TimeUtils.java new file mode 100644 index 0000000..cf5f4a2 --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pechkin/util/TimeUtils.java @@ -0,0 +1,29 @@ +package me.basiqueevangelist.pechkin.util; + +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; + +import java.time.Duration; +import java.time.Instant; + +public final class TimeUtils { + private TimeUtils() { + + } + + public static MutableText formatTime(Instant time) { + var duration = Duration.between(time, Instant.now()); + + StringBuilder b = new StringBuilder(); + if (duration.toDaysPart() > 0) + b.append(duration.toDaysPart()).append("d"); + if (duration.toHoursPart() > 0) + b.append(duration.toHoursPart()).append("h"); + if (duration.toMinutesPart() > 0) + b.append(duration.toMinutesPart()).append("m"); + if (duration.toSecondsPart() > 0) + b.append(duration.toSecondsPart()).append("s"); + + return Text.literal(b.toString()); + } +} diff --git a/src/main/java/me/basiqueevangelist/pingspam/PingSpam.java b/src/main/java/me/basiqueevangelist/pingspam/PingSpam.java index 241183e..70f6654 100644 --- a/src/main/java/me/basiqueevangelist/pingspam/PingSpam.java +++ b/src/main/java/me/basiqueevangelist/pingspam/PingSpam.java @@ -4,6 +4,7 @@ import me.basiqueevangelist.onedatastore.api.DataStore; import me.basiqueevangelist.onedatastore.api.PlayerDataEntry; import me.basiqueevangelist.onedatastore.impl.OneDataStoreInit; +import me.basiqueevangelist.pechkin.Pechkin; import me.basiqueevangelist.pingspam.commands.PingSpamCommands; import me.basiqueevangelist.pingspam.data.PingspamGlobalData; import me.basiqueevangelist.pingspam.data.PingspamPlayerData; @@ -36,5 +37,7 @@ public void onInitialize() { ServerLifecycleEvents.SERVER_STARTED.register(server -> SERVER = server); ServerLifecycleEvents.SERVER_STOPPED.register(server -> SERVER = null); + + new Pechkin().onInitialize(); } } diff --git a/src/main/java/me/basiqueevangelist/pingspam/mixin/CommandManagerMixin.java b/src/main/java/me/basiqueevangelist/pingspam/mixin/CommandManagerMixin.java new file mode 100644 index 0000000..4bbac70 --- /dev/null +++ b/src/main/java/me/basiqueevangelist/pingspam/mixin/CommandManagerMixin.java @@ -0,0 +1,22 @@ +package me.basiqueevangelist.pingspam.mixin; + +import com.mojang.brigadier.tree.CommandNode; +import me.basiqueevangelist.pechkin.hack.StateTracker; +import net.minecraft.server.command.CommandManager; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(CommandManager.class) +public class CommandManagerMixin { + @Redirect(method = "makeTreeForSource", at = @At(value = "INVOKE", target = "Lcom/mojang/brigadier/tree/CommandNode;canUse(Ljava/lang/Object;)Z")) + private boolean canUse(CommandNode node, Object source) { + StateTracker.IS_IN_COMMAND_TREE_CREATION = true; + try { + return node.canUse(source); + } finally { + StateTracker.IS_IN_COMMAND_TREE_CREATION = false; + } + } + +} diff --git a/src/main/resources/pingspam.mixins.json b/src/main/resources/pingspam.mixins.json index 5413f5d..5bf1248 100644 --- a/src/main/resources/pingspam.mixins.json +++ b/src/main/resources/pingspam.mixins.json @@ -4,6 +4,7 @@ "package": "me.basiqueevangelist.pingspam.mixin", "compatibilityLevel": "JAVA_8", "mixins": [ + "CommandManagerMixin", "MinecraftServerMixin", "PlayerManagerMixin", "ServerPlayerEntityMixin",