diff --git a/paper/CHANGELOG.md b/paper/CHANGELOG.md index 2c464ed..f151d91 100644 --- a/paper/CHANGELOG.md +++ b/paper/CHANGELOG.md @@ -5,4 +5,8 @@ - When enabled, claimed zombie villagers won't take damage or attack. - Config option `claimed_damage` no longer applies to claimed zombie villagers. - Config option `claimed_damage` will now also update when config is updated via commands. -- Added new permission `clickvillagers.change-biome` to manage changing the biome of villagers via the menu. \ No newline at end of file +- Added new permission `clickvillagers.change-biome` to manage changing the biome of villagers via the menu. +## Interaction audit logging +- Added optional console logs for successful player villager pickups and placements. +- Added optional asynchronous Discord webhook notifications for those interactions. +- Both features are disabled by default and only affect the server plugin. diff --git a/paper/src/main/java/de/clickism/clickvillagers/ClickVillagers.java b/paper/src/main/java/de/clickism/clickvillagers/ClickVillagers.java index 2b98ab9..496909c 100644 --- a/paper/src/main/java/de/clickism/clickvillagers/ClickVillagers.java +++ b/paper/src/main/java/de/clickism/clickvillagers/ClickVillagers.java @@ -73,7 +73,9 @@ public void onEnable() { MenuManager menuManager = new MenuManager(this); ClaimManager claimManager = new ClaimManager(this); AnchorManager anchorHandler = new AnchorManager(this); - PickupManager pickupManager = new PickupManager(this, new SnapshotSaver(), claimManager, anchorHandler); + InteractionAuditLogger interactionAuditLogger = new InteractionAuditLogger(); + PickupManager pickupManager = new PickupManager(this, new SnapshotSaver(), claimManager, anchorHandler, + interactionAuditLogger); HopperManager hopperManager = new HopperManager(this, pickupManager, claimManager); // Set up config listeners HOPPER_TICK_RATE.onChange(ticks -> hopperManager.restartTasks()); @@ -89,7 +91,8 @@ public void onEnable() { CooldownManager cooldownManager = new CooldownManager(() -> COOLDOWN.get() * 1000L); new InteractListener(this, claimManager, pickupManager, - anchorHandler, partnerManager, chatInputListener, menuManager, cooldownManager); + anchorHandler, partnerManager, chatInputListener, menuManager, cooldownManager, + interactionAuditLogger); new DispenserListener(this, pickupManager); new TradeListener(this); diff --git a/paper/src/main/java/de/clickism/clickvillagers/ClickVillagersConfig.java b/paper/src/main/java/de/clickism/clickvillagers/ClickVillagersConfig.java index b49be8d..fa7bc2e 100644 --- a/paper/src/main/java/de/clickism/clickvillagers/ClickVillagersConfig.java +++ b/paper/src/main/java/de/clickism/clickvillagers/ClickVillagersConfig.java @@ -18,7 +18,7 @@ public interface ClickVillagersConfig { Config CONFIG = Config.of("plugins/ClickVillagers/config.yml") - .version(8) + .version(9) .keyGenerator(KeyGenerator.withAlternative(key -> key.replace('-', '_'))) .header(""" --------------------------------------------------------- @@ -199,6 +199,23 @@ public interface ClickVillagersConfig { Whether dispensers can dispense picked up villagers. """); + ConfigOption LOG_VILLAGER_INTERACTIONS = + CONFIG.option("log_villager_interactions", false) + .header(""" + --------------------------------------------------------- + Interaction Logging + --------------------------------------------------------- + """) + .description("Whether successful player villager pickups and placements are logged to the console."); + + ConfigOption DISCORD_WEBHOOK_ENABLED = + CONFIG.option("discord_webhook_enabled", false) + .description("Whether successful player villager pickups and placements are sent to Discord."); + + ConfigOption DISCORD_WEBHOOK_URL = + CONFIG.option("discord_webhook_url", "") + .description("Discord webhook URL used when discord_webhook_enabled is true."); + ConfigOption> CUSTOM_MODEL_DATAS = CONFIG.option("custom_model_datas", (Map) new HashMap<>(Map.of( "baby", 0, diff --git a/paper/src/main/java/de/clickism/clickvillagers/listener/InteractListener.java b/paper/src/main/java/de/clickism/clickvillagers/listener/InteractListener.java index 9b9527f..85b40bd 100644 --- a/paper/src/main/java/de/clickism/clickvillagers/listener/InteractListener.java +++ b/paper/src/main/java/de/clickism/clickvillagers/listener/InteractListener.java @@ -46,12 +46,13 @@ public class InteractListener implements Listener { private final ChatInputListener chatInputListener; private final MenuManager menuManager; private final CooldownManager cooldownManager; + private final InteractionAuditLogger interactionAuditLogger; @AutoRegistered public InteractListener(JavaPlugin plugin, ClaimManager claimManager, PickupManager pickupManager, AnchorManager anchorManager, PartnerManager partnerManager, ChatInputListener chatInputListener, MenuManager menuManager, - CooldownManager cooldownManager) { + CooldownManager cooldownManager, InteractionAuditLogger interactionAuditLogger) { this.claimManager = claimManager; this.pickupManager = pickupManager; this.anchorManager = anchorManager; @@ -59,6 +60,7 @@ public InteractListener(JavaPlugin plugin, ClaimManager claimManager, PickupMana this.chatInputListener = chatInputListener; this.menuManager = menuManager; this.cooldownManager = cooldownManager; + this.interactionAuditLogger = interactionAuditLogger; plugin.getServer().getPluginManager().registerEvents(this, plugin); } @@ -126,6 +128,7 @@ private void onVehicleInteract(PlayerInteractEntityEvent event) { world.playSound(player, Sound.BLOCK_WOOD_BREAK, 1, .5f); } entity.addPassenger(villager); + interactionAuditLogger.place(player, villager); } catch (IllegalArgumentException exception) { Message.READ_ERROR.send(player); ClickVillagers.LOGGER.severe("Failed to read villager data: " + exception.getMessage()); @@ -211,6 +214,7 @@ private void handlePickup(Player player, LivingEntity villager) { return; } ItemStack item; + InteractionAuditLogger.VillagerDetails villagerDetails = interactionAuditLogger.capture(villager); try { item = pickupManager.toItemStack(villager); } catch (IllegalArgumentException exception) { @@ -222,5 +226,6 @@ private void handlePickup(Player player, LivingEntity villager) { Message.PICK_UP_VILLAGER.sendActionbarSilently(player); pickupManager.sendPickupEffect(villager); cooldownManager.giveCooldown(player); + interactionAuditLogger.pickup(player, villagerDetails); } } diff --git a/paper/src/main/java/de/clickism/clickvillagers/listener/InteractionAuditLogger.java b/paper/src/main/java/de/clickism/clickvillagers/listener/InteractionAuditLogger.java new file mode 100644 index 0000000..d762959 --- /dev/null +++ b/paper/src/main/java/de/clickism/clickvillagers/listener/InteractionAuditLogger.java @@ -0,0 +1,102 @@ +/* + * Copyright 2025 Clickism + * Released under the GNU General Public License 3.0. + * See LICENSE.md for details. + */ + +package de.clickism.clickvillagers.listener; + +import de.clickism.clickvillagers.ClickVillagers; +import de.clickism.clickvillagers.util.Utils; +import org.bukkit.Location; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Locale; +import java.util.UUID; + +import static de.clickism.clickvillagers.ClickVillagersConfig.DISCORD_WEBHOOK_ENABLED; +import static de.clickism.clickvillagers.ClickVillagersConfig.DISCORD_WEBHOOK_URL; +import static de.clickism.clickvillagers.ClickVillagersConfig.LOG_VILLAGER_INTERACTIONS; + +public class InteractionAuditLogger { + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + public VillagerDetails capture(LivingEntity villager) { + Location location = villager.getLocation(); + String customName = villager.getCustomName(); + return new VillagerDetails( + villager.getUniqueId(), + villager.getType().key().asMinimalString(), + Utils.getVillagerProfession(villager).key().asMinimalString(), + customName == null ? "unnamed" : customName, + location.getWorld() == null ? "unknown" : location.getWorld().getName(), + location.getX(), location.getY(), location.getZ() + ); + } + + public void pickup(Player player, VillagerDetails villager) { + publish("PICKUP", player, villager); + } + + public void place(Player player, LivingEntity villager) { + publish("PLACE", player, capture(villager)); + } + + private void publish(String action, Player player, VillagerDetails villager) { + String message = String.format(Locale.ROOT, + "[Villager %s] player=%s player_uuid=%s villager_uuid=%s type=%s profession=%s name=%s location=%s %.1f %.1f %.1f", + action, player.getName(), player.getUniqueId(), villager.uuid(), villager.type(), + villager.profession(), villager.name(), villager.world(), villager.x(), villager.y(), villager.z()); + + if (LOG_VILLAGER_INTERACTIONS.get()) { + ClickVillagers.LOGGER.info(message); + } + if (!DISCORD_WEBHOOK_ENABLED.get()) return; + + String webhookUrl = DISCORD_WEBHOOK_URL.get().trim(); + if (webhookUrl.isEmpty()) { + ClickVillagers.LOGGER.warning("Discord interaction webhook is enabled, but discord_webhook_url is empty."); + return; + } + + try { + HttpRequest request = HttpRequest.newBuilder(URI.create(webhookUrl)) + .timeout(Duration.ofSeconds(10)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("{\"content\":\"" + escapeJson(message) + + "\",\"allowed_mentions\":{\"parse\":[]}}")) + .build(); + httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()) + .thenAccept(response -> { + if (response.statusCode() < 200 || response.statusCode() >= 300) { + ClickVillagers.LOGGER.warning("Discord interaction webhook returned HTTP " + response.statusCode()); + } + }) + .exceptionally(exception -> { + ClickVillagers.LOGGER.warning("Failed to send Discord interaction webhook: " + exception.getMessage()); + return null; + }); + } catch (IllegalArgumentException exception) { + ClickVillagers.LOGGER.warning("Invalid Discord interaction webhook URL: " + exception.getMessage()); + } + } + + private String escapeJson(String value) { + return value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\r", "\\r") + .replace("\n", "\\n"); + } + + public record VillagerDetails(UUID uuid, String type, String profession, String name, + String world, double x, double y, double z) { + } +} diff --git a/paper/src/main/java/de/clickism/clickvillagers/villager/PickupManager.java b/paper/src/main/java/de/clickism/clickvillagers/villager/PickupManager.java index a7d61d7..0422fb2 100644 --- a/paper/src/main/java/de/clickism/clickvillagers/villager/PickupManager.java +++ b/paper/src/main/java/de/clickism/clickvillagers/villager/PickupManager.java @@ -11,6 +11,7 @@ import de.clickism.clickvillagers.command.Permission; import de.clickism.clickvillagers.entity.EntitySaver; import de.clickism.clickvillagers.listener.AutoRegistered; +import de.clickism.clickvillagers.listener.InteractionAuditLogger; import de.clickism.clickvillagers.message.Message; import de.clickism.clickvillagers.util.DataVersionUtil; import de.clickism.clickvillagers.util.Utils; @@ -45,12 +46,15 @@ public class PickupManager implements Listener { private final EntitySaver entitySaver; private final ClaimManager claimManager; private final AnchorManager anchorManager; + private final InteractionAuditLogger interactionAuditLogger; @AutoRegistered - public PickupManager(JavaPlugin plugin, EntitySaver entitySaver, ClaimManager claimManager, AnchorManager anchorManager) { + public PickupManager(JavaPlugin plugin, EntitySaver entitySaver, ClaimManager claimManager, AnchorManager anchorManager, + InteractionAuditLogger interactionAuditLogger) { this.entitySaver = entitySaver; this.claimManager = claimManager; this.anchorManager = anchorManager; + this.interactionAuditLogger = interactionAuditLogger; plugin.getServer().getPluginManager().registerEvents(this, plugin); } @@ -93,8 +97,9 @@ private void onPlace(BlockPlaceEvent event) { location.setYaw((yaw + 360) % 360 - 180); // Face the villager towards the player try { ItemStack item = itemResult.item(); - spawnFromItemStack(item, location); + LivingEntity villager = spawnFromItemStack(item, location); itemResult.decrementAmount(inventory); + interactionAuditLogger.place(player, villager); World world = player.getWorld(); world.playSound(location, Sound.ENTITY_PLAYER_ATTACK_WEAK, 1, .5f); Block blockBelow = block.getRelative(BlockFace.DOWN); @@ -229,4 +234,4 @@ void decrementAmount(PlayerInventory inventory) { inventory.setItem(slot, item); } } -} \ No newline at end of file +}