Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion paper/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
---------------------------------------------------------
Expand Down Expand Up @@ -199,6 +199,23 @@ public interface ClickVillagersConfig {
Whether dispensers can dispense picked up villagers.
""");

ConfigOption<Boolean> 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<Boolean> DISCORD_WEBHOOK_ENABLED =
CONFIG.option("discord_webhook_enabled", false)
.description("Whether successful player villager pickups and placements are sent to Discord.");

ConfigOption<String> DISCORD_WEBHOOK_URL =
CONFIG.option("discord_webhook_url", "")
.description("Discord webhook URL used when discord_webhook_enabled is true.");

ConfigOption<Map<String, Integer>> CUSTOM_MODEL_DATAS =
CONFIG.option("custom_model_datas", (Map<String, Integer>) new HashMap<>(Map.of(
"baby", 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,21 @@ 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;
this.partnerManager = partnerManager;
this.chatInputListener = chatInputListener;
this.menuManager = menuManager;
this.cooldownManager = cooldownManager;
this.interactionAuditLogger = interactionAuditLogger;
plugin.getServer().getPluginManager().registerEvents(this, plugin);
}

Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -229,4 +234,4 @@ void decrementAmount(PlayerInventory inventory) {
inventory.setItem(slot, item);
}
}
}
}