From 926b2a548f0e4832d4940471b3cb48052e304459 Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Wed, 8 Oct 2025 09:45:24 +1100 Subject: [PATCH 01/16] Add Tebex integration for bridge routines --- zander-bridge/pom.xml | 28 ++- .../zander/bridge/ZanderBridge.java | 41 ---- .../zander/bridge/common/BridgeApiClient.java | 182 +++++++++++++++ .../zander/bridge/common/BridgeConfig.java | 123 ++++++++++ .../zander/bridge/common/BridgePlatform.java | 24 ++ .../zander/bridge/common/BridgeService.java | 106 +++++++++ .../zander/bridge/common/BridgeTask.java | 19 ++ .../zander/bridge/common/TaskStatus.java | 25 ++ .../common/tebex/TebexEventContext.java | 80 +++++++ .../common/tebex/TebexMetadataExtractor.java | 220 ++++++++++++++++++ .../bridge/events/PlayerJoinListener.java | 14 -- .../bridge/events/PlayerVoteListener.java | 71 ------ .../zander/bridge/model/BridgeProcess.java | 17 -- .../bridge/model/BridgeRoutineProcess.java | 19 -- .../zander/bridge/model/VoteProcess.java | 19 -- .../bridge/paper/PaperBridgePlatform.java | 82 +++++++ .../bridge/paper/PaperBridgePlugin.java | 137 +++++++++++ .../paper/tebex/TebexPaperIntegration.java | 214 +++++++++++++++++ .../zander/bridge/util/Bridge.java | 148 ------------ .../velocity/VelocityBridgePlatform.java | 85 +++++++ .../bridge/velocity/VelocityBridgePlugin.java | 209 +++++++++++++++++ .../tebex/TebexVelocityIntegration.java | 166 +++++++++++++ zander-bridge/src/main/resources/config.yml | 27 ++- zander-bridge/src/main/resources/plugin.yml | 12 +- .../src/main/resources/velocity-plugin.json | 18 ++ 25 files changed, 1735 insertions(+), 351 deletions(-) delete mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/ZanderBridge.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeApiClient.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeConfig.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgePlatform.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeTask.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/TaskStatus.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/tebex/TebexEventContext.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/tebex/TebexMetadataExtractor.java delete mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/events/PlayerJoinListener.java delete mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/events/PlayerVoteListener.java delete mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/model/BridgeProcess.java delete mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/model/BridgeRoutineProcess.java delete mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/model/VoteProcess.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlatform.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlugin.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/tebex/TebexPaperIntegration.java delete mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/util/Bridge.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlugin.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/tebex/TebexVelocityIntegration.java create mode 100644 zander-bridge/src/main/resources/velocity-plugin.json diff --git a/zander-bridge/pom.xml b/zander-bridge/pom.xml index c3976d6..ddb7d3e 100644 --- a/zander-bridge/pom.xml +++ b/zander-bridge/pom.xml @@ -55,6 +55,10 @@ papermc-repo https://repo.papermc.io/repository/maven-public/ + + velocitypowered-repo + https://repo.velocitypowered.com/snapshots/ + sonatype https://oss.sonatype.org/content/groups/public/ @@ -76,38 +80,32 @@ 1.21.4-R0.1-SNAPSHOT provided - - com.googlecode.json-simple - json-simple - 1.1.1 - compile - org.projectlombok lombok 1.18.30 provided + + com.velocitypowered + velocity-api + 3.2.0-SNAPSHOT + provided + io.github.ModularEnigma Requests 1.0.3 - - com.jayway.jsonpath - json-path - 2.9.0 - com.google.code.gson gson 2.10.1 - com.bencodez - votifierplus - LATEST - provided + org.yaml + snakeyaml + 2.2 diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/ZanderBridge.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/ZanderBridge.java deleted file mode 100644 index 82d183f..0000000 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/ZanderBridge.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.modularsoft.zander.bridge; - -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.TextComponent; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.plugin.java.JavaPlugin; -import org.modularsoft.zander.bridge.events.PlayerJoinListener; -import org.modularsoft.zander.bridge.events.PlayerVoteListener; -import org.modularsoft.zander.bridge.util.Bridge; - -public final class ZanderBridge extends JavaPlugin { - public static ZanderBridge plugin; - - @Override - public void onEnable() { - plugin = this; - - // Save the default config.yml if it doesn't exist - saveDefaultConfig(); - - // Initialize scheduler via Bridge class - Bridge.bridgeSyncAPICall(); - - getServer().getPluginManager().registerEvents(new PlayerJoinListener(), this); - getServer().getPluginManager().registerEvents(new PlayerVoteListener(), this); - - // Init Message - TextComponent enabledMessage = Component.empty() - .color(NamedTextColor.GREEN) - .append(Component.text("\n\nZander Bridge has been enabled.\n")) - .append(Component.text("Running Version " + plugin.getDescription().getVersion() + "\n")) - .append(Component.text("GitHub Repository: https://github.com/ModularSoftAU/zander\n")) - .append(Component.text("Created by Modular Software\n\n", NamedTextColor.DARK_PURPLE)); - getServer().sendMessage(enabledMessage); - } - - @Override - public void onDisable() { - // Plugin shutdown logic - } -} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeApiClient.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeApiClient.java new file mode 100644 index 0000000..6193c41 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeApiClient.java @@ -0,0 +1,182 @@ +package org.modularsoft.zander.bridge.common; + +import com.google.gson.*; +import io.github.ModularEnigma.Request; +import io.github.ModularEnigma.Response; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.*; + +public final class BridgeApiClient { + + private static final String BASE_ENDPOINT = "/api/bridge"; + + private final Gson gson = new GsonBuilder().create(); + private final String baseApiUrl; + private final String apiKey; + + public BridgeApiClient(String baseApiUrl, String apiKey) { + this.baseApiUrl = Objects.requireNonNull(baseApiUrl, "baseApiUrl"); + this.apiKey = Objects.requireNonNull(apiKey, "apiKey"); + } + + public List fetchTasks(String slug, boolean claim, int limit) throws IOException { + StringBuilder url = new StringBuilder(baseApiUrl) + .append(BASE_ENDPOINT) + .append("/processor/get?status=pending"); + + url.append("&limit=").append(limit); + if (slug != null && !slug.isBlank()) { + url.append("&slug=") + .append(URLEncoder.encode(slug, StandardCharsets.UTF_8)); + } + if (claim) { + url.append("&claim=true"); + } + + Request request = Request.builder() + .setURL(url.toString()) + .setMethod(Request.Method.GET) + .addHeader("x-access-token", apiKey) + .build(); + + Response response = request.execute(); + if (response.getStatusCode() >= 400) { + throw new IOException("Failed to fetch tasks: " + response.getStatusCode() + " - " + response.getBody()); + } + + return parseTasks(response.getBody()); + } + + public void reportTask(long taskId, + TaskStatus status, + String result, + String executedBy, + JsonElement metadata) throws IOException { + JsonObject payload = new JsonObject(); + payload.addProperty("status", status.toString()); + if (result != null) { + payload.addProperty("result", result); + } + if (executedBy != null) { + payload.addProperty("executedBy", executedBy); + } + if (metadata != null && !metadata.isJsonNull()) { + payload.add("metadata", metadata); + } + + Request request = Request.builder() + .setURL(baseApiUrl + BASE_ENDPOINT + "/processor/task/" + taskId + "/report") + .setMethod(Request.Method.POST) + .addHeader("x-access-token", apiKey) + .setRequestBody(gson.toJson(payload)) + .build(); + + Response response = request.execute(); + if (response.getStatusCode() >= 400) { + throw new IOException("Failed to report task " + taskId + ": " + response.getStatusCode() + " - " + response.getBody()); + } + } + + public void updateServerStatus(Map serverInfo, Instant lastUpdated) throws IOException { + JsonObject payload = new JsonObject(); + payload.add("serverInfo", gson.toJsonTree(serverInfo)); + payload.addProperty("lastUpdated", lastUpdated.toString()); + + Request request = Request.builder() + .setURL(baseApiUrl + BASE_ENDPOINT + "/server/update") + .setMethod(Request.Method.POST) + .addHeader("x-access-token", apiKey) + .setRequestBody(gson.toJson(payload)) + .build(); + + Response response = request.execute(); + if (response.getStatusCode() >= 400) { + throw new IOException("Failed to update server status: " + response.getStatusCode() + " - " + response.getBody()); + } + } + + public void queueRoutine(String routineSlug, + String targetSlug, + Map metadata, + Integer priority) throws IOException { + if (routineSlug == null || routineSlug.isBlank()) { + throw new IllegalArgumentException("routineSlug must not be blank"); + } + + JsonObject payload = new JsonObject(); + payload.addProperty("routineSlug", routineSlug); + if (targetSlug != null && !targetSlug.isBlank()) { + payload.addProperty("slug", targetSlug); + } + if (priority != null) { + payload.addProperty("priority", priority); + } + if (metadata != null && !metadata.isEmpty()) { + payload.add("metadata", gson.toJsonTree(metadata)); + } + + Request request = Request.builder() + .setURL(baseApiUrl + BASE_ENDPOINT + "/processor/command/add") + .setMethod(Request.Method.POST) + .addHeader("x-access-token", apiKey) + .setRequestBody(gson.toJson(payload)) + .build(); + + Response response = request.execute(); + if (response.getStatusCode() >= 400) { + throw new IOException("Failed to queue routine '" + routineSlug + "': " + response.getStatusCode() + " - " + response.getBody()); + } + } + + private List parseTasks(String body) { + JsonObject root = JsonParser.parseString(body).getAsJsonObject(); + JsonArray data = root.getAsJsonArray("data"); + List tasks = new ArrayList<>(); + + if (data == null) { + return tasks; + } + + for (JsonElement element : data) { + JsonObject obj = element.getAsJsonObject(); + long id = obj.get("executorTaskId").getAsLong(); + String slug = getAsString(obj, "slug"); + String command = getAsString(obj, "command"); + TaskStatus status = TaskStatus.fromString(getAsString(obj, "status")); + String routineSlug = getAsString(obj, "routineSlug"); + JsonElement metadata = obj.get("metadata"); + String result = getAsString(obj, "result"); + int priority = obj.has("priority") && !obj.get("priority").isJsonNull() ? obj.get("priority").getAsInt() : 0; + String executedBy = getAsString(obj, "executedBy"); + Instant createdAt = parseInstant(obj.get("createdAt")); + Instant updatedAt = parseInstant(obj.get("updatedAt")); + Instant processedAt = parseInstant(obj.get("processedAt")); + + tasks.add(new BridgeTask(id, slug, command, status, routineSlug, metadata, result, priority, executedBy, createdAt, updatedAt, processedAt)); + } + + return tasks; + } + + private Instant parseInstant(JsonElement element) { + if (element == null || element.isJsonNull()) { + return null; + } + try { + return Instant.parse(element.getAsString()); + } catch (Exception ignored) { + return null; + } + } + + private String getAsString(JsonObject obj, String member) { + if (!obj.has(member) || obj.get(member).isJsonNull()) { + return null; + } + return obj.get(member).getAsString(); + } +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeConfig.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeConfig.java new file mode 100644 index 0000000..28b4b7b --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeConfig.java @@ -0,0 +1,123 @@ +package org.modularsoft.zander.bridge.common; + +import java.time.Duration; +import java.util.Map; +import java.util.Objects; + +/** + * Represents the configuration required for both Velocity and Paper bridge + * plugins. + */ +public final class BridgeConfig { + + private final String baseApiUrl; + private final String apiKey; + private final Processor processor; + private final StatusReporter statusReporter; + private final TebexIntegration tebex; + + public BridgeConfig(String baseApiUrl, + String apiKey, + Processor processor, + StatusReporter statusReporter, + TebexIntegration tebex) { + this.baseApiUrl = Objects.requireNonNull(baseApiUrl, "baseApiUrl"); + this.apiKey = Objects.requireNonNull(apiKey, "apiKey"); + this.processor = Objects.requireNonNull(processor, "processor"); + this.statusReporter = Objects.requireNonNull(statusReporter, "statusReporter"); + this.tebex = Objects.requireNonNull(tebex, "tebex"); + } + + public String baseApiUrl() { + return baseApiUrl; + } + + public String apiKey() { + return apiKey; + } + + public Processor processor() { + return processor; + } + + public StatusReporter statusReporter() { + return statusReporter; + } + + public TebexIntegration tebex() { + return tebex; + } + + public record Processor(String serverSlug, + boolean claimTasks, + int pollBatchSize, + Duration pollInterval) { + + public Processor { + Objects.requireNonNull(serverSlug, "serverSlug"); + Objects.requireNonNull(pollInterval, "pollInterval"); + + if (serverSlug.isBlank()) { + throw new IllegalArgumentException("serverSlug must not be blank"); + } + if (pollBatchSize < 1) { + throw new IllegalArgumentException("pollBatchSize must be at least 1"); + } + if (pollInterval.isNegative() || pollInterval.isZero()) { + throw new IllegalArgumentException("pollInterval must be positive"); + } + } + } + + public record StatusReporter(boolean enabled, + Duration reportInterval) { + + public StatusReporter { + Objects.requireNonNull(reportInterval, "reportInterval"); + if (reportInterval.isNegative() || reportInterval.isZero()) { + throw new IllegalArgumentException("reportInterval must be positive"); + } + } + } + + public record TebexIntegration(boolean enabled, + Purchase purchase, + Subscription subscription) { + + public TebexIntegration { + Objects.requireNonNull(purchase, "purchase"); + Objects.requireNonNull(subscription, "subscription"); + } + + public static TebexIntegration disabled() { + return new TebexIntegration(false, Purchase.disabled(), Subscription.disabled()); + } + + public record Purchase(String defaultRoutine, + Map packageRoutines, + int priority) { + + public Purchase { + packageRoutines = Map.copyOf(packageRoutines); + } + + public static Purchase disabled() { + return new Purchase(null, Map.of(), 0); + } + } + + public record Subscription(String expirationRoutine, + String cancellationRoutine, + Map packageRoutines, + int priority) { + + public Subscription { + packageRoutines = Map.copyOf(packageRoutines); + } + + public static Subscription disabled() { + return new Subscription(null, null, Map.of(), 0); + } + } + } +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgePlatform.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgePlatform.java new file mode 100644 index 0000000..200bac5 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgePlatform.java @@ -0,0 +1,24 @@ +package org.modularsoft.zander.bridge.common; + +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +public interface BridgePlatform { + + Logger logger(); + + ScheduledTask scheduleRepeatingTask(Runnable task, long initialDelay, long repeatInterval, TimeUnit unit); + + void runSync(Runnable task); + + void runAsync(Runnable task); + + void executeConsoleCommand(String command) throws Exception; + + Map collectServerStatus(); + + interface ScheduledTask { + void cancel(); + } +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java new file mode 100644 index 0000000..6391446 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java @@ -0,0 +1,106 @@ +package org.modularsoft.zander.bridge.common; + +import com.google.gson.JsonElement; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public final class BridgeService { + + private final BridgePlatform platform; + private final BridgeConfig config; + private final BridgeApiClient apiClient; + + private BridgePlatform.ScheduledTask taskPoller; + private BridgePlatform.ScheduledTask statusReporter; + + public BridgeService(BridgePlatform platform, BridgeConfig config) { + this.platform = platform; + this.config = config; + this.apiClient = new BridgeApiClient(config.baseApiUrl(), config.apiKey()); + } + + public void start() { + stop(); + + long pollIntervalSeconds = config.processor().pollInterval().toSeconds(); + taskPoller = platform.scheduleRepeatingTask( + () -> platform.runAsync(this::pollAndExecuteTasks), + 0L, + pollIntervalSeconds, + TimeUnit.SECONDS); + + if (config.statusReporter().enabled()) { + long statusInterval = config.statusReporter().reportInterval().toSeconds(); + statusReporter = platform.scheduleRepeatingTask( + () -> platform.runAsync(this::reportServerStatus), + statusInterval, + statusInterval, + TimeUnit.SECONDS); + } + } + + public void stop() { + if (taskPoller != null) { + taskPoller.cancel(); + taskPoller = null; + } + if (statusReporter != null) { + statusReporter.cancel(); + statusReporter = null; + } + } + + public BridgeApiClient apiClient() { + return apiClient; + } + + private void pollAndExecuteTasks() { + try { + List tasks = apiClient.fetchTasks( + config.processor().serverSlug(), + config.processor().claimTasks(), + config.processor().pollBatchSize()); + + if (tasks.isEmpty()) { + return; + } + + for (BridgeTask task : tasks) { + executeTask(task); + } + } catch (IOException exception) { + platform.logger().warning("Failed to fetch tasks from bridge API: " + exception.getMessage()); + } + } + + private void executeTask(BridgeTask task) { + platform.runSync(() -> { + try { + platform.executeConsoleCommand(task.command()); + reportTask(task, TaskStatus.COMPLETED, "Executed command successfully", task.metadata()); + } catch (Exception exception) { + platform.logger().severe("Failed to execute command from bridge task " + task.id() + ": " + exception.getMessage()); + reportTask(task, TaskStatus.FAILED, "Command execution failed: " + exception.getMessage(), task.metadata()); + } + }); + } + + private void reportTask(BridgeTask task, TaskStatus status, String result, JsonElement metadata) { + try { + apiClient.reportTask(task.id(), status, result, config.processor().serverSlug(), metadata); + } catch (IOException exception) { + platform.logger().warning("Failed to report task " + task.id() + " to bridge API: " + exception.getMessage()); + } + } + + private void reportServerStatus() { + try { + apiClient.updateServerStatus(platform.collectServerStatus(), Instant.now()); + } catch (IOException exception) { + platform.logger().warning("Failed to update server status: " + exception.getMessage()); + } + } +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeTask.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeTask.java new file mode 100644 index 0000000..aa38fb7 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeTask.java @@ -0,0 +1,19 @@ +package org.modularsoft.zander.bridge.common; + +import com.google.gson.JsonElement; + +import java.time.Instant; + +public record BridgeTask(long id, + String slug, + String command, + TaskStatus status, + String routineSlug, + JsonElement metadata, + String result, + int priority, + String executedBy, + Instant createdAt, + Instant updatedAt, + Instant processedAt) { +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/TaskStatus.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/TaskStatus.java new file mode 100644 index 0000000..84e2d27 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/TaskStatus.java @@ -0,0 +1,25 @@ +package org.modularsoft.zander.bridge.common; + +public enum TaskStatus { + PENDING, + PROCESSING, + COMPLETED, + FAILED; + + public static TaskStatus fromString(String value) { + if (value == null) { + return PENDING; + } + return switch (value.toLowerCase()) { + case "processing" -> PROCESSING; + case "completed" -> COMPLETED; + case "failed" -> FAILED; + default -> PENDING; + }; + } + + @Override + public String toString() { + return name().toLowerCase(); + } +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/tebex/TebexEventContext.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/tebex/TebexEventContext.java new file mode 100644 index 0000000..15c9542 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/tebex/TebexEventContext.java @@ -0,0 +1,80 @@ +package org.modularsoft.zander.bridge.common.tebex; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public final class TebexEventContext { + + private final String playerName; + private final String playerUuid; + private final String packageId; + private final String packageName; + private final String subscriptionId; + private final Map metadata; + + public TebexEventContext(String playerName, + String playerUuid, + String packageId, + String packageName, + String subscriptionId, + Map metadata) { + this.playerName = normalize(playerName); + this.playerUuid = normalize(playerUuid); + this.packageId = normalize(packageId); + this.packageName = normalize(packageName); + this.subscriptionId = normalize(subscriptionId); + Map copy = new LinkedHashMap<>(Objects.requireNonNullElse(metadata, Map.of())); + this.metadata = Collections.unmodifiableMap(copy); + } + + public String playerName() { + return playerName; + } + + public String playerUuid() { + return playerUuid; + } + + public String packageId() { + return packageId; + } + + public String packageName() { + return packageName; + } + + public String subscriptionId() { + return subscriptionId; + } + + public Map metadata() { + return metadata; + } + + public boolean hasPackageIdentifier() { + return packageId != null || packageName != null; + } + + public String describePackage() { + if (packageId != null && packageName != null) { + return packageName + " (" + packageId + ")"; + } + if (packageName != null) { + return packageName; + } + if (packageId != null) { + return packageId; + } + return "unknown package"; + } + + private String normalize(String input) { + if (input == null) { + return null; + } + String trimmed = input.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/tebex/TebexMetadataExtractor.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/tebex/TebexMetadataExtractor.java new file mode 100644 index 0000000..cddbd00 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/tebex/TebexMetadataExtractor.java @@ -0,0 +1,220 @@ +package org.modularsoft.zander.bridge.common.tebex; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +public final class TebexMetadataExtractor { + + private TebexMetadataExtractor() { + } + + public static TebexEventContext extract(Object event) { + if (event == null) { + return new TebexEventContext(null, null, null, null, null, Map.of()); + } + + Map metadata = new LinkedHashMap<>(); + metadata.put("eventClass", event.getClass().getName()); + + Object payment = firstNonNull( + invoke(event, "getPayment"), + invoke(event, "payment"), + invoke(event, "getTransaction"), + invoke(event, "transaction"), + invoke(event, "getCompletedPayment"), + invoke(event, "getPurchase")); + if (payment != null) { + metadata.put("paymentClass", payment.getClass().getName()); + } + + Object subscription = firstNonNull( + invoke(event, "getSubscription"), + invoke(payment, "getSubscription")); + if (subscription != null) { + metadata.put("subscriptionClass", subscription.getClass().getName()); + } + + Object packageInfo = firstNonNull( + invoke(payment, "getPackage"), + invoke(payment, "getPackageInfo"), + invoke(payment, "getPackageDetails"), + invoke(subscription, "getPackage"), + invoke(subscription, "getPackageInfo"), + invoke(event, "getPackage")); + if (packageInfo != null) { + metadata.put("packageClass", packageInfo.getClass().getName()); + } + + Object player = firstNonNull( + invoke(event, "getPlayer"), + invoke(event, "getCustomer"), + invoke(payment, "getPlayer"), + invoke(payment, "getCustomer"), + invoke(subscription, "getPlayer"), + invoke(subscription, "getCustomer")); + if (player != null) { + metadata.put("playerClass", player.getClass().getName()); + } + + String playerName = stringValue(firstNonNull( + invoke(player, "getName"), + invoke(player, "getUsername"), + invoke(event, "getPlayerName"), + invoke(payment, "getPlayerName"))); + putIfPresent(metadata, "playerName", playerName); + + String playerUuid = stringValue(firstNonNull( + invoke(player, "getUuid"), + invoke(player, "getUniqueId"), + invoke(player, "getUuidString"), + invoke(event, "getPlayerUuid"))); + putIfPresent(metadata, "playerUuid", playerUuid); + + String packageId = stringValue(firstNonNull( + invoke(packageInfo, "getId"), + invoke(packageInfo, "getPackageId"), + invoke(event, "getPackageId"), + invoke(subscription, "getPackageId"))); + putIfPresent(metadata, "packageId", packageId); + + String packageName = stringValue(firstNonNull( + invoke(packageInfo, "getName"), + invoke(packageInfo, "getPackageName"), + invoke(event, "getPackageName"))); + putIfPresent(metadata, "packageName", packageName); + + String subscriptionId = stringValue(firstNonNull( + invoke(subscription, "getId"), + invoke(subscription, "getSubscriptionId"), + invoke(event, "getSubscriptionId"))); + putIfPresent(metadata, "subscriptionId", subscriptionId); + + putIfPresent(metadata, "price", stringValue(firstNonNull( + invoke(payment, "getPrice"), + invoke(payment, "getAmount"), + invoke(payment, "getPaid")))); + putIfPresent(metadata, "currency", stringValue(firstNonNull( + invoke(payment, "getCurrency"), + invoke(payment, "getCurrencyIso"), + invoke(payment, "getCurrencyCode")))); + + putIfPresent(metadata, "status", stringValue(firstNonNull( + invoke(subscription, "getStatus"), + invoke(payment, "getStatus")))); + + putIfPresent(metadata, "expiresAt", stringValue(firstNonNull( + invoke(subscription, "getExpiry"), + invoke(subscription, "getExpiresAt"), + invoke(subscription, "getExpires")))); + + TebexEventContext context = new TebexEventContext(playerName, playerUuid, packageId, packageName, subscriptionId, metadata); + return context; + } + + private static Object invoke(Object target, String methodName) { + if (target == null || methodName == null) { + return null; + } + + try { + Method method = findMethod(target.getClass(), methodName); + if (method != null) { + method.setAccessible(true); + Object result = method.invoke(target); + return unwrap(result); + } + } catch (ReflectiveOperationException ignored) { + } + + try { + Field field = findField(target.getClass(), methodName); + if (field != null) { + field.setAccessible(true); + Object result = field.get(target); + return unwrap(result); + } + } catch (ReflectiveOperationException ignored) { + } + + return null; + } + + private static Method findMethod(Class type, String name) { + String normalised = normaliseName(name); + for (Method method : type.getMethods()) { + if (method.getParameterCount() != 0) { + continue; + } + String methodName = normaliseName(method.getName()); + if (methodName.equals(normalised) || methodName.endsWith(normalised)) { + return method; + } + } + return null; + } + + private static Field findField(Class type, String name) { + String normalised = normaliseName(name); + for (Field field : type.getFields()) { + String fieldName = normaliseName(field.getName()); + if (fieldName.equals(normalised) || fieldName.endsWith(normalised)) { + return field; + } + } + return null; + } + + private static Object unwrap(Object value) { + if (value instanceof Optional optional) { + return optional.orElse(null); + } + return value; + } + + private static Object firstNonNull(Object... values) { + if (values == null) { + return null; + } + for (Object value : values) { + if (value != null) { + return value; + } + } + return null; + } + + private static void putIfPresent(Map map, String key, Object value) { + if (value == null) { + return; + } + map.put(key, value); + } + + private static String stringValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof CharSequence sequence) { + String result = sequence.toString().trim(); + return result.isEmpty() ? null : result; + } + if (value instanceof Number number) { + return number.toString(); + } + return value.toString(); + } + + private static String normaliseName(String input) { + if (input == null) { + return ""; + } + return input.toLowerCase(Locale.ROOT) + .replace("get", "") + .replace("is", "") + .replace("has", ""); + } +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/events/PlayerJoinListener.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/events/PlayerJoinListener.java deleted file mode 100644 index 935b450..0000000 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/events/PlayerJoinListener.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.modularsoft.zander.bridge.events; - -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.player.PlayerJoinEvent; -import org.modularsoft.zander.bridge.util.Bridge; - -public class PlayerJoinListener implements Listener { - - @EventHandler - public void onPlayerVote(PlayerJoinEvent event) { - Bridge.processBridgeData(Bridge.getPendingActions()); - } -} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/events/PlayerVoteListener.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/events/PlayerVoteListener.java deleted file mode 100644 index 620d9fc..0000000 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/events/PlayerVoteListener.java +++ /dev/null @@ -1,71 +0,0 @@ -package org.modularsoft.zander.bridge.events; - -import com.vexsoftware.votifier.model.VotifierEvent; -import io.github.ModularEnigma.Request; -import io.github.ModularEnigma.Response; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.modularsoft.zander.bridge.ZanderBridge; -import org.modularsoft.zander.bridge.model.BridgeRoutineProcess; -import org.modularsoft.zander.bridge.model.VoteProcess; - -public class PlayerVoteListener implements Listener { - @EventHandler - public void onVotifierEvent(VotifierEvent event) { - String username = event.getVote().getUsername(); - String serviceName = event.getVote().getServiceName(); - String address = event.getVote().getAddress(); - String timeStamp = event.getVote().getTimeStamp(); - - // Handle the vote event - ZanderBridge.plugin.getServer().getConsoleSender().sendMessage("[BRIDGE] " + username + " voted on " + serviceName + " from " + address + " at " + timeStamp); - - String BaseAPIURL = ZanderBridge.plugin.getConfig().getString("BaseAPIURL"); - String APIKey = ZanderBridge.plugin.getConfig().getString("APIKey"); - - try { - // - // Send vote to API for processing - // - VoteProcess voteProcess = VoteProcess.builder() - .site(serviceName) - .username(username) - .build(); - - Request voteProcessReq = Request.builder() - .setURL(BaseAPIURL + "/api/vote/cast") - .setMethod(Request.Method.POST) - .addHeader("x-access-token", APIKey) - .setRequestBody(voteProcess.toString()) - .build(); - - Response voteProcessRes = voteProcessReq.execute(); - ZanderBridge.plugin.getServer().getConsoleSender().sendMessage("Response (" + voteProcessRes.getStatusCode() + "): " + voteProcessRes.getBody()); - } catch (Exception e) { - ZanderBridge.plugin.getServer().getConsoleSender().sendMessage("Error in submitting vote: " + e.getMessage()); - } - - try { - // - // Send routine to API for processing - // - BridgeRoutineProcess bridgeRoutine = BridgeRoutineProcess.builder() - .username(username) - .routine("vote") - .build(); - - Request bridgeRoutineReq = Request.builder() - .setURL(BaseAPIURL + "/api/bridge/routine/execute") - .setMethod(Request.Method.POST) - .addHeader("x-access-token", APIKey) - .setRequestBody(bridgeRoutine.toString()) - .build(); - - Response bridgeRoutineRes = bridgeRoutineReq.execute(); - ZanderBridge.plugin.getServer().getConsoleSender().sendMessage("Response (" + bridgeRoutineRes.getStatusCode() + "): " + bridgeRoutineRes.getBody()); - } catch (Exception e) { - ZanderBridge.plugin.getServer().getConsoleSender().sendMessage("Error in sending routine: " + e.getMessage()); - } - } -} \ No newline at end of file diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/model/BridgeProcess.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/model/BridgeProcess.java deleted file mode 100644 index 1591416..0000000 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/model/BridgeProcess.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.modularsoft.zander.bridge.model; - -import com.google.gson.Gson; -import lombok.Builder; -import lombok.Getter; - -@Builder -public class BridgeProcess { - - @Getter String id; - - @Override - public String toString() { - return new Gson().toJson(this); - } - -} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/model/BridgeRoutineProcess.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/model/BridgeRoutineProcess.java deleted file mode 100644 index f7970de..0000000 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/model/BridgeRoutineProcess.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.modularsoft.zander.bridge.model; - -import com.google.gson.Gson; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class BridgeRoutineProcess { - - String username; - String routine; - - @Override - public String toString() { - return new Gson().toJson(this); - } - -} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/model/VoteProcess.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/model/VoteProcess.java deleted file mode 100644 index f6849a5..0000000 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/model/VoteProcess.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.modularsoft.zander.bridge.model; - -import com.google.gson.Gson; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class VoteProcess { - - String username; - String site; - - @Override - public String toString() { - return new Gson().toJson(this); - } - -} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlatform.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlatform.java new file mode 100644 index 0000000..2c29963 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlatform.java @@ -0,0 +1,82 @@ +package org.modularsoft.zander.bridge.paper; + +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.plugin.java.JavaPlugin; +import org.modularsoft.zander.bridge.common.BridgePlatform; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +public final class PaperBridgePlatform implements BridgePlatform { + + private final JavaPlugin plugin; + private final String serverId; + + public PaperBridgePlatform(JavaPlugin plugin, String serverId) { + this.plugin = plugin; + this.serverId = serverId; + } + + @Override + public Logger logger() { + return plugin.getLogger(); + } + + @Override + public ScheduledTask scheduleRepeatingTask(Runnable task, long initialDelay, long repeatInterval, TimeUnit unit) { + long ticksInitial = Math.max(0L, unit.toSeconds(initialDelay)) * 20L; + long ticksRepeat = Math.max(1L, unit.toSeconds(repeatInterval)) * 20L; + + long start = Math.max(1L, ticksInitial); + long period = Math.max(1L, ticksRepeat); + + var bukkitTask = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, task, start, period); + return bukkitTask::cancel; + } + + @Override + public void runSync(Runnable task) { + if (Bukkit.isPrimaryThread()) { + task.run(); + } else { + Bukkit.getScheduler().runTask(plugin, task); + } + } + + @Override + public void runAsync(Runnable task) { + Bukkit.getScheduler().runTaskAsynchronously(plugin, task); + } + + @Override + public void executeConsoleCommand(String command) { + ConsoleCommandSender console = Bukkit.getConsoleSender(); + boolean success = Bukkit.dispatchCommand(console, command); + if (!success) { + throw new IllegalStateException("Command execution returned false for: " + command); + } + } + + @Override + public Map collectServerStatus() { + Server server = plugin.getServer(); + Map payload = new HashMap<>(); + payload.put("platform", "paper"); + payload.put("serverId", serverId); + payload.put("playerCount", server.getOnlinePlayers().size()); + payload.put("maxPlayers", server.getMaxPlayers()); + payload.put("onlinePlayers", server.getOnlinePlayers().stream().map(player -> { + Map info = new HashMap<>(); + info.put("name", player.getName()); + info.put("uuid", player.getUniqueId().toString()); + info.put("world", player.getWorld().getName()); + return info; + }).toList()); + return payload; + } + +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlugin.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlugin.java new file mode 100644 index 0000000..bcc686d --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlugin.java @@ -0,0 +1,137 @@ +package org.modularsoft.zander.bridge.paper; + +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.ConfigurationSection; +import org.modularsoft.zander.bridge.common.BridgeConfig; +import org.modularsoft.zander.bridge.common.BridgeService; +import org.modularsoft.zander.bridge.common.BridgeConfig.TebexIntegration; +import org.modularsoft.zander.bridge.paper.tebex.TebexPaperIntegration; + +import org.bukkit.plugin.java.JavaPlugin; + +import java.time.Duration; +import java.util.Locale; +import java.util.Map; + +public final class PaperBridgePlugin extends JavaPlugin { + + private BridgeService bridgeService; + private TebexPaperIntegration tebexIntegration; + + @Override + public void onEnable() { + saveDefaultConfig(); + + BridgeConfig config = loadBridgeConfig(); + PaperBridgePlatform platform = new PaperBridgePlatform(this, config.processor().serverSlug()); + + bridgeService = new BridgeService(platform, config); + bridgeService.start(); + + if (config.tebex().enabled()) { + tebexIntegration = new TebexPaperIntegration(this, bridgeService.apiClient(), config); + tebexIntegration.start(); + } + + getLogger().info(() -> "Zander Bridge (Paper) enabled for server slug '" + config.processor().serverSlug() + "'"); + } + + @Override + public void onDisable() { + if (tebexIntegration != null) { + tebexIntegration.stop(); + tebexIntegration = null; + } + if (bridgeService != null) { + bridgeService.stop(); + bridgeService = null; + } + } + + private BridgeConfig loadBridgeConfig() { + FileConfiguration configuration = getConfig(); + String baseApiUrl = normaliseBaseUrl(configuration.getString("bridge.baseApiUrl", "http://localhost:3000")); + String apiKey = configuration.getString("bridge.apiKey", ""); + String serverSlug = configuration.getString("bridge.serverSlug", getServer().getServerName()); + boolean claimTasks = configuration.getBoolean("bridge.claimTasks", true); + int pollBatchSize = Math.max(1, configuration.getInt("bridge.pollBatchSize", 25)); + Duration pollInterval = Duration.ofSeconds(Math.max(1, configuration.getLong("bridge.pollIntervalSeconds", 5L))); + + boolean statusEnabled = configuration.getBoolean("bridge.status.enabled", true); + Duration statusInterval = Duration.ofSeconds(Math.max(5, configuration.getLong("bridge.status.reportIntervalSeconds", 60L))); + + BridgeConfig.Processor processor = new BridgeConfig.Processor(serverSlug, claimTasks, pollBatchSize, pollInterval); + BridgeConfig.StatusReporter statusReporter = new BridgeConfig.StatusReporter(statusEnabled, statusInterval); + + TebexIntegration tebexIntegration = loadTebexConfig(configuration.getConfigurationSection("bridge.tebex")); + + return new BridgeConfig(baseApiUrl, apiKey, processor, statusReporter, tebexIntegration); + } + + private String normaliseBaseUrl(String url) { + if (url == null || url.isBlank()) { + return "http://localhost:3000"; + } + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + private TebexIntegration loadTebexConfig(ConfigurationSection section) { + if (section == null) { + return TebexIntegration.disabled(); + } + + boolean enabled = section.getBoolean("enabled", false); + TebexIntegration.Purchase purchase = loadPurchaseConfig(section.getConfigurationSection("purchase")); + TebexIntegration.Subscription subscription = loadSubscriptionConfig(section.getConfigurationSection("subscription")); + + return new TebexIntegration(enabled, purchase, subscription); + } + + private TebexIntegration.Purchase loadPurchaseConfig(ConfigurationSection section) { + if (section == null) { + return TebexIntegration.Purchase.disabled(); + } + + String defaultRoutine = trimToNull(section.getString("defaultRoutine")); + int priority = section.getInt("priority", 0); + ConfigurationSection mappings = section.getConfigurationSection("packageRoutines"); + + return new TebexIntegration.Purchase(defaultRoutine, readRoutineMappings(mappings), priority); + } + + private TebexIntegration.Subscription loadSubscriptionConfig(ConfigurationSection section) { + if (section == null) { + return TebexIntegration.Subscription.disabled(); + } + + String expirationRoutine = trimToNull(section.getString("expirationRoutine")); + String cancellationRoutine = trimToNull(section.getString("cancellationRoutine")); + int priority = section.getInt("priority", 0); + ConfigurationSection mappings = section.getConfigurationSection("packageRoutines"); + + return new TebexIntegration.Subscription(expirationRoutine, cancellationRoutine, readRoutineMappings(mappings), priority); + } + + private Map readRoutineMappings(ConfigurationSection section) { + if (section == null) { + return Map.of(); + } + + Map mappings = new java.util.HashMap<>(); + for (String key : section.getKeys(false)) { + String routine = trimToNull(section.getString(key)); + if (routine != null) { + mappings.put(key.trim().toLowerCase(java.util.Locale.ROOT), routine); + } + } + return mappings; + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/tebex/TebexPaperIntegration.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/tebex/TebexPaperIntegration.java new file mode 100644 index 0000000..7c0e0d6 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/tebex/TebexPaperIntegration.java @@ -0,0 +1,214 @@ +package org.modularsoft.zander.bridge.paper.tebex; + +import org.bukkit.Bukkit; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginDisableEvent; +import org.bukkit.event.server.PluginEnableEvent; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.modularsoft.zander.bridge.common.BridgeConfig; +import org.modularsoft.zander.bridge.common.BridgeApiClient; +import org.modularsoft.zander.bridge.common.tebex.TebexEventContext; +import org.modularsoft.zander.bridge.common.tebex.TebexMetadataExtractor; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.logging.Level; + +public final class TebexPaperIntegration { + + private static final Set SUPPORTED_PLUGIN_NAMES = Set.of("Tebex", "TebexPlugin", "BuycraftX"); + + private static final String[] PURCHASE_EVENT_CLASSES = { + "com.tebex.bukkit.platform.bukkit.event.TransactionCompletedEvent", + "com.tebex.bukkit.platform.bukkit.event.PurchaseCompleteEvent", + "net.buycraft.plugin.bukkit.event.PaymentCompletedEvent", + "net.buycraft.plugin.bukkit.events.PaymentCompletedEvent" + }; + + private static final String[] SUBSCRIPTION_EXPIRED_EVENT_CLASSES = { + "com.tebex.bukkit.platform.bukkit.event.SubscriptionExpiredEvent", + "com.tebex.bukkit.platform.bukkit.event.SubscriptionCancelledEvent", + "net.buycraft.plugin.bukkit.event.SubscriptionExpiredEvent", + "net.buycraft.plugin.bukkit.events.SubscriptionCancelledEvent" + }; + + private final JavaPlugin plugin; + private final BridgeApiClient apiClient; + private final BridgeConfig config; + private final Listener lifecycleListener = new LifecycleListener(); + private final Listener bridgeListener = new BridgeListener(); + private final List> registeredEvents = new ArrayList<>(); + + private Plugin tebexPlugin; + + public TebexPaperIntegration(JavaPlugin plugin, BridgeApiClient apiClient, BridgeConfig config) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.apiClient = Objects.requireNonNull(apiClient, "apiClient"); + this.config = Objects.requireNonNull(config, "config"); + } + + public void start() { + PluginManager pluginManager = Bukkit.getPluginManager(); + pluginManager.registerEvents(lifecycleListener, plugin); + attemptHook(pluginManager); + } + + public void stop() { + HandlerList.unregisterAll(lifecycleListener); + HandlerList.unregisterAll(bridgeListener); + registeredEvents.clear(); + tebexPlugin = null; + } + + private void attemptHook(PluginManager pluginManager) { + if (tebexPlugin != null) { + return; + } + + for (Plugin candidate : pluginManager.getPlugins()) { + if (SUPPORTED_PLUGIN_NAMES.contains(candidate.getName())) { + tebexPlugin = candidate; + break; + } + } + + if (tebexPlugin == null) { + plugin.getLogger().info("Tebex plugin not detected; waiting for it to enable."); + return; + } + + registerEventHandlers(); + } + + private void registerEventHandlers() { + HandlerList.unregisterAll(bridgeListener); + registeredEvents.clear(); + + ClassLoader loader = tebexPlugin.getClass().getClassLoader(); + + for (String className : PURCHASE_EVENT_CLASSES) { + registerEvent(loader, className, event -> handlePurchase(event)); + } + + for (String className : SUBSCRIPTION_EXPIRED_EVENT_CLASSES) { + registerEvent(loader, className, event -> handleSubscription(event)); + } + + plugin.getLogger().info(() -> "Tebex integration active using plugin '" + tebexPlugin.getName() + "'."); + } + + @SuppressWarnings("unchecked") + private void registerEvent(ClassLoader loader, String className, Consumer handler) { + try { + Class type = Class.forName(className, false, loader); + if (!Event.class.isAssignableFrom(type)) { + return; + } + Class eventClass = (Class) type; + Bukkit.getPluginManager().registerEvent(eventClass, bridgeListener, EventPriority.MONITOR, (listener, event) -> { + if (eventClass.isInstance(event)) { + handler.accept(eventClass.cast(event)); + } + }, plugin, true); + registeredEvents.add(eventClass); + plugin.getLogger().fine(() -> "Registered Tebex event listener for " + className); + } catch (ClassNotFoundException ignored) { + } catch (Throwable throwable) { + plugin.getLogger().log(Level.WARNING, "Failed to hook Tebex event '" + className + "'", throwable); + } + } + + private void handlePurchase(Event event) { + TebexEventContext context = TebexMetadataExtractor.extract(event); + Map metadata = new HashMap<>(context.metadata()); + metadata.put("bridgeEvent", "tebexPurchase"); + metadata.put("serverSlug", config.processor().serverSlug()); + + String routine = resolveRoutine(config.tebex().purchase().packageRoutines(), context, config.tebex().purchase().defaultRoutine()); + dispatchRoutine(routine, metadata, config.tebex().purchase().priority(), "purchase", context); + } + + private void handleSubscription(Event event) { + TebexEventContext context = TebexMetadataExtractor.extract(event); + Map metadata = new HashMap<>(context.metadata()); + metadata.put("bridgeEvent", "tebexSubscription"); + metadata.put("serverSlug", config.processor().serverSlug()); + + String routine = resolveRoutine(config.tebex().subscription().packageRoutines(), context, config.tebex().subscription().expirationRoutine()); + if (routine == null) { + routine = config.tebex().subscription().cancellationRoutine(); + } + dispatchRoutine(routine, metadata, config.tebex().subscription().priority(), "subscription", context); + } + + private void dispatchRoutine(String routine, + Map metadata, + int priority, + String eventType, + TebexEventContext context) { + if (routine == null || routine.isBlank()) { + return; + } + + try { + apiClient.queueRoutine(routine, config.processor().serverSlug(), metadata, priority); + plugin.getLogger().info(() -> String.format(Locale.ROOT, + "Queued Tebex %s routine '%s' for %s", eventType, routine, context.describePackage())); + } catch (IOException exception) { + plugin.getLogger().log(Level.WARNING, + "Failed to queue routine '" + routine + "' for Tebex event", exception); + } + } + + private String resolveRoutine(Map mappings, TebexEventContext context, String defaultRoutine) { + if (context.packageId() != null) { + String mapped = mappings.get(context.packageId().toLowerCase(Locale.ROOT)); + if (mapped != null && !mapped.isBlank()) { + return mapped; + } + } + if (context.packageName() != null) { + String mapped = mappings.get(context.packageName().toLowerCase(Locale.ROOT)); + if (mapped != null && !mapped.isBlank()) { + return mapped; + } + } + return defaultRoutine; + } + + private final class LifecycleListener implements Listener { + + @EventHandler(priority = EventPriority.MONITOR) + public void onPluginEnable(PluginEnableEvent event) { + if (SUPPORTED_PLUGIN_NAMES.contains(event.getPlugin().getName())) { + tebexPlugin = event.getPlugin(); + registerEventHandlers(); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPluginDisable(PluginDisableEvent event) { + if (tebexPlugin != null && tebexPlugin.equals(event.getPlugin())) { + HandlerList.unregisterAll(bridgeListener); + registeredEvents.clear(); + tebexPlugin = null; + } + } + } + + private static final class BridgeListener implements Listener { + } +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/util/Bridge.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/util/Bridge.java deleted file mode 100644 index 6dbdcdb..0000000 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/util/Bridge.java +++ /dev/null @@ -1,148 +0,0 @@ -package org.modularsoft.zander.bridge.util; - -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonParser; -import com.google.gson.reflect.TypeToken; -import com.jayway.jsonpath.JsonPath; -import io.github.ModularEnigma.Request; -import io.github.ModularEnigma.Response; -import lombok.Getter; -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitRunnable; -import org.modularsoft.zander.bridge.ZanderBridge; -import org.modularsoft.zander.bridge.model.BridgeProcess; - -import java.lang.reflect.Type; -import java.util.List; -import java.util.Map; -import java.util.logging.Logger; - -public class Bridge { - private static final Logger LOGGER = ZanderBridge.plugin.getLogger(); - private static final Gson gson = new Gson(); - - @Getter - private static List> pendingActions; - - public static void bridgeSyncAPICall() { - String baseAPIURL = ZanderBridge.plugin.getConfig().getString("BaseAPIURL"); - String apiKey = ZanderBridge.plugin.getConfig().getString("APIKey"); - int syncInterval = ZanderBridge.plugin.getConfig().getInt("SyncInterval", 5); - - new BukkitRunnable() { - @Override - public void run() { - try { - Request req = Request.builder() - .setURL(baseAPIURL + "/bridge/get") - .setMethod(Request.Method.GET) - .addHeader("x-access-token", apiKey) - .build(); - - Response res = req.execute(); - String json = res.getBody(); - - LOGGER.info("Received API Response: " + json); - - Type listType = new TypeToken>>() {}.getType(); - Object dataObject = JsonPath.parse(json).read("$.data"); - pendingActions = gson.fromJson(gson.toJson(dataObject), listType); - - if (pendingActions == null || pendingActions.isEmpty()) { - LOGGER.info("No actions to process."); - return; - } - - processBridgeData(pendingActions); - } catch (Exception e) { - LOGGER.severe("Error in bridgeSyncAPICall: " + e.getMessage()); - e.printStackTrace(); - } - } - }.runTaskTimer(ZanderBridge.plugin, 0L, syncInterval * 60L * 20L); - } - - public static void processBridgeData(List> dataList) { - if (dataList == null) return; - - for (Map data : dataList) { - String actionData = (String) data.get("actionData"); - String bridgeId = String.valueOf(data.get("bridgeId")); - String targetServer = (String) data.get("targetServer"); - String playerName = extractPlayerName(actionData); - - if (playerName != null) { - Player player = Bukkit.getPlayer(playerName); - if (player != null && player.isOnline()) { - try { - String processedActionData = processActionData(actionData); - Bukkit.dispatchCommand(Bukkit.getConsoleSender(), processedActionData); - LOGGER.info("Executed command for targetServer " + targetServer + ": " + processedActionData); - - markActionAsProcessed(bridgeId); - } catch (Exception e) { - LOGGER.severe("Error executing command for " + playerName + " on targetServer " + targetServer + ": " + e.getMessage()); - } - } else { - LOGGER.info("Player " + playerName + " is not online. Skipping action for targetServer " + targetServer); - } - } else { - LOGGER.warning("Invalid actionData format: " + actionData); - } - } - } - - private static void markActionAsProcessed(String bridgeId) { - String baseAPIURL = ZanderBridge.plugin.getConfig().getString("BaseAPIURL"); - String apiKey = ZanderBridge.plugin.getConfig().getString("APIKey"); - - BridgeProcess processedAction = BridgeProcess.builder() - .id(bridgeId) - .build(); - - Request processedActionReq = Request.builder() - .setURL(baseAPIURL + "/bridge/action/process") - .setMethod(Request.Method.POST) - .addHeader("x-access-token", apiKey) - .setRequestBody(gson.toJson(processedAction)) - .build(); - - try { - Response res = processedActionReq.execute(); - LOGGER.info("Action Processed (" + res.getStatusCode() + "): " + res.getBody()); - } catch (Exception e) { - LOGGER.severe("Error processing bridge action: " + e.getMessage()); - } - } - - private static String extractPlayerName(String actionData) { - if (actionData == null || actionData.trim().isEmpty()) return null; - - String[] parts = actionData.split(" "); - for (String part : parts) { - Player player = Bukkit.getPlayer(part); - if (player != null) { - return part; - } - } - return null; - } - - private static String processActionData(String actionData) { - if (actionData == null) return ""; - - try { - // Attempt to parse as JSON to check if it's properly structured - JsonElement jsonElement = JsonParser.parseString(actionData); - if (jsonElement.isJsonObject() || jsonElement.isJsonArray()) { - // Replace escaped backslashes inside the JSON/NBT - return actionData.replace("\\\"", "\"").replace("\\\\", "\\"); - } - } catch (Exception ignored) { - } - - return actionData; - } -} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java new file mode 100644 index 0000000..2e11609 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java @@ -0,0 +1,85 @@ +package org.modularsoft.zander.bridge.velocity; + +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.scheduler.ScheduledTask; +import org.modularsoft.zander.bridge.common.BridgePlatform; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +public final class VelocityBridgePlatform implements BridgePlatform { + + private final Object pluginInstance; + private final ProxyServer proxyServer; + private final Logger logger; + private final String serverId; + + public VelocityBridgePlatform(Object pluginInstance, ProxyServer proxyServer, Logger logger, String serverId) { + this.pluginInstance = pluginInstance; + this.proxyServer = proxyServer; + this.logger = logger; + this.serverId = serverId; + } + + @Override + public Logger logger() { + return logger; + } + + @Override + public BridgePlatform.ScheduledTask scheduleRepeatingTask(Runnable task, long initialDelay, long repeatInterval, TimeUnit unit) { + ScheduledTask scheduledTask = proxyServer.getScheduler() + .buildTask(pluginInstance, task) + .delay(initialDelay, unit) + .repeat(repeatInterval, unit) + .schedule(); + return new ScheduledTaskWrapper(scheduledTask); + } + + @Override + public void runSync(Runnable task) { + proxyServer.getScheduler().buildTask(pluginInstance, task).schedule(); + } + + @Override + public void runAsync(Runnable task) { + proxyServer.getScheduler().buildTask(pluginInstance, task).schedule(); + } + + @Override + public void executeConsoleCommand(String command) { + CompletableFuture future = proxyServer.getCommandManager() + .executeAsync(proxyServer.getConsoleCommandSource(), command); + try { + future.join(); + } catch (Exception exception) { + throw new IllegalStateException("Failed to execute command '" + command + "'", exception); + } + } + + @Override + public Map collectServerStatus() { + Map payload = new HashMap<>(); + payload.put("platform", "velocity"); + payload.put("serverId", serverId); + payload.put("playerCount", proxyServer.getPlayerCount()); + payload.put("onlinePlayers", proxyServer.getAllPlayers().stream().map(player -> { + Map info = new HashMap<>(); + info.put("name", player.getUsername()); + info.put("uuid", player.getUniqueId().toString()); + info.put("currentServer", player.getCurrentServer().map(serverConnection -> serverConnection.getServerInfo().getName()).orElse(null)); + return info; + }).toList()); + return payload; + } + + private record ScheduledTaskWrapper(ScheduledTask scheduledTask) implements BridgePlatform.ScheduledTask { + @Override + public void cancel() { + scheduledTask.cancel(); + } + } +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlugin.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlugin.java new file mode 100644 index 0000000..b11ec27 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlugin.java @@ -0,0 +1,209 @@ +package org.modularsoft.zander.bridge.velocity; + +import com.google.inject.Inject; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; +import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; +import com.velocitypowered.api.plugin.Plugin; +import com.velocitypowered.api.proxy.ProxyServer; +import org.modularsoft.zander.bridge.common.BridgeConfig; +import org.modularsoft.zander.bridge.common.BridgeService; +import org.modularsoft.zander.bridge.common.BridgeConfig.TebexIntegration; +import org.modularsoft.zander.bridge.velocity.tebex.TebexVelocityIntegration; +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Map; +import java.util.logging.Logger; + +@Plugin(id = "zander-bridge", name = "Zander Bridge", version = "${project.version}") +public final class VelocityBridgePlugin { + + private final ProxyServer proxyServer; + private final Logger logger; + private final Path dataDirectory; + + private BridgeService bridgeService; + private TebexVelocityIntegration tebexIntegration; + @Inject + public VelocityBridgePlugin(ProxyServer proxyServer, Logger logger, @com.velocitypowered.api.plugin.annotation.DataDirectory Path dataDirectory) { + this.proxyServer = proxyServer; + this.logger = logger; + this.dataDirectory = dataDirectory; + } + + @Subscribe + public void onProxyInitialization(ProxyInitializeEvent event) { + try { + BridgeConfig configuration = loadConfiguration(); + VelocityBridgePlatform platform = new VelocityBridgePlatform(this, proxyServer, logger, configuration.processor().serverSlug()); + bridgeService = new BridgeService(platform, configuration); + bridgeService.start(); + if (configuration.tebex().enabled()) { + tebexIntegration = new TebexVelocityIntegration(this, proxyServer, logger, bridgeService.apiClient(), configuration); + tebexIntegration.start(); + } + logger.info(() -> "Zander Bridge (Velocity) enabled for server slug '" + configuration.processor().serverSlug() + "'"); + } catch (IOException exception) { + logger.severe("Failed to start Zander Bridge: " + exception.getMessage()); + } + } + + @Subscribe + public void onProxyShutdown(ProxyShutdownEvent event) { + if (bridgeService != null) { + bridgeService.stop(); + bridgeService = null; + } + if (tebexIntegration != null) { + tebexIntegration.stop(); + tebexIntegration = null; + } + } + + private BridgeConfig loadConfiguration() throws IOException { + Files.createDirectories(dataDirectory); + Path configPath = dataDirectory.resolve("config.yml"); + + if (Files.notExists(configPath)) { + try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream("config.yml")) { + if (inputStream == null) { + throw new IOException("Default config.yml not found in resources"); + } + Files.copy(inputStream, configPath); + } + } + + Yaml yaml = new Yaml(); + Map root; + try (Reader reader = Files.newBufferedReader(configPath)) { + Object loaded = yaml.load(reader); + if (loaded instanceof Map map) { + //noinspection unchecked + root = (Map) map; + } else { + root = Map.of(); + } + } + + Map bridgeSection = getSection(root, "bridge"); + String baseApiUrl = normaliseBaseUrl((String) bridgeSection.getOrDefault("baseApiUrl", "http://localhost:3000")); + String apiKey = (String) bridgeSection.getOrDefault("apiKey", ""); + String serverSlug = (String) bridgeSection.getOrDefault("serverSlug", "velocity-proxy"); + boolean claimTasks = getBoolean(bridgeSection, "claimTasks", true); + int pollBatchSize = getInteger(bridgeSection, "pollBatchSize", 25); + Duration pollInterval = Duration.ofSeconds(Math.max(1, getInteger(bridgeSection, "pollIntervalSeconds", 5))); + + Map statusSection = getSection(bridgeSection, "status"); + boolean statusEnabled = getBoolean(statusSection, "enabled", true); + Duration statusInterval = Duration.ofSeconds(Math.max(5, getInteger(statusSection, "reportIntervalSeconds", 60))); + + BridgeConfig.Processor processor = new BridgeConfig.Processor(serverSlug, claimTasks, pollBatchSize, pollInterval); + BridgeConfig.StatusReporter statusReporter = new BridgeConfig.StatusReporter(statusEnabled, statusInterval); + TebexIntegration tebexIntegration = loadTebexConfiguration(getSection(bridgeSection, "tebex")); + return new BridgeConfig(baseApiUrl, apiKey, processor, statusReporter, tebexIntegration); + } + + private Map getSection(Map parent, String key) { + Object value = parent.get(key); + if (value instanceof Map map) { + //noinspection unchecked + return (Map) map; + } + return Map.of(); + } + + private boolean getBoolean(Map map, String key, boolean defaultValue) { + Object value = map.get(key); + if (value instanceof Boolean bool) { + return bool; + } + if (value instanceof String string) { + return Boolean.parseBoolean(string); + } + return defaultValue; + } + + private int getInteger(Map map, String key, int defaultValue) { + Object value = map.get(key); + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String string) { + try { + return Integer.parseInt(string); + } catch (NumberFormatException ignored) { + } + } + return defaultValue; + } + + private String normaliseBaseUrl(String url) { + if (url == null || url.isBlank()) { + return "http://localhost:3000"; + } + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + private TebexIntegration loadTebexConfiguration(Map section) { + if (section.isEmpty()) { + return TebexIntegration.disabled(); + } + + boolean enabled = getBoolean(section, "enabled", false); + TebexIntegration.Purchase purchase = loadPurchaseConfiguration(getSection(section, "purchase")); + TebexIntegration.Subscription subscription = loadSubscriptionConfiguration(getSection(section, "subscription")); + return new TebexIntegration(enabled, purchase, subscription); + } + + private TebexIntegration.Purchase loadPurchaseConfiguration(Map section) { + if (section.isEmpty()) { + return TebexIntegration.Purchase.disabled(); + } + + String defaultRoutine = trimToNull(section.get("defaultRoutine")); + int priority = getInteger(section, "priority", 0); + Map mappings = readRoutineMappings(getSection(section, "packageRoutines")); + return new TebexIntegration.Purchase(defaultRoutine, mappings, priority); + } + + private TebexIntegration.Subscription loadSubscriptionConfiguration(Map section) { + if (section.isEmpty()) { + return TebexIntegration.Subscription.disabled(); + } + + String expirationRoutine = trimToNull(section.get("expirationRoutine")); + String cancellationRoutine = trimToNull(section.get("cancellationRoutine")); + int priority = getInteger(section, "priority", 0); + Map mappings = readRoutineMappings(getSection(section, "packageRoutines")); + return new TebexIntegration.Subscription(expirationRoutine, cancellationRoutine, mappings, priority); + } + + private Map readRoutineMappings(Map section) { + if (section.isEmpty()) { + return Map.of(); + } + + Map mappings = new java.util.HashMap<>(); + for (Map.Entry entry : section.entrySet()) { + String value = trimToNull(entry.getValue()); + if (value != null) { + mappings.put(entry.getKey().trim().toLowerCase(java.util.Locale.ROOT), value); + } + } + return mappings; + } + + private String trimToNull(Object value) { + if (value instanceof String string) { + String trimmed = string.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + return null; + } +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/tebex/TebexVelocityIntegration.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/tebex/TebexVelocityIntegration.java new file mode 100644 index 0000000..9c92395 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/tebex/TebexVelocityIntegration.java @@ -0,0 +1,166 @@ +package org.modularsoft.zander.bridge.velocity.tebex; + +import com.velocitypowered.api.event.EventManager; +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.proxy.ProxyServer; +import org.modularsoft.zander.bridge.common.BridgeApiClient; +import org.modularsoft.zander.bridge.common.BridgeConfig; +import org.modularsoft.zander.bridge.common.tebex.TebexEventContext; +import org.modularsoft.zander.bridge.common.tebex.TebexMetadataExtractor; +import org.modularsoft.zander.bridge.velocity.VelocityBridgePlugin; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class TebexVelocityIntegration { + + private static final Set SUPPORTED_PLUGIN_IDS = Set.of("tebex", "buycraftx"); + + private static final String[] PURCHASE_EVENT_CLASSES = { + "com.tebex.velocity.event.TransactionCompletedEvent", + "com.tebex.velocity.event.PurchaseCompleteEvent", + "net.buycraft.plugin.velocity.event.PaymentCompletedEvent" + }; + + private static final String[] SUBSCRIPTION_EVENT_CLASSES = { + "com.tebex.velocity.event.SubscriptionExpiredEvent", + "com.tebex.velocity.event.SubscriptionCancelledEvent", + "net.buycraft.plugin.velocity.event.SubscriptionExpiredEvent" + }; + + private final VelocityBridgePlugin plugin; + private final ProxyServer proxyServer; + private final Logger logger; + private final BridgeApiClient apiClient; + private final BridgeConfig config; + + public TebexVelocityIntegration(VelocityBridgePlugin plugin, + ProxyServer proxyServer, + Logger logger, + BridgeApiClient apiClient, + BridgeConfig config) { + this.plugin = plugin; + this.proxyServer = proxyServer; + this.logger = logger; + this.apiClient = apiClient; + this.config = config; + } + + public void start() { + Optional container = SUPPORTED_PLUGIN_IDS.stream() + .map(id -> proxyServer.getPluginManager().getPlugin(id)) + .filter(Optional::isPresent) + .map(Optional::get) + .findFirst(); + + if (container.isEmpty()) { + logger.info("Tebex plugin not detected on Velocity proxy; Tebex integration disabled."); + return; + } + + Object tebexInstance; + try { + tebexInstance = container.get().getInstance().orElse(null); + } catch (Throwable throwable) { + logger.log(Level.WARNING, "Unable to obtain Tebex plugin instance for integration", throwable); + return; + } + + if (tebexInstance == null) { + logger.warning("Tebex plugin instance is not available; skipping Tebex integration."); + return; + } + + ClassLoader loader = tebexInstance.getClass().getClassLoader(); + EventManager eventManager = proxyServer.getEventManager(); + + for (String className : PURCHASE_EVENT_CLASSES) { + registerEvent(eventManager, loader, className, this::handlePurchase); + } + + for (String className : SUBSCRIPTION_EVENT_CLASSES) { + registerEvent(eventManager, loader, className, this::handleSubscription); + } + + logger.info(() -> "Tebex integration active via Velocity plugin '" + container.get().getDescription().getId() + "'."); + } + + public void stop() { + proxyServer.getEventManager().unregisterListeners(plugin); + } + + private void registerEvent(EventManager manager, ClassLoader loader, String className, Consumer handler) { + try { + Class clazz = Class.forName(className, false, loader); + manager.register(plugin, clazz, event -> handler.accept(clazz.cast(event))); + logger.fine(() -> "Registered Tebex Velocity event listener for " + className); + } catch (ClassNotFoundException ignored) { + } catch (Throwable throwable) { + logger.log(Level.WARNING, "Failed to register Tebex Velocity event '" + className + "'", throwable); + } + } + + private void handlePurchase(Object event) { + TebexEventContext context = TebexMetadataExtractor.extract(event); + Map metadata = new HashMap<>(context.metadata()); + metadata.put("bridgeEvent", "tebexPurchase"); + metadata.put("serverSlug", config.processor().serverSlug()); + + String routine = resolveRoutine(config.tebex().purchase().packageRoutines(), context, config.tebex().purchase().defaultRoutine()); + dispatchRoutine(routine, metadata, config.tebex().purchase().priority(), "purchase", context); + } + + private void handleSubscription(Object event) { + TebexEventContext context = TebexMetadataExtractor.extract(event); + Map metadata = new HashMap<>(context.metadata()); + metadata.put("bridgeEvent", "tebexSubscription"); + metadata.put("serverSlug", config.processor().serverSlug()); + + String routine = resolveRoutine(config.tebex().subscription().packageRoutines(), context, config.tebex().subscription().expirationRoutine()); + if (routine == null) { + routine = config.tebex().subscription().cancellationRoutine(); + } + dispatchRoutine(routine, metadata, config.tebex().subscription().priority(), "subscription", context); + } + + private void dispatchRoutine(String routine, + Map metadata, + int priority, + String eventType, + TebexEventContext context) { + if (routine == null || routine.isBlank()) { + return; + } + + try { + apiClient.queueRoutine(routine, config.processor().serverSlug(), metadata, priority); + logger.info(() -> String.format(Locale.ROOT, + "Queued Tebex %s routine '%s' for %s", eventType, routine, context.describePackage())); + } catch (IOException exception) { + logger.log(Level.WARNING, "Failed to queue Tebex routine '" + routine + "'", exception); + } + } + + private String resolveRoutine(Map mappings, TebexEventContext context, String defaultRoutine) { + if (context.packageId() != null) { + String mapped = mappings.get(context.packageId().toLowerCase(Locale.ROOT)); + if (mapped != null && !mapped.isBlank()) { + return mapped; + } + } + if (context.packageName() != null) { + String mapped = mappings.get(context.packageName().toLowerCase(Locale.ROOT)); + if (mapped != null && !mapped.isBlank()) { + return mapped; + } + } + return defaultRoutine; + } +} diff --git a/zander-bridge/src/main/resources/config.yml b/zander-bridge/src/main/resources/config.yml index 809c46e..16c3601 100644 --- a/zander-bridge/src/main/resources/config.yml +++ b/zander-bridge/src/main/resources/config.yml @@ -1,4 +1,23 @@ -BaseAPIURL: "https://yourapi.com" -APIKey: "your_api_key_here" -ServerName: "your_server_name_here" -SyncInterval: 5 \ No newline at end of file +bridge: + baseApiUrl: "https://your-api-host" + apiKey: "your_api_key_here" + serverSlug: "your_server_slug" + claimTasks: true + pollBatchSize: 25 + pollIntervalSeconds: 5 + status: + enabled: true + reportIntervalSeconds: 60 + tebex: + enabled: false + purchase: + defaultRoutine: null + priority: 0 + packageRoutines: + # "123456": "grant-vip" + subscription: + expirationRoutine: null + cancellationRoutine: null + priority: 0 + packageRoutines: + # "subscription-package": "remove-vip" diff --git a/zander-bridge/src/main/resources/plugin.yml b/zander-bridge/src/main/resources/plugin.yml index 88a014f..80e0996 100644 --- a/zander-bridge/src/main/resources/plugin.yml +++ b/zander-bridge/src/main/resources/plugin.yml @@ -1,4 +1,10 @@ name: zander-bridge -version: '1.0.0' -main: org.modularsoft.zander.bridge.ZanderBridge -api-version: '1.21' +version: '${project.version}' +main: org.modularsoft.zander.bridge.paper.PaperBridgePlugin +api-version: '1.20' +description: Bridge tasks between the Zander API and Paper servers. +author: Modular Software +softdepend: + - Tebex + - TebexPlugin + - BuycraftX diff --git a/zander-bridge/src/main/resources/velocity-plugin.json b/zander-bridge/src/main/resources/velocity-plugin.json new file mode 100644 index 0000000..f726258 --- /dev/null +++ b/zander-bridge/src/main/resources/velocity-plugin.json @@ -0,0 +1,18 @@ +{ + "id": "zander-bridge", + "name": "Zander Bridge", + "version": "${project.version}", + "description": "Bridge tasks between the Zander API and Velocity proxy.", + "authors": ["Modular Software"], + "dependencies": [ + { + "id": "tebex", + "optional": true + }, + { + "id": "buycraftx", + "optional": true + } + ], + "main": "org.modularsoft.zander.bridge.velocity.VelocityBridgePlugin" +} From 5f5a3e7fda0fb39b94044849cf39f74189160b48 Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Wed, 8 Oct 2025 21:08:55 +1100 Subject: [PATCH 02/16] Fix bridge platform command execution handling --- .../bridge/paper/PaperBridgePlatform.java | 4 ++-- .../bridge/paper/PaperBridgePlugin.java | 6 +++++- .../velocity/VelocityBridgePlatform.java | 19 ++++++++++++++----- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlatform.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlatform.java index 2c29963..2008864 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlatform.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlatform.java @@ -53,11 +53,11 @@ public void runAsync(Runnable task) { } @Override - public void executeConsoleCommand(String command) { + public void executeConsoleCommand(String command) throws Exception { ConsoleCommandSender console = Bukkit.getConsoleSender(); boolean success = Bukkit.dispatchCommand(console, command); if (!success) { - throw new IllegalStateException("Command execution returned false for: " + command); + throw new Exception("Command execution returned false for: " + command); } } diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlugin.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlugin.java index bcc686d..3c4cd8e 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlugin.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlugin.java @@ -52,7 +52,11 @@ private BridgeConfig loadBridgeConfig() { FileConfiguration configuration = getConfig(); String baseApiUrl = normaliseBaseUrl(configuration.getString("bridge.baseApiUrl", "http://localhost:3000")); String apiKey = configuration.getString("bridge.apiKey", ""); - String serverSlug = configuration.getString("bridge.serverSlug", getServer().getServerName()); + String configuredSlug = trimToNull(configuration.getString("bridge.serverSlug")); + String serverSlug = configuredSlug != null ? configuredSlug : trimToNull(getServer().getName()); + if (serverSlug == null) { + serverSlug = "paper-server"; + } boolean claimTasks = configuration.getBoolean("bridge.claimTasks", true); int pollBatchSize = Math.max(1, configuration.getInt("bridge.pollBatchSize", 25)); Duration pollInterval = Duration.ofSeconds(Math.max(1, configuration.getLong("bridge.pollIntervalSeconds", 5L))); diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java index 2e11609..5f1427f 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java @@ -50,13 +50,16 @@ public void runAsync(Runnable task) { } @Override - public void executeConsoleCommand(String command) { - CompletableFuture future = proxyServer.getCommandManager() + public void executeConsoleCommand(String command) throws Exception { + CompletableFuture future = proxyServer.getCommandManager() .executeAsync(proxyServer.getConsoleCommandSource(), command); try { - future.join(); + Boolean result = future.get(); + if (Boolean.FALSE.equals(result)) { + throw new Exception("Command execution returned false for: " + command); + } } catch (Exception exception) { - throw new IllegalStateException("Failed to execute command '" + command + "'", exception); + throw new Exception("Failed to execute command '" + command + "'", exception); } } @@ -76,7 +79,13 @@ public Map collectServerStatus() { return payload; } - private record ScheduledTaskWrapper(ScheduledTask scheduledTask) implements BridgePlatform.ScheduledTask { + private static final class ScheduledTaskWrapper implements BridgePlatform.ScheduledTask { + private final ScheduledTask scheduledTask; + + private ScheduledTaskWrapper(ScheduledTask scheduledTask) { + this.scheduledTask = scheduledTask; + } + @Override public void cancel() { scheduledTask.cancel(); From ebf3777e4044d6e54e573c5f260ac3bdae23a1da Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Wed, 8 Oct 2025 21:17:24 +1100 Subject: [PATCH 03/16] Wrap Velocity scheduled tasks in bridge handle --- .../zander/bridge/velocity/VelocityBridgePlatform.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java index 5f1427f..58a445b 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java @@ -1,7 +1,6 @@ package org.modularsoft.zander.bridge.velocity; import com.velocitypowered.api.proxy.ProxyServer; -import com.velocitypowered.api.scheduler.ScheduledTask; import org.modularsoft.zander.bridge.common.BridgePlatform; import java.util.HashMap; @@ -31,12 +30,12 @@ public Logger logger() { @Override public BridgePlatform.ScheduledTask scheduleRepeatingTask(Runnable task, long initialDelay, long repeatInterval, TimeUnit unit) { - ScheduledTask scheduledTask = proxyServer.getScheduler() + com.velocitypowered.api.scheduler.ScheduledTask velocityTask = proxyServer.getScheduler() .buildTask(pluginInstance, task) .delay(initialDelay, unit) .repeat(repeatInterval, unit) .schedule(); - return new ScheduledTaskWrapper(scheduledTask); + return new ScheduledTaskWrapper(velocityTask); } @Override @@ -80,9 +79,9 @@ public Map collectServerStatus() { } private static final class ScheduledTaskWrapper implements BridgePlatform.ScheduledTask { - private final ScheduledTask scheduledTask; + private final com.velocitypowered.api.scheduler.ScheduledTask scheduledTask; - private ScheduledTaskWrapper(ScheduledTask scheduledTask) { + private ScheduledTaskWrapper(com.velocitypowered.api.scheduler.ScheduledTask scheduledTask) { this.scheduledTask = scheduledTask; } From 406e37a6f7d26a73e39fbb59a5e95b51dee509cf Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Wed, 8 Oct 2025 21:26:40 +1100 Subject: [PATCH 04/16] Handle bridge API runtime request failures --- .../zander/bridge/common/BridgeApiClient.java | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeApiClient.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeApiClient.java index 6193c41..1f72142 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeApiClient.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeApiClient.java @@ -43,7 +43,7 @@ public List fetchTasks(String slug, boolean claim, int limit) throws .addHeader("x-access-token", apiKey) .build(); - Response response = request.execute(); + Response response = execute(request); if (response.getStatusCode() >= 400) { throw new IOException("Failed to fetch tasks: " + response.getStatusCode() + " - " + response.getBody()); } @@ -75,7 +75,7 @@ public void reportTask(long taskId, .setRequestBody(gson.toJson(payload)) .build(); - Response response = request.execute(); + Response response = execute(request); if (response.getStatusCode() >= 400) { throw new IOException("Failed to report task " + taskId + ": " + response.getStatusCode() + " - " + response.getBody()); } @@ -93,7 +93,7 @@ public void updateServerStatus(Map serverInfo, Instant lastUpdat .setRequestBody(gson.toJson(payload)) .build(); - Response response = request.execute(); + Response response = execute(request); if (response.getStatusCode() >= 400) { throw new IOException("Failed to update server status: " + response.getStatusCode() + " - " + response.getBody()); } @@ -126,12 +126,24 @@ public void queueRoutine(String routineSlug, .setRequestBody(gson.toJson(payload)) .build(); - Response response = request.execute(); + Response response = execute(request); if (response.getStatusCode() >= 400) { throw new IOException("Failed to queue routine '" + routineSlug + "': " + response.getStatusCode() + " - " + response.getBody()); } } + private Response execute(Request request) throws IOException { + try { + return request.execute(); + } catch (RuntimeException exception) { + String message = exception.getMessage(); + if (message == null || message.isBlank()) { + message = exception.getClass().getSimpleName(); + } + throw new IOException("Exception raised while sending request: " + message, exception); + } + } + private List parseTasks(String body) { JsonObject root = JsonParser.parseString(body).getAsJsonObject(); JsonArray data = root.getAsJsonArray("data"); From be4f75443cf5c9970fee602256fc605baa1afebc Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Wed, 8 Oct 2025 21:33:05 +1100 Subject: [PATCH 05/16] Improve bridge API failure diagnostics --- .../zander/bridge/common/BridgeService.java | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java index 6391446..2c2637f 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java @@ -6,6 +6,7 @@ import java.time.Instant; import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.logging.Level; public final class BridgeService { @@ -15,6 +16,7 @@ public final class BridgeService { private BridgePlatform.ScheduledTask taskPoller; private BridgePlatform.ScheduledTask statusReporter; + private int consecutiveTaskFetchFailures; public BridgeService(BridgePlatform platform, BridgeConfig config) { this.platform = platform; @@ -64,6 +66,12 @@ private void pollAndExecuteTasks() { config.processor().claimTasks(), config.processor().pollBatchSize()); + if (consecutiveTaskFetchFailures > 0) { + platform.logger().info("Bridge API task polling recovered after " + + consecutiveTaskFetchFailures + " failure(s)."); + consecutiveTaskFetchFailures = 0; + } + if (tasks.isEmpty()) { return; } @@ -72,7 +80,15 @@ private void pollAndExecuteTasks() { executeTask(task); } } catch (IOException exception) { - platform.logger().warning("Failed to fetch tasks from bridge API: " + exception.getMessage()); + consecutiveTaskFetchFailures++; + platform.logger().log( + Level.WARNING, + "Failed to fetch tasks from bridge API (attempt " + consecutiveTaskFetchFailures + + ", baseUrl=" + config.baseApiUrl() + + ", slug=" + config.processor().serverSlug() + + ", claim=" + config.processor().claimTasks() + + ", limit=" + config.processor().pollBatchSize() + ")", + exception); } } @@ -92,7 +108,10 @@ private void reportTask(BridgeTask task, TaskStatus status, String result, JsonE try { apiClient.reportTask(task.id(), status, result, config.processor().serverSlug(), metadata); } catch (IOException exception) { - platform.logger().warning("Failed to report task " + task.id() + " to bridge API: " + exception.getMessage()); + platform.logger().log( + Level.WARNING, + "Failed to report task " + task.id() + " to bridge API (status=" + status + ")", + exception); } } @@ -100,7 +119,10 @@ private void reportServerStatus() { try { apiClient.updateServerStatus(platform.collectServerStatus(), Instant.now()); } catch (IOException exception) { - platform.logger().warning("Failed to update server status: " + exception.getMessage()); + platform.logger().log( + Level.WARNING, + "Failed to update server status via bridge API", + exception); } } } From cb0d0698877491725689ebfcadbe1c96ef05be7f Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Wed, 8 Oct 2025 21:42:21 +1100 Subject: [PATCH 06/16] Preserve Velocity favicon when updating MOTD --- .../zander/velocity/events/UserOnProxyPing.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java index 2c0038a..26690ef 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java @@ -23,8 +23,11 @@ public UserOnProxyPing(ZanderVelocityMain plugin) { @Subscribe(order = PostOrder.FIRST) public void onProxyPingEvent(ProxyPingEvent event) { - // Get the existing ServerPing.Builder from the event - Builder pingBuilder = event.getPing().asBuilder(); + // Get the existing ServerPing.Builder from the event and ensure we keep the + // original favicon (Velocity drops it if the builder does not explicitly set it). + var originalPing = event.getPing(); + Builder pingBuilder = originalPing.asBuilder(); + originalPing.getFavicon().ifPresent(pingBuilder::favicon); try { // Fetch configuration values From d33cbbbd5de1556f871440927be104f2e8b644b7 Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Wed, 8 Oct 2025 22:02:25 +1100 Subject: [PATCH 07/16] Add Votifier voting integration to bridge --- zander-bridge/pom.xml | 6 ++ .../zander/bridge/common/BridgeConfig.java | 23 +++++- .../bridge/common/voting/VoteContext.java | 7 ++ .../voting/VotingRoutineDispatcher.java | 79 +++++++++++++++++++ .../bridge/paper/PaperBridgePlugin.java | 38 ++++++++- .../voting/VotifierPaperIntegration.java | 61 ++++++++++++++ .../bridge/velocity/VelocityBridgePlugin.java | 35 +++++++- .../voting/VotifierVelocityIntegration.java | 60 ++++++++++++++ zander-bridge/src/main/resources/config.yml | 6 ++ zander-bridge/src/main/resources/plugin.yml | 2 + .../src/main/resources/velocity-plugin.json | 4 + 11 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VoteContext.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VotingRoutineDispatcher.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java create mode 100644 zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/voting/VotifierVelocityIntegration.java diff --git a/zander-bridge/pom.xml b/zander-bridge/pom.xml index ddb7d3e..9a8e912 100644 --- a/zander-bridge/pom.xml +++ b/zander-bridge/pom.xml @@ -92,6 +92,12 @@ 3.2.0-SNAPSHOT provided + + com.vexsoftware + votifier + 2.7.3 + provided + io.github.ModularEnigma Requests diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeConfig.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeConfig.java index 28b4b7b..1b4cd47 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeConfig.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeConfig.java @@ -15,17 +15,20 @@ public final class BridgeConfig { private final Processor processor; private final StatusReporter statusReporter; private final TebexIntegration tebex; + private final VotingIntegration voting; public BridgeConfig(String baseApiUrl, String apiKey, Processor processor, StatusReporter statusReporter, - TebexIntegration tebex) { + TebexIntegration tebex, + VotingIntegration voting) { this.baseApiUrl = Objects.requireNonNull(baseApiUrl, "baseApiUrl"); this.apiKey = Objects.requireNonNull(apiKey, "apiKey"); this.processor = Objects.requireNonNull(processor, "processor"); this.statusReporter = Objects.requireNonNull(statusReporter, "statusReporter"); this.tebex = Objects.requireNonNull(tebex, "tebex"); + this.voting = Objects.requireNonNull(voting, "voting"); } public String baseApiUrl() { @@ -48,6 +51,10 @@ public TebexIntegration tebex() { return tebex; } + public VotingIntegration voting() { + return voting; + } + public record Processor(String serverSlug, boolean claimTasks, int pollBatchSize, @@ -120,4 +127,18 @@ public static Subscription disabled() { } } } + + public record VotingIntegration(boolean enabled, + String defaultRoutine, + Map serviceRoutines, + int priority) { + + public VotingIntegration { + serviceRoutines = Map.copyOf(serviceRoutines); + } + + public static VotingIntegration disabled() { + return new VotingIntegration(false, null, Map.of(), 0); + } + } } diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VoteContext.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VoteContext.java new file mode 100644 index 0000000..0deed84 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VoteContext.java @@ -0,0 +1,7 @@ +package org.modularsoft.zander.bridge.common.voting; + +public record VoteContext(String username, + String serviceName, + String address, + String timestamp) { +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VotingRoutineDispatcher.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VotingRoutineDispatcher.java new file mode 100644 index 0000000..19a7721 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VotingRoutineDispatcher.java @@ -0,0 +1,79 @@ +package org.modularsoft.zander.bridge.common.voting; + +import org.modularsoft.zander.bridge.common.BridgeApiClient; +import org.modularsoft.zander.bridge.common.BridgeConfig; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class VotingRoutineDispatcher { + + private final BridgeApiClient apiClient; + private final BridgeConfig config; + private final Logger logger; + private final String platform; + + public VotingRoutineDispatcher(BridgeApiClient apiClient, + BridgeConfig config, + Logger logger, + String platform) { + this.apiClient = apiClient; + this.config = config; + this.logger = logger; + this.platform = platform; + } + + public void handleVote(VoteContext context) { + BridgeConfig.VotingIntegration voting = config.voting(); + if (!voting.enabled()) { + return; + } + + String routine = resolveRoutine(voting, context.serviceName()); + if (routine == null || routine.isBlank()) { + logger.fine(() -> "No voting routine configured for service '" + context.serviceName() + "'"); + return; + } + + Map metadata = new HashMap<>(); + metadata.put("bridgeEvent", "vote"); + metadata.put("platform", platform); + metadata.put("serverSlug", config.processor().serverSlug()); + putIfNotBlank(metadata, "username", context.username()); + putIfNotBlank(metadata, "serviceName", context.serviceName()); + putIfNotBlank(metadata, "address", context.address()); + putIfNotBlank(metadata, "timestamp", context.timestamp()); + + try { + apiClient.queueRoutine(routine, config.processor().serverSlug(), metadata, voting.priority()); + logger.info(() -> String.format(Locale.ROOT, + "Queued voting routine '%s' for player '%s' via service '%s'", + routine, + context.username() != null ? context.username() : "unknown", + context.serviceName() != null ? context.serviceName() : "unknown")); + } catch (IOException exception) { + logger.log(Level.WARNING, "Failed to queue voting routine '" + routine + "'", exception); + } + } + + private String resolveRoutine(BridgeConfig.VotingIntegration voting, String serviceName) { + if (serviceName != null) { + String key = serviceName.trim().toLowerCase(Locale.ROOT); + String mapped = voting.serviceRoutines().get(key); + if (mapped != null && !mapped.isBlank()) { + return mapped; + } + } + return voting.defaultRoutine(); + } + + private void putIfNotBlank(Map target, String key, String value) { + if (value != null && !value.isBlank()) { + target.put(key, value); + } + } +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlugin.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlugin.java index 3c4cd8e..03750c6 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlugin.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlugin.java @@ -1,11 +1,14 @@ package org.modularsoft.zander.bridge.paper; -import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.FileConfiguration; import org.modularsoft.zander.bridge.common.BridgeConfig; -import org.modularsoft.zander.bridge.common.BridgeService; import org.modularsoft.zander.bridge.common.BridgeConfig.TebexIntegration; +import org.modularsoft.zander.bridge.common.BridgeConfig.VotingIntegration; +import org.modularsoft.zander.bridge.common.BridgeService; +import org.modularsoft.zander.bridge.common.voting.VotingRoutineDispatcher; import org.modularsoft.zander.bridge.paper.tebex.TebexPaperIntegration; +import org.modularsoft.zander.bridge.paper.voting.VotifierPaperIntegration; import org.bukkit.plugin.java.JavaPlugin; @@ -17,6 +20,7 @@ public final class PaperBridgePlugin extends JavaPlugin { private BridgeService bridgeService; private TebexPaperIntegration tebexIntegration; + private VotifierPaperIntegration votifierIntegration; @Override public void onEnable() { @@ -33,6 +37,16 @@ public void onEnable() { tebexIntegration.start(); } + if (config.voting().enabled()) { + VotingRoutineDispatcher dispatcher = new VotingRoutineDispatcher( + bridgeService.apiClient(), + config, + getLogger(), + "paper"); + votifierIntegration = new VotifierPaperIntegration(this, dispatcher); + votifierIntegration.start(); + } + getLogger().info(() -> "Zander Bridge (Paper) enabled for server slug '" + config.processor().serverSlug() + "'"); } @@ -42,6 +56,10 @@ public void onDisable() { tebexIntegration.stop(); tebexIntegration = null; } + if (votifierIntegration != null) { + votifierIntegration.stop(); + votifierIntegration = null; + } if (bridgeService != null) { bridgeService.stop(); bridgeService = null; @@ -68,8 +86,9 @@ private BridgeConfig loadBridgeConfig() { BridgeConfig.StatusReporter statusReporter = new BridgeConfig.StatusReporter(statusEnabled, statusInterval); TebexIntegration tebexIntegration = loadTebexConfig(configuration.getConfigurationSection("bridge.tebex")); + VotingIntegration votingIntegration = loadVotingConfig(configuration.getConfigurationSection("bridge.voting")); - return new BridgeConfig(baseApiUrl, apiKey, processor, statusReporter, tebexIntegration); + return new BridgeConfig(baseApiUrl, apiKey, processor, statusReporter, tebexIntegration, votingIntegration); } private String normaliseBaseUrl(String url) { @@ -116,6 +135,19 @@ private TebexIntegration.Subscription loadSubscriptionConfig(ConfigurationSectio return new TebexIntegration.Subscription(expirationRoutine, cancellationRoutine, readRoutineMappings(mappings), priority); } + private VotingIntegration loadVotingConfig(ConfigurationSection section) { + if (section == null) { + return VotingIntegration.disabled(); + } + + boolean enabled = section.getBoolean("enabled", false); + String defaultRoutine = trimToNull(section.getString("defaultRoutine")); + int priority = section.getInt("priority", 0); + ConfigurationSection mappings = section.getConfigurationSection("serviceRoutines"); + + return new VotingIntegration(enabled, defaultRoutine, readRoutineMappings(mappings), priority); + } + private Map readRoutineMappings(ConfigurationSection section) { if (section == null) { return Map.of(); diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java new file mode 100644 index 0000000..eb847c5 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java @@ -0,0 +1,61 @@ +package org.modularsoft.zander.bridge.paper.voting; + +import com.vexsoftware.votifier.model.Vote; +import com.vexsoftware.votifier.model.VotifierEvent; +import org.bukkit.Bukkit; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.HandlerList; +import org.bukkit.plugin.java.JavaPlugin; +import org.modularsoft.zander.bridge.common.voting.VoteContext; +import org.modularsoft.zander.bridge.common.voting.VotingRoutineDispatcher; + +public final class VotifierPaperIntegration implements Listener { + + private final JavaPlugin plugin; + private final VotingRoutineDispatcher dispatcher; + private boolean registered; + + public VotifierPaperIntegration(JavaPlugin plugin, VotingRoutineDispatcher dispatcher) { + this.plugin = plugin; + this.dispatcher = dispatcher; + } + + public void start() { + if (!isVotifierPresent()) { + plugin.getLogger().warning("Votifier plugin not detected; voting integration disabled."); + return; + } + + if (!registered) { + Bukkit.getPluginManager().registerEvents(this, plugin); + registered = true; + plugin.getLogger().info("Votifier integration active; votes will queue bridge routines."); + } + } + + public void stop() { + if (registered) { + HandlerList.unregisterAll(this); + registered = false; + } + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onVote(VotifierEvent event) { + Vote vote = event.getVote(); + VoteContext context = new VoteContext( + vote != null ? vote.getUsername() : null, + vote != null ? vote.getServiceName() : null, + vote != null ? vote.getAddress() : null, + vote != null ? vote.getTimeStamp() : null + ); + dispatcher.handleVote(context); + } + + private boolean isVotifierPresent() { + return Bukkit.getPluginManager().getPlugin("Votifier") != null + || Bukkit.getPluginManager().getPlugin("NuVotifier") != null; + } +} diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlugin.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlugin.java index b11ec27..0dce9a0 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlugin.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlugin.java @@ -7,9 +7,12 @@ import com.velocitypowered.api.plugin.Plugin; import com.velocitypowered.api.proxy.ProxyServer; import org.modularsoft.zander.bridge.common.BridgeConfig; -import org.modularsoft.zander.bridge.common.BridgeService; import org.modularsoft.zander.bridge.common.BridgeConfig.TebexIntegration; +import org.modularsoft.zander.bridge.common.BridgeConfig.VotingIntegration; +import org.modularsoft.zander.bridge.common.BridgeService; +import org.modularsoft.zander.bridge.common.voting.VotingRoutineDispatcher; import org.modularsoft.zander.bridge.velocity.tebex.TebexVelocityIntegration; +import org.modularsoft.zander.bridge.velocity.voting.VotifierVelocityIntegration; import org.yaml.snakeyaml.Yaml; import java.io.IOException; @@ -30,6 +33,7 @@ public final class VelocityBridgePlugin { private BridgeService bridgeService; private TebexVelocityIntegration tebexIntegration; + private VotifierVelocityIntegration votifierIntegration; @Inject public VelocityBridgePlugin(ProxyServer proxyServer, Logger logger, @com.velocitypowered.api.plugin.annotation.DataDirectory Path dataDirectory) { this.proxyServer = proxyServer; @@ -48,6 +52,15 @@ public void onProxyInitialization(ProxyInitializeEvent event) { tebexIntegration = new TebexVelocityIntegration(this, proxyServer, logger, bridgeService.apiClient(), configuration); tebexIntegration.start(); } + if (configuration.voting().enabled()) { + VotingRoutineDispatcher dispatcher = new VotingRoutineDispatcher( + bridgeService.apiClient(), + configuration, + logger, + "velocity"); + votifierIntegration = new VotifierVelocityIntegration(this, proxyServer, logger, dispatcher); + votifierIntegration.start(); + } logger.info(() -> "Zander Bridge (Velocity) enabled for server slug '" + configuration.processor().serverSlug() + "'"); } catch (IOException exception) { logger.severe("Failed to start Zander Bridge: " + exception.getMessage()); @@ -64,6 +77,10 @@ public void onProxyShutdown(ProxyShutdownEvent event) { tebexIntegration.stop(); tebexIntegration = null; } + if (votifierIntegration != null) { + votifierIntegration.stop(); + votifierIntegration = null; + } } private BridgeConfig loadConfiguration() throws IOException { @@ -106,7 +123,8 @@ private BridgeConfig loadConfiguration() throws IOException { BridgeConfig.Processor processor = new BridgeConfig.Processor(serverSlug, claimTasks, pollBatchSize, pollInterval); BridgeConfig.StatusReporter statusReporter = new BridgeConfig.StatusReporter(statusEnabled, statusInterval); TebexIntegration tebexIntegration = loadTebexConfiguration(getSection(bridgeSection, "tebex")); - return new BridgeConfig(baseApiUrl, apiKey, processor, statusReporter, tebexIntegration); + VotingIntegration votingIntegration = loadVotingConfiguration(getSection(bridgeSection, "voting")); + return new BridgeConfig(baseApiUrl, apiKey, processor, statusReporter, tebexIntegration, votingIntegration); } private Map getSection(Map parent, String key) { @@ -199,6 +217,19 @@ private Map readRoutineMappings(Map section) { return mappings; } + private VotingIntegration loadVotingConfiguration(Map section) { + if (section.isEmpty()) { + return VotingIntegration.disabled(); + } + + boolean enabled = getBoolean(section, "enabled", false); + String defaultRoutine = trimToNull(section.get("defaultRoutine")); + int priority = getInteger(section, "priority", 0); + Map serviceMappings = readRoutineMappings(getSection(section, "serviceRoutines")); + + return new VotingIntegration(enabled, defaultRoutine, serviceMappings, priority); + } + private String trimToNull(Object value) { if (value instanceof String string) { String trimmed = string.trim(); diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/voting/VotifierVelocityIntegration.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/voting/VotifierVelocityIntegration.java new file mode 100644 index 0000000..0a29c20 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/voting/VotifierVelocityIntegration.java @@ -0,0 +1,60 @@ +package org.modularsoft.zander.bridge.velocity.voting; + +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.proxy.ProxyServer; +import com.vexsoftware.votifier.model.Vote; +import com.vexsoftware.votifier.velocity.event.VotifierEvent; +import org.modularsoft.zander.bridge.common.voting.VoteContext; +import org.modularsoft.zander.bridge.common.voting.VotingRoutineDispatcher; +import org.modularsoft.zander.bridge.velocity.VelocityBridgePlugin; + +import java.util.Optional; +import java.util.logging.Logger; + +public final class VotifierVelocityIntegration { + + private static final String NUVOTIFIER_ID = "nuvotifier"; + + private final VelocityBridgePlugin plugin; + private final ProxyServer proxyServer; + private final Logger logger; + private final VotingRoutineDispatcher dispatcher; + + public VotifierVelocityIntegration(VelocityBridgePlugin plugin, + ProxyServer proxyServer, + Logger logger, + VotingRoutineDispatcher dispatcher) { + this.plugin = plugin; + this.proxyServer = proxyServer; + this.logger = logger; + this.dispatcher = dispatcher; + } + + public void start() { + Optional container = proxyServer.getPluginManager().getPlugin(NUVOTIFIER_ID); + if (container.isEmpty()) { + logger.warning("NuVotifier plugin not detected; voting integration disabled."); + return; + } + + proxyServer.getEventManager().register(plugin, this); + logger.info("NuVotifier integration active; votes will queue bridge routines."); + } + + public void stop() { + proxyServer.getEventManager().unregisterListeners(plugin); + } + + @Subscribe + public void onVote(VotifierEvent event) { + Vote vote = event.getVote(); + VoteContext context = new VoteContext( + vote != null ? vote.getUsername() : null, + vote != null ? vote.getServiceName() : null, + vote != null ? vote.getAddress() : null, + vote != null ? vote.getTimeStamp() : null + ); + dispatcher.handleVote(context); + } +} diff --git a/zander-bridge/src/main/resources/config.yml b/zander-bridge/src/main/resources/config.yml index 16c3601..11bfa90 100644 --- a/zander-bridge/src/main/resources/config.yml +++ b/zander-bridge/src/main/resources/config.yml @@ -21,3 +21,9 @@ bridge: priority: 0 packageRoutines: # "subscription-package": "remove-vip" + voting: + enabled: false + defaultRoutine: null + priority: 0 + serviceRoutines: + # "minecraftservers.org": "vote-routine" diff --git a/zander-bridge/src/main/resources/plugin.yml b/zander-bridge/src/main/resources/plugin.yml index 80e0996..82cfc2c 100644 --- a/zander-bridge/src/main/resources/plugin.yml +++ b/zander-bridge/src/main/resources/plugin.yml @@ -8,3 +8,5 @@ softdepend: - Tebex - TebexPlugin - BuycraftX + - Votifier + - NuVotifier diff --git a/zander-bridge/src/main/resources/velocity-plugin.json b/zander-bridge/src/main/resources/velocity-plugin.json index f726258..3ce6cdc 100644 --- a/zander-bridge/src/main/resources/velocity-plugin.json +++ b/zander-bridge/src/main/resources/velocity-plugin.json @@ -12,6 +12,10 @@ { "id": "buycraftx", "optional": true + }, + { + "id": "nuvotifier", + "optional": true } ], "main": "org.modularsoft.zander.bridge.velocity.VelocityBridgePlugin" From fdfc8d9cab28efc323bcc6cb1dbe5dd5dd7aff6d Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Wed, 8 Oct 2025 22:02:40 +1100 Subject: [PATCH 08/16] Remove legacy bridge routine dispatch from Velocity vote listener --- .../zander/velocity/events/UserOnVote.java | 27 ++----------------- .../velocity/model/vote/BridgeProcess.java | 17 ------------ .../model/vote/BridgeRoutineProcess.java | 19 ------------- 3 files changed, 2 insertions(+), 61 deletions(-) delete mode 100644 zander-velocity/src/main/java/org/modularsoft/zander/velocity/model/vote/BridgeProcess.java delete mode 100644 zander-velocity/src/main/java/org/modularsoft/zander/velocity/model/vote/BridgeRoutineProcess.java diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnVote.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnVote.java index 8c042fd..dec1e08 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnVote.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnVote.java @@ -1,14 +1,11 @@ package org.modularsoft.zander.velocity.events; import com.velocitypowered.api.event.Subscribe; -import com.vexsoftware.votifier.model.Vote; import com.vexsoftware.votifier.velocity.event.VotifierEvent; import dev.dejvokep.boostedyaml.route.Route; import io.github.ModularEnigma.Request; import io.github.ModularEnigma.Response; -import net.kyori.adventure.text.Component; import org.modularsoft.zander.velocity.ZanderVelocityMain; -import org.modularsoft.zander.velocity.model.vote.BridgeRoutineProcess; import org.modularsoft.zander.velocity.model.vote.VoteProcess; import org.slf4j.Logger; @@ -50,26 +47,6 @@ public void onVote(VotifierEvent event) { logger.error("Error in submitting vote: " + e.getMessage()); } - try { - // - // Send routine to API for processing - // - BridgeRoutineProcess bridgeRoutine = BridgeRoutineProcess.builder() - .username(username) - .routine("vote") - .build(); - - Request bridgeRoutineReq = Request.builder() - .setURL(BaseAPIURL + "/bridge/routine/execute") - .setMethod(Request.Method.POST) - .addHeader("x-access-token", APIKey) - .setRequestBody(bridgeRoutine.toString()) - .build(); - - Response bridgeRoutineRes = bridgeRoutineReq.execute(); - logger.info("Response (" + bridgeRoutineRes.getStatusCode() + "): " + bridgeRoutineRes.getBody()); - } catch (Exception e) { - logger.error("Error in sending routine: " + e.getMessage()); - } + logger.info("Bridge routines are handled by zander-bridge when running on the proxy; skipping legacy bridge dispatch."); } -} \ No newline at end of file +} diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/model/vote/BridgeProcess.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/model/vote/BridgeProcess.java deleted file mode 100644 index 03290a2..0000000 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/model/vote/BridgeProcess.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.modularsoft.zander.velocity.model.vote; - -import com.google.gson.Gson; -import lombok.Builder; -import lombok.Getter; - -@Builder -public class BridgeProcess { - - @Getter String id; - - @Override - public String toString() { - return new Gson().toJson(this); - } - -} diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/model/vote/BridgeRoutineProcess.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/model/vote/BridgeRoutineProcess.java deleted file mode 100644 index 8e4c74e..0000000 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/model/vote/BridgeRoutineProcess.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.modularsoft.zander.velocity.model.vote; - -import com.google.gson.Gson; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class BridgeRoutineProcess { - - String username; - String routine; - - @Override - public String toString() { - return new Gson().toJson(this); - } - -} From 0a06bc552d987c505460836efd7d03921f1d3ac2 Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Wed, 8 Oct 2025 22:18:54 +1100 Subject: [PATCH 09/16] Use NuVotifier artifact for vote listener support --- zander-bridge/pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zander-bridge/pom.xml b/zander-bridge/pom.xml index 9a8e912..3659022 100644 --- a/zander-bridge/pom.xml +++ b/zander-bridge/pom.xml @@ -93,8 +93,8 @@ provided - com.vexsoftware - votifier + com.github.NuVotifier + NuVotifier 2.7.3 provided From 818b5634416525327cb7f7c81ced00299bb3087c Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Wed, 8 Oct 2025 22:25:47 +1100 Subject: [PATCH 10/16] Remove NuVotifier compile dependency --- zander-bridge/pom.xml | 6 -- .../voting/VotifierPaperIntegration.java | 102 ++++++++++++++---- .../voting/VotifierVelocityIntegration.java | 95 +++++++++++++--- 3 files changed, 163 insertions(+), 40 deletions(-) diff --git a/zander-bridge/pom.xml b/zander-bridge/pom.xml index 3659022..ddb7d3e 100644 --- a/zander-bridge/pom.xml +++ b/zander-bridge/pom.xml @@ -92,12 +92,6 @@ 3.2.0-SNAPSHOT provided - - com.github.NuVotifier - NuVotifier - 2.7.3 - provided - io.github.ModularEnigma Requests diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java index eb847c5..cc47315 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java @@ -1,38 +1,64 @@ package org.modularsoft.zander.bridge.paper.voting; -import com.vexsoftware.votifier.model.Vote; -import com.vexsoftware.votifier.model.VotifierEvent; import org.bukkit.Bukkit; -import org.bukkit.event.EventHandler; +import org.bukkit.event.Event; import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.EventExecutor; +import org.bukkit.plugin.PluginManager; import org.bukkit.plugin.java.JavaPlugin; import org.modularsoft.zander.bridge.common.voting.VoteContext; import org.modularsoft.zander.bridge.common.voting.VotingRoutineDispatcher; +import java.lang.reflect.Method; +import java.util.logging.Level; +import java.util.logging.Logger; + public final class VotifierPaperIntegration implements Listener { + private static final String VOTIFIER_EVENT_CLASS = "com.vexsoftware.votifier.model.VotifierEvent"; + private static final String VOTE_CLASS = "com.vexsoftware.votifier.model.Vote"; + private final JavaPlugin plugin; private final VotingRoutineDispatcher dispatcher; + private final Logger logger; + + private Class eventClass; + private Method eventGetVote; + private Method voteGetUsername; + private Method voteGetServiceName; + private Method voteGetAddress; + private Method voteGetTimestamp; + private boolean registered; public VotifierPaperIntegration(JavaPlugin plugin, VotingRoutineDispatcher dispatcher) { this.plugin = plugin; this.dispatcher = dispatcher; + this.logger = plugin.getLogger(); } public void start() { if (!isVotifierPresent()) { - plugin.getLogger().warning("Votifier plugin not detected; voting integration disabled."); + logger.warning("Votifier plugin not detected; voting integration disabled."); return; } - if (!registered) { - Bukkit.getPluginManager().registerEvents(this, plugin); - registered = true; - plugin.getLogger().info("Votifier integration active; votes will queue bridge routines."); + if (!resolveReflection()) { + logger.warning("Unable to locate Votifier classes; voting integration disabled."); + return; } + + if (registered) { + return; + } + + PluginManager pluginManager = Bukkit.getPluginManager(); + EventExecutor executor = this::handleVoteEvent; + pluginManager.registerEvent(eventClass, this, EventPriority.NORMAL, executor, plugin, true); + registered = true; + logger.info("Votifier integration active; votes will queue bridge routines."); } public void stop() { @@ -42,16 +68,54 @@ public void stop() { } } - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onVote(VotifierEvent event) { - Vote vote = event.getVote(); - VoteContext context = new VoteContext( - vote != null ? vote.getUsername() : null, - vote != null ? vote.getServiceName() : null, - vote != null ? vote.getAddress() : null, - vote != null ? vote.getTimeStamp() : null - ); - dispatcher.handleVote(context); + private void handleVoteEvent(Listener listener, Event event) { + if (eventClass == null || !eventClass.isInstance(event)) { + return; + } + + try { + Object vote = eventGetVote.invoke(event); + VoteContext context = new VoteContext( + invokeString(vote, voteGetUsername), + invokeString(vote, voteGetServiceName), + invokeString(vote, voteGetAddress), + invokeString(vote, voteGetTimestamp) + ); + dispatcher.handleVote(context); + } catch (Exception ex) { + logger.log(Level.SEVERE, "Failed to process Votifier vote event", ex); + } + } + + private String invokeString(Object target, Method method) throws Exception { + if (target == null || method == null) { + return null; + } + Object result = method.invoke(target); + return result != null ? result.toString() : null; + } + + @SuppressWarnings("unchecked") + private boolean resolveReflection() { + if (eventClass != null) { + return true; + } + + try { + Class rawEventClass = Class.forName(VOTIFIER_EVENT_CLASS); + Class voteClass = Class.forName(VOTE_CLASS); + + eventClass = (Class) rawEventClass; + eventGetVote = rawEventClass.getMethod("getVote"); + voteGetUsername = voteClass.getMethod("getUsername"); + voteGetServiceName = voteClass.getMethod("getServiceName"); + voteGetAddress = voteClass.getMethod("getAddress"); + voteGetTimestamp = voteClass.getMethod("getTimeStamp"); + return true; + } catch (ClassNotFoundException | NoSuchMethodException ex) { + logger.log(Level.WARNING, "Failed to load Votifier classes", ex); + return false; + } } private boolean isVotifierPresent() { diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/voting/VotifierVelocityIntegration.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/voting/VotifierVelocityIntegration.java index 0a29c20..7c16e92 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/voting/VotifierVelocityIntegration.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/voting/VotifierVelocityIntegration.java @@ -1,26 +1,36 @@ package org.modularsoft.zander.bridge.velocity.voting; -import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.EventHandler; import com.velocitypowered.api.plugin.PluginContainer; import com.velocitypowered.api.proxy.ProxyServer; -import com.vexsoftware.votifier.model.Vote; -import com.vexsoftware.votifier.velocity.event.VotifierEvent; import org.modularsoft.zander.bridge.common.voting.VoteContext; import org.modularsoft.zander.bridge.common.voting.VotingRoutineDispatcher; import org.modularsoft.zander.bridge.velocity.VelocityBridgePlugin; +import java.lang.reflect.Method; import java.util.Optional; +import java.util.logging.Level; import java.util.logging.Logger; public final class VotifierVelocityIntegration { private static final String NUVOTIFIER_ID = "nuvotifier"; + private static final String VOTIFIER_EVENT_CLASS = "com.vexsoftware.votifier.velocity.event.VotifierEvent"; + private static final String VOTE_CLASS = "com.vexsoftware.votifier.model.Vote"; private final VelocityBridgePlugin plugin; private final ProxyServer proxyServer; private final Logger logger; private final VotingRoutineDispatcher dispatcher; + private Class eventClass; + private Method eventGetVote; + private Method voteGetUsername; + private Method voteGetServiceName; + private Method voteGetAddress; + private Method voteGetTimestamp; + private boolean registered; + public VotifierVelocityIntegration(VelocityBridgePlugin plugin, ProxyServer proxyServer, Logger logger, @@ -38,23 +48,78 @@ public void start() { return; } - proxyServer.getEventManager().register(plugin, this); + if (!resolveReflection()) { + logger.warning("Unable to locate NuVotifier classes; voting integration disabled."); + return; + } + + if (registered) { + return; + } + + EventHandler handler = this::handleVoteEvent; + proxyServer.getEventManager().register(plugin, castEventClass(), handler); + registered = true; logger.info("NuVotifier integration active; votes will queue bridge routines."); } public void stop() { - proxyServer.getEventManager().unregisterListeners(plugin); + if (registered) { + proxyServer.getEventManager().unregisterListeners(plugin); + registered = false; + } } - @Subscribe - public void onVote(VotifierEvent event) { - Vote vote = event.getVote(); - VoteContext context = new VoteContext( - vote != null ? vote.getUsername() : null, - vote != null ? vote.getServiceName() : null, - vote != null ? vote.getAddress() : null, - vote != null ? vote.getTimeStamp() : null - ); - dispatcher.handleVote(context); + private void handleVoteEvent(Object event) { + if (eventClass == null || !eventClass.isInstance(event)) { + return; + } + + try { + Object vote = eventGetVote.invoke(event); + VoteContext context = new VoteContext( + invokeString(vote, voteGetUsername), + invokeString(vote, voteGetServiceName), + invokeString(vote, voteGetAddress), + invokeString(vote, voteGetTimestamp) + ); + dispatcher.handleVote(context); + } catch (Exception ex) { + logger.log(Level.SEVERE, "Failed to process NuVotifier vote event", ex); + } + } + + private String invokeString(Object target, Method method) throws Exception { + if (target == null || method == null) { + return null; + } + Object result = method.invoke(target); + return result != null ? result.toString() : null; + } + + @SuppressWarnings("unchecked") + private Class castEventClass() { + return (Class) eventClass; + } + + private boolean resolveReflection() { + if (eventClass != null) { + return true; + } + + try { + eventClass = Class.forName(VOTIFIER_EVENT_CLASS); + Class voteClass = Class.forName(VOTE_CLASS); + + eventGetVote = eventClass.getMethod("getVote"); + voteGetUsername = voteClass.getMethod("getUsername"); + voteGetServiceName = voteClass.getMethod("getServiceName"); + voteGetAddress = voteClass.getMethod("getAddress"); + voteGetTimestamp = voteClass.getMethod("getTimeStamp"); + return true; + } catch (ClassNotFoundException | NoSuchMethodException ex) { + logger.log(Level.WARNING, "Failed to load NuVotifier classes", ex); + return false; + } } } From cf98d1f3839baa3ebecc17e41b2fab79080bd20c Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Wed, 8 Oct 2025 22:29:41 +1100 Subject: [PATCH 11/16] Fix Paper Votifier listener import --- .../zander/bridge/paper/voting/VotifierPaperIntegration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java index cc47315..a2b7f70 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java @@ -5,7 +5,7 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.HandlerList; import org.bukkit.event.Listener; -import org.bukkit.event.EventExecutor; +import org.bukkit.plugin.EventExecutor; import org.bukkit.plugin.PluginManager; import org.bukkit.plugin.java.JavaPlugin; import org.modularsoft.zander.bridge.common.voting.VoteContext; From e95fb09ddd4fbeb493d5ba22e1e95fd1823426d0 Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Thu, 9 Oct 2025 01:35:33 +1100 Subject: [PATCH 12/16] Restore Velocity favicon when customising MOTD --- .../velocity/events/UserOnProxyPing.java | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java index 26690ef..b1571c2 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java @@ -5,6 +5,7 @@ import com.velocitypowered.api.event.Subscribe; import com.velocitypowered.api.event.proxy.ProxyPingEvent; import com.velocitypowered.api.proxy.server.ServerPing.Builder; +import com.velocitypowered.api.proxy.server.ServerPing.Favicon; import dev.dejvokep.boostedyaml.route.Route; import io.github.ModularEnigma.Request; import io.github.ModularEnigma.Response; @@ -12,6 +13,9 @@ import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import org.modularsoft.zander.velocity.ZanderVelocityMain; +import java.lang.reflect.Method; +import java.util.Optional; + public class UserOnProxyPing { private final ZanderVelocityMain plugin; @@ -27,7 +31,11 @@ public void onProxyPingEvent(ProxyPingEvent event) { // original favicon (Velocity drops it if the builder does not explicitly set it). var originalPing = event.getPing(); Builder pingBuilder = originalPing.asBuilder(); - originalPing.getFavicon().ifPresent(pingBuilder::favicon); + Optional favicon = originalPing.getFavicon(); + if (favicon.isEmpty()) { + favicon = resolveProxyFavicon(); + } + favicon.ifPresent(pingBuilder::favicon); try { // Fetch configuration values @@ -71,4 +79,36 @@ public void onProxyPingEvent(ProxyPingEvent event) { // Set the modified ServerPing back to the event event.setPing(pingBuilder.build()); } -} \ No newline at end of file + + private Optional resolveProxyFavicon() { + try { + Object proxy = ZanderVelocityMain.getProxy(); + if (proxy == null) { + return Optional.empty(); + } + + Method getConfiguration = proxy.getClass().getMethod("getConfiguration"); + Object configuration = getConfiguration.invoke(proxy); + if (configuration == null) { + return Optional.empty(); + } + + try { + Method getFavicon = configuration.getClass().getMethod("getFavicon"); + Object faviconResult = getFavicon.invoke(configuration); + if (faviconResult instanceof Optional optional) { + Object value = optional.orElse(null); + if (value instanceof Favicon resolved) { + return Optional.of(resolved); + } + } else if (faviconResult instanceof Favicon resolved) { + return Optional.of(resolved); + } + } catch (NoSuchMethodException ignored) { + } + } catch (ReflectiveOperationException ignored) { + } + + return Optional.empty(); + } +} From e5a74457f44155f3dd093e208adf0b634baeb470 Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Thu, 9 Oct 2025 03:07:49 +1100 Subject: [PATCH 13/16] Fix Velocity favicon import --- .../org/modularsoft/zander/velocity/events/UserOnProxyPing.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java index b1571c2..2df81d7 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java @@ -5,7 +5,7 @@ import com.velocitypowered.api.event.Subscribe; import com.velocitypowered.api.event.proxy.ProxyPingEvent; import com.velocitypowered.api.proxy.server.ServerPing.Builder; -import com.velocitypowered.api.proxy.server.ServerPing.Favicon; +import com.velocitypowered.api.util.Favicon; import dev.dejvokep.boostedyaml.route.Route; import io.github.ModularEnigma.Request; import io.github.ModularEnigma.Response; From 0faea471bedbe1997ac3575a3cd9444e7889bae8 Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Fri, 10 Oct 2025 07:03:20 +1100 Subject: [PATCH 14/16] Tidy Velocity logging noise --- .../modularsoft/zander/velocity/events/UserOnProxyPing.java | 6 +++++- .../org/modularsoft/zander/velocity/events/UserOnVote.java | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java index 2df81d7..5fe8bb7 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java @@ -12,12 +12,15 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import org.modularsoft.zander.velocity.ZanderVelocityMain; +import org.slf4j.Logger; import java.lang.reflect.Method; import java.util.Optional; public class UserOnProxyPing { + private static final Logger logger = ZanderVelocityMain.getLogger(); + private final ZanderVelocityMain plugin; public UserOnProxyPing(ZanderVelocityMain plugin) { @@ -64,7 +67,8 @@ public void onProxyPingEvent(ProxyPingEvent event) { pingBuilder.description(serverPingDescription); } catch (Exception e) { - System.out.print(e); + logger.warn("Unable to fetch MOTD from bridge API; using configured fallback. {}: {}", + e.getClass().getSimpleName(), e.getMessage()); // Fallback MOTD in case of an exception String motdTopLine = ZanderVelocityMain.getConfig().getString(Route.from("announcementMOTDTopLine")); diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnVote.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnVote.java index dec1e08..403fbf9 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnVote.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnVote.java @@ -20,7 +20,7 @@ public void onVote(VotifierEvent event) { String timeStamp = event.getVote().getTimeStamp(); // Handle the vote event - logger.info("[BRIDGE] " + username + " voted on " + serviceName + " from " + address + " at " + timeStamp); + logger.debug("[BRIDGE] {} voted on {} from {} at {}", username, serviceName, address, timeStamp); String BaseAPIURL = ZanderVelocityMain.getConfig().getString(Route.from("BaseAPIURL")); String APIKey = ZanderVelocityMain.getConfig().getString(Route.from("APIKey")); @@ -42,11 +42,11 @@ public void onVote(VotifierEvent event) { .build(); Response voteProcessRes = voteProcessReq.execute(); - logger.info("Response (" + voteProcessRes.getStatusCode() + "): " + voteProcessRes.getBody()); + logger.debug("Vote API response ({}): {}", voteProcessRes.getStatusCode(), voteProcessRes.getBody()); } catch (Exception e) { logger.error("Error in submitting vote: " + e.getMessage()); } - logger.info("Bridge routines are handled by zander-bridge when running on the proxy; skipping legacy bridge dispatch."); + logger.debug("Skipping legacy bridge dispatch; zander-bridge handles proxy routines."); } } From 241ef051b989eef1cb99da50d8e76d5422cb224f Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Sun, 12 Oct 2025 14:34:15 +1100 Subject: [PATCH 15/16] Queue bridge commands until target players are online --- .../zander/bridge/common/BridgePlatform.java | 8 + .../zander/bridge/common/BridgeService.java | 167 +++++++++++++++++- .../bridge/paper/PaperBridgePlatform.java | 115 +++++++++++- .../velocity/VelocityBridgePlatform.java | 95 ++++++++++ 4 files changed, 379 insertions(+), 6 deletions(-) diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgePlatform.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgePlatform.java index 200bac5..bfd981f 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgePlatform.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgePlatform.java @@ -18,6 +18,14 @@ public interface BridgePlatform { Map collectServerStatus(); + default boolean isPlayerOnline(String playerUuid, String playerName) { + return true; + } + + default void runWhenPlayerOnline(String playerUuid, String playerName, Runnable action) { + action.run(); + } + interface ScheduledTask { void cancel(); } diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java index 2c2637f..0026329 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java @@ -1,6 +1,8 @@ package org.modularsoft.zander.bridge.common; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; import java.io.IOException; import java.time.Instant; @@ -93,7 +95,9 @@ private void pollAndExecuteTasks() { } private void executeTask(BridgeTask task) { - platform.runSync(() -> { + PlayerTarget playerTarget = PlayerTarget.fromMetadata(task.metadata()); + + Runnable executeCommand = () -> platform.runSync(() -> { try { platform.executeConsoleCommand(task.command()); reportTask(task, TaskStatus.COMPLETED, "Executed command successfully", task.metadata()); @@ -102,6 +106,28 @@ private void executeTask(BridgeTask task) { reportTask(task, TaskStatus.FAILED, "Command execution failed: " + exception.getMessage(), task.metadata()); } }); + + if (!playerTarget.requiresOnlinePlayer()) { + executeCommand.run(); + return; + } + + if (!playerTarget.hasIdentifier()) { + platform.logger().warning("Bridge task " + task.id() + " requires an online player but no identifier was provided; executing immediately."); + executeCommand.run(); + return; + } + + if (platform.isPlayerOnline(playerTarget.playerUuid(), playerTarget.playerName())) { + executeCommand.run(); + return; + } + + platform.logger().info(() -> "Queuing bridge task " + task.id() + " until player " + playerTarget.describe() + " is online"); + platform.runWhenPlayerOnline(playerTarget.playerUuid(), playerTarget.playerName(), () -> { + platform.logger().info(() -> "Executing queued bridge task " + task.id() + " for player " + playerTarget.describe()); + executeCommand.run(); + }); } private void reportTask(BridgeTask task, TaskStatus status, String result, JsonElement metadata) { @@ -125,4 +151,143 @@ private void reportServerStatus() { exception); } } + + private record PlayerTarget(boolean requiresOnlinePlayer, + String playerUuid, + String playerName) { + + private static PlayerTarget fromMetadata(JsonElement metadata) { + if (metadata == null || metadata.isJsonNull() || !metadata.isJsonObject()) { + return new PlayerTarget(false, null, null); + } + + JsonObject object = metadata.getAsJsonObject(); + boolean requiresOnline = getBoolean(object, "requiresOnlinePlayer") + || getBoolean(object, "requiresPlayerOnline") + || getBoolean(object, "queueUntilOnline") + || getBoolean(object, "playerCommand"); + + String playerUuid = firstString(object, + "playerUuid", + "playerUUID", + "playerId", + "uuid", + "userUuid", + "targetUuid"); + String playerName = firstString(object, + "playerName", + "username", + "userName", + "name", + "targetPlayer", + "targetName"); + + if (playerUuid == null || playerName == null) { + JsonObject playerObject = getObject(object, "player", "user", "target"); + if (playerObject != null) { + if (playerUuid == null) { + playerUuid = firstString(playerObject, "uuid", "uniqueId", "id"); + } + if (playerName == null) { + playerName = firstString(playerObject, "name", "username", "playerName"); + } + } + } + + if (!requiresOnline) { + requiresOnline = playerUuid != null || playerName != null; + } + + return new PlayerTarget(requiresOnline, normalise(playerUuid), normalise(playerName)); + } + + private static JsonObject getObject(JsonObject parent, String... keys) { + for (String key : keys) { + if (!parent.has(key)) { + continue; + } + JsonElement element = parent.get(key); + if (element != null && element.isJsonObject()) { + return element.getAsJsonObject(); + } + } + return null; + } + + private static boolean getBoolean(JsonObject object, String key) { + if (!object.has(key)) { + return false; + } + JsonElement element = object.get(key); + if (element == null || element.isJsonNull()) { + return false; + } + if (element.isJsonPrimitive()) { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isBoolean()) { + return primitive.getAsBoolean(); + } + if (primitive.isString()) { + return Boolean.parseBoolean(primitive.getAsString()); + } + if (primitive.isNumber()) { + return primitive.getAsInt() != 0; + } + } + return false; + } + + private static String firstString(JsonObject object, String... keys) { + for (String key : keys) { + if (!object.has(key)) { + continue; + } + JsonElement element = object.get(key); + if (element == null || element.isJsonNull()) { + continue; + } + if (element.isJsonPrimitive()) { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isString()) { + String value = primitive.getAsString().trim(); + if (!value.isEmpty()) { + return value; + } + } else if (primitive.isNumber()) { + String value = primitive.getAsNumber().toString().trim(); + if (!value.isEmpty()) { + return value; + } + } + } + } + return null; + } + + private static String normalise(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + boolean hasIdentifier() { + return (playerUuid != null && !playerUuid.isBlank()) + || (playerName != null && !playerName.isBlank()); + } + + String describe() { + if (playerUuid != null && !playerUuid.isBlank()) { + if (playerName != null && !playerName.isBlank()) { + return playerName + " (" + playerUuid + ")"; + } + return "UUID " + playerUuid; + } + if (playerName != null && !playerName.isBlank()) { + return "name '" + playerName + "'"; + } + return ""; + } + } } diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlatform.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlatform.java index 2008864..5ccaa1c 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlatform.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlatform.java @@ -1,24 +1,37 @@ package org.modularsoft.zander.bridge.paper; import org.bukkit.Bukkit; -import org.bukkit.Server; import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.plugin.PluginManager; import org.bukkit.plugin.java.JavaPlugin; import org.modularsoft.zander.bridge.common.BridgePlatform; +import java.util.Locale; import java.util.HashMap; import java.util.Map; +import java.util.Queue; +import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.logging.Logger; +import java.util.logging.Level; public final class PaperBridgePlatform implements BridgePlatform { private final JavaPlugin plugin; private final String serverId; + private final Map> pendingPlayerActions = new ConcurrentHashMap<>(); public PaperBridgePlatform(JavaPlugin plugin, String serverId) { this.plugin = plugin; this.serverId = serverId; + PluginManager pluginManager = plugin.getServer().getPluginManager(); + pluginManager.registerEvents(new PlayerConnectionListener(), plugin); } @Override @@ -61,15 +74,43 @@ public void executeConsoleCommand(String command) throws Exception { } } + @Override + public boolean isPlayerOnline(String playerUuid, String playerName) { + return resolvePlayer(playerUuid, playerName) != null; + } + + @Override + public void runWhenPlayerOnline(String playerUuid, String playerName, Runnable action) { + if (isPlayerOnline(playerUuid, playerName)) { + action.run(); + return; + } + + String key = playerKey(playerUuid, playerName); + if (key == null) { + action.run(); + return; + } + + pendingPlayerActions.compute(key, (ignored, queue) -> { + Queue pending = queue != null ? queue : new ConcurrentLinkedQueue<>(); + pending.add(action); + return pending; + }); + + if (isPlayerOnline(playerUuid, playerName)) { + flushPendingActions(key); + } + } + @Override public Map collectServerStatus() { - Server server = plugin.getServer(); Map payload = new HashMap<>(); payload.put("platform", "paper"); payload.put("serverId", serverId); - payload.put("playerCount", server.getOnlinePlayers().size()); - payload.put("maxPlayers", server.getMaxPlayers()); - payload.put("onlinePlayers", server.getOnlinePlayers().stream().map(player -> { + payload.put("playerCount", plugin.getServer().getOnlinePlayers().size()); + payload.put("maxPlayers", plugin.getServer().getMaxPlayers()); + payload.put("onlinePlayers", plugin.getServer().getOnlinePlayers().stream().map(player -> { Map info = new HashMap<>(); info.put("name", player.getName()); info.put("uuid", player.getUniqueId().toString()); @@ -79,4 +120,68 @@ public Map collectServerStatus() { return payload; } + private Player resolvePlayer(String playerUuid, String playerName) { + Player player = null; + if (playerUuid != null && !playerUuid.isBlank()) { + try { + UUID uuid = UUID.fromString(playerUuid); + player = Bukkit.getPlayer(uuid); + } catch (IllegalArgumentException ignored) { + logger().log(Level.FINE, "Invalid player UUID provided for lookup: {0}", playerUuid); + } + } + + if ((player == null || !player.isOnline()) && playerName != null && !playerName.isBlank()) { + player = Bukkit.getPlayerExact(playerName); + if (player == null) { + player = Bukkit.getPlayer(playerName); + } + } + + if (player != null && player.isOnline()) { + return player; + } + return null; + } + + private void flushPendingActions(String key) { + if (key == null) { + return; + } + Queue queue = pendingPlayerActions.remove(key); + if (queue == null) { + return; + } + Runnable action; + while ((action = queue.poll()) != null) { + try { + action.run(); + } catch (Exception exception) { + logger().log(Level.SEVERE, "Failed to execute queued action for key " + key, exception); + } + } + } + + private void handlePlayerOnline(Player player) { + flushPendingActions(playerKey(player.getUniqueId().toString(), null)); + flushPendingActions(playerKey(null, player.getName())); + } + + private String playerKey(String playerUuid, String playerName) { + if (playerUuid != null && !playerUuid.isBlank()) { + return "uuid:" + playerUuid.trim().toLowerCase(Locale.ROOT); + } + if (playerName != null && !playerName.isBlank()) { + return "name:" + playerName.trim().toLowerCase(Locale.ROOT); + } + return null; + } + + private final class PlayerConnectionListener implements Listener { + + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + handlePlayerOnline(event.getPlayer()); + } + } } diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java index 58a445b..7557757 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java @@ -1,13 +1,21 @@ package org.modularsoft.zander.bridge.velocity; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.connection.PostLoginEvent; import com.velocitypowered.api.proxy.ProxyServer; import org.modularsoft.zander.bridge.common.BridgePlatform; +import java.util.Locale; import java.util.HashMap; import java.util.Map; +import java.util.Queue; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.logging.Logger; +import java.util.logging.Level; public final class VelocityBridgePlatform implements BridgePlatform { @@ -15,12 +23,14 @@ public final class VelocityBridgePlatform implements BridgePlatform { private final ProxyServer proxyServer; private final Logger logger; private final String serverId; + private final Map> pendingPlayerActions = new ConcurrentHashMap<>(); public VelocityBridgePlatform(Object pluginInstance, ProxyServer proxyServer, Logger logger, String serverId) { this.pluginInstance = pluginInstance; this.proxyServer = proxyServer; this.logger = logger; this.serverId = serverId; + proxyServer.getEventManager().register(pluginInstance, new PlayerConnectionListener()); } @Override @@ -62,6 +72,50 @@ public void executeConsoleCommand(String command) throws Exception { } } + @Override + public boolean isPlayerOnline(String playerUuid, String playerName) { + if (playerUuid != null && !playerUuid.isBlank()) { + try { + UUID uuid = UUID.fromString(playerUuid); + if (proxyServer.getPlayer(uuid).isPresent()) { + return true; + } + } catch (IllegalArgumentException ignored) { + logger.log(Level.FINE, "Invalid player UUID provided for lookup: {0}", playerUuid); + } + } + + if (playerName != null && !playerName.isBlank()) { + return proxyServer.getPlayer(playerName).isPresent(); + } + + return false; + } + + @Override + public void runWhenPlayerOnline(String playerUuid, String playerName, Runnable action) { + if (isPlayerOnline(playerUuid, playerName)) { + action.run(); + return; + } + + String key = playerKey(playerUuid, playerName); + if (key == null) { + action.run(); + return; + } + + pendingPlayerActions.compute(key, (ignored, queue) -> { + Queue pending = queue != null ? queue : new ConcurrentLinkedQueue<>(); + pending.add(action); + return pending; + }); + + if (isPlayerOnline(playerUuid, playerName)) { + flushPendingActions(key); + } + } + @Override public Map collectServerStatus() { Map payload = new HashMap<>(); @@ -78,6 +132,39 @@ public Map collectServerStatus() { return payload; } + private void flushPendingActions(String key) { + if (key == null) { + return; + } + Queue queue = pendingPlayerActions.remove(key); + if (queue == null) { + return; + } + Runnable action; + while ((action = queue.poll()) != null) { + try { + action.run(); + } catch (Exception exception) { + logger.log(Level.SEVERE, "Failed to execute queued action for key " + key, exception); + } + } + } + + private void handlePlayerOnline(UUID uuid, String username) { + flushPendingActions(playerKey(uuid != null ? uuid.toString() : null, null)); + flushPendingActions(playerKey(null, username)); + } + + private String playerKey(String playerUuid, String playerName) { + if (playerUuid != null && !playerUuid.isBlank()) { + return "uuid:" + playerUuid.trim().toLowerCase(Locale.ROOT); + } + if (playerName != null && !playerName.isBlank()) { + return "name:" + playerName.trim().toLowerCase(Locale.ROOT); + } + return null; + } + private static final class ScheduledTaskWrapper implements BridgePlatform.ScheduledTask { private final com.velocitypowered.api.scheduler.ScheduledTask scheduledTask; @@ -90,4 +177,12 @@ public void cancel() { scheduledTask.cancel(); } } + + private final class PlayerConnectionListener { + + @Subscribe + public void onPostLogin(PostLoginEvent event) { + handlePlayerOnline(event.getPlayer().getUniqueId(), event.getPlayer().getUsername()); + } + } } From ee30807e6a8ff73a735cd2b656c039c3e5ceb238 Mon Sep 17 00:00:00 2001 From: Ben Robson Date: Tue, 21 Oct 2025 11:01:27 +1100 Subject: [PATCH 16/16] Improve bridge routine metadata and preserve Velocity favicon --- .../zander/bridge/common/BridgeApiClient.java | 145 ++++++++++++++- .../zander/bridge/common/BridgeService.java | 170 +++++++++++++++++- .../bridge/common/voting/VoteContext.java | 1 + .../voting/VotingRoutineDispatcher.java | 46 ++++- .../voting/VotifierPaperIntegration.java | 38 +++- .../voting/VotifierVelocityIntegration.java | 14 +- .../velocity/events/UserOnProxyPing.java | 150 +++++++++++++++- 7 files changed, 541 insertions(+), 23 deletions(-) diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeApiClient.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeApiClient.java index 1f72142..8578ac2 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeApiClient.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeApiClient.java @@ -9,14 +9,18 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; public final class BridgeApiClient { private static final String BASE_ENDPOINT = "/api/bridge"; + private static final long ROUTINE_CACHE_TTL_MILLIS = TimeUnit.MINUTES.toMillis(2); private final Gson gson = new GsonBuilder().create(); private final String baseApiUrl; private final String apiKey; + private final Map routineCache = new ConcurrentHashMap<>(); public BridgeApiClient(String baseApiUrl, String apiKey) { this.baseApiUrl = Objects.requireNonNull(baseApiUrl, "baseApiUrl"); @@ -107,16 +111,71 @@ public void queueRoutine(String routineSlug, throw new IllegalArgumentException("routineSlug must not be blank"); } + if (metadata == null || metadata.isEmpty()) { + JsonObject payload = new JsonObject(); + payload.addProperty("routineSlug", routineSlug); + if (targetSlug != null && !targetSlug.isBlank()) { + payload.addProperty("slug", targetSlug); + } + if (priority != null) { + payload.addProperty("priority", priority); + } + + Request request = Request.builder() + .setURL(baseApiUrl + BASE_ENDPOINT + "/processor/command/add") + .setMethod(Request.Method.POST) + .addHeader("x-access-token", apiKey) + .setRequestBody(gson.toJson(payload)) + .build(); + + Response response = execute(request); + if (response.getStatusCode() >= 400) { + throw new IOException("Failed to queue routine '" + routineSlug + "': " + response.getStatusCode() + " - " + response.getBody()); + } + return; + } + + List steps = getRoutineSteps(routineSlug); + if (steps.isEmpty()) { + throw new IOException("Routine '" + routineSlug + "' does not have any steps configured"); + } + JsonObject payload = new JsonObject(); - payload.addProperty("routineSlug", routineSlug); - if (targetSlug != null && !targetSlug.isBlank()) { - payload.addProperty("slug", targetSlug); + JsonArray tasks = new JsonArray(); + JsonObject baseMetadata = gson.toJsonTree(metadata).getAsJsonObject(); + + for (RoutineStep step : steps) { + JsonObject task = new JsonObject(); + String resolvedSlug = resolveStepSlug(step.slug(), targetSlug); + if (resolvedSlug == null || resolvedSlug.isBlank()) { + throw new IOException("Routine '" + routineSlug + "' contains a step without a target slug"); + } + + if (step.command() == null || step.command().isBlank()) { + throw new IOException("Routine '" + routineSlug + "' contains a step without a command"); + } + + task.addProperty("slug", resolvedSlug); + task.addProperty("command", step.command()); + task.addProperty("routineSlug", routineSlug); + if (priority != null) { + task.addProperty("priority", priority); + } + + JsonObject mergedMetadata = mergeMetadata(baseMetadata, step.metadata()); + if (mergedMetadata != null && mergedMetadata.size() > 0) { + task.add("metadata", mergedMetadata); + } + + tasks.add(task); } + + payload.add("tasks", tasks); if (priority != null) { payload.addProperty("priority", priority); } - if (metadata != null && !metadata.isEmpty()) { - payload.add("metadata", gson.toJsonTree(metadata)); + if (targetSlug != null && !targetSlug.isBlank()) { + payload.addProperty("slug", targetSlug); } Request request = Request.builder() @@ -191,4 +250,80 @@ private String getAsString(JsonObject obj, String member) { } return obj.get(member).getAsString(); } + + private String resolveStepSlug(String stepSlug, String defaultSlug) { + if (stepSlug != null && !stepSlug.isBlank()) { + return stepSlug; + } + return defaultSlug; + } + + private JsonObject mergeMetadata(JsonObject baseMetadata, JsonElement stepMetadata) { + JsonObject merged = baseMetadata != null ? baseMetadata.deepCopy() : new JsonObject(); + if (stepMetadata != null && !stepMetadata.isJsonNull() && stepMetadata.isJsonObject()) { + JsonObject stepObject = stepMetadata.getAsJsonObject(); + for (Map.Entry entry : stepObject.entrySet()) { + JsonElement value = entry.getValue(); + merged.add(entry.getKey(), value != null ? value.deepCopy() : JsonNull.INSTANCE); + } + } + return merged.size() == 0 ? null : merged; + } + + private List getRoutineSteps(String routineSlug) throws IOException { + long now = System.currentTimeMillis(); + RoutineCacheEntry cached = routineCache.get(routineSlug); + if (cached != null && cached.expiresAt() >= now) { + return cached.steps(); + } + + List steps = fetchRoutineSteps(routineSlug); + routineCache.put(routineSlug, new RoutineCacheEntry(steps, now + ROUTINE_CACHE_TTL_MILLIS)); + return steps; + } + + private List fetchRoutineSteps(String routineSlug) throws IOException { + String url = baseApiUrl + BASE_ENDPOINT + "/routine/get?routineSlug=" + + URLEncoder.encode(routineSlug, StandardCharsets.UTF_8); + + Request request = Request.builder() + .setURL(url) + .setMethod(Request.Method.GET) + .addHeader("x-access-token", apiKey) + .build(); + + Response response = execute(request); + if (response.getStatusCode() >= 400) { + throw new IOException("Failed to load routine '" + routineSlug + "': " + response.getStatusCode() + " - " + response.getBody()); + } + + JsonObject root = JsonParser.parseString(response.getBody()).getAsJsonObject(); + JsonArray data = root.getAsJsonArray("data"); + if (data == null || data.size() == 0) { + throw new IOException("Routine '" + routineSlug + "' was not found"); + } + + JsonObject routine = data.get(0).getAsJsonObject(); + JsonArray stepsJson = routine.getAsJsonArray("steps"); + if (stepsJson == null || stepsJson.size() == 0) { + throw new IOException("Routine '" + routineSlug + "' does not have any steps configured"); + } + + List steps = new ArrayList<>(); + for (JsonElement element : stepsJson) { + JsonObject obj = element.getAsJsonObject(); + String slug = getAsString(obj, "slug"); + String command = getAsString(obj, "command"); + JsonElement metadata = obj.get("metadata"); + steps.add(new RoutineStep(slug, command, metadata != null ? metadata.deepCopy() : JsonNull.INSTANCE)); + } + + return Collections.unmodifiableList(steps); + } + + private record RoutineStep(String slug, String command, JsonElement metadata) { + } + + private record RoutineCacheEntry(List steps, long expiresAt) { + } } diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java index 0026329..ab2f866 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java @@ -1,5 +1,6 @@ package org.modularsoft.zander.bridge.common; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; @@ -7,6 +8,9 @@ import java.io.IOException; import java.time.Instant; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.LinkedHashMap; import java.util.concurrent.TimeUnit; import java.util.logging.Level; @@ -95,15 +99,17 @@ private void pollAndExecuteTasks() { } private void executeTask(BridgeTask task) { - PlayerTarget playerTarget = PlayerTarget.fromMetadata(task.metadata()); + JsonElement metadata = task.metadata(); + String resolvedCommand = resolveCommandPlaceholders(task.command(), metadata); + PlayerTarget playerTarget = PlayerTarget.fromMetadata(metadata); Runnable executeCommand = () -> platform.runSync(() -> { try { - platform.executeConsoleCommand(task.command()); - reportTask(task, TaskStatus.COMPLETED, "Executed command successfully", task.metadata()); + platform.executeConsoleCommand(resolvedCommand); + reportTask(task, TaskStatus.COMPLETED, "Executed command successfully", metadata); } catch (Exception exception) { platform.logger().severe("Failed to execute command from bridge task " + task.id() + ": " + exception.getMessage()); - reportTask(task, TaskStatus.FAILED, "Command execution failed: " + exception.getMessage(), task.metadata()); + reportTask(task, TaskStatus.FAILED, "Command execution failed: " + exception.getMessage(), metadata); } }); @@ -180,10 +186,19 @@ private static PlayerTarget fromMetadata(JsonElement metadata) { "userName", "name", "targetPlayer", - "targetName"); + "targetName", + "player", + "player_display_name", + "playerDisplayName"); if (playerUuid == null || playerName == null) { - JsonObject playerObject = getObject(object, "player", "user", "target"); + JsonObject playerObject = getObject(object, + "player", + "playerInfo", + "playerDetails", + "playerContext", + "user", + "target"); if (playerObject != null) { if (playerUuid == null) { playerUuid = firstString(playerObject, "uuid", "uniqueId", "id"); @@ -290,4 +305,147 @@ String describe() { return ""; } } + + private String resolveCommandPlaceholders(String command, JsonElement metadata) { + if (command == null || command.isBlank()) { + return command; + } + if (metadata == null || metadata.isJsonNull()) { + return command; + } + + Map placeholders = new LinkedHashMap<>(); + collectPlaceholderValues("", metadata, placeholders); + if (placeholders.isEmpty()) { + return command; + } + + String resolved = command; + for (Map.Entry entry : placeholders.entrySet()) { + String placeholder = "{{" + entry.getKey() + "}}"; + resolved = resolved.replace(placeholder, entry.getValue()); + } + return resolved; + } + + private void collectPlaceholderValues(String prefix, JsonElement element, Map output) { + if (element == null || element.isJsonNull()) { + return; + } + + if (element.isJsonObject()) { + for (Map.Entry entry : element.getAsJsonObject().entrySet()) { + String childKey = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey(); + collectPlaceholderValues(childKey, entry.getValue(), output); + } + return; + } + + if (element.isJsonArray()) { + JsonArray array = element.getAsJsonArray(); + for (int i = 0; i < array.size(); i++) { + String childKey = prefix + "[" + i + "]"; + collectPlaceholderValues(childKey, array.get(i), output); + } + return; + } + + if (!element.isJsonPrimitive() || prefix == null || prefix.isBlank()) { + return; + } + + JsonPrimitive primitive = element.getAsJsonPrimitive(); + String value; + if (primitive.isString()) { + value = primitive.getAsString(); + } else if (primitive.isBoolean()) { + value = Boolean.toString(primitive.getAsBoolean()); + } else if (primitive.isNumber()) { + value = primitive.getAsNumber().toString(); + } else { + return; + } + + addPlaceholderVariant(prefix, value, output); + } + + private void addPlaceholderVariant(String key, String value, Map output) { + if (key == null || key.isBlank() || value == null) { + return; + } + + String trimmedValue = value.trim(); + if (trimmedValue.isEmpty()) { + return; + } + + output.put(key, trimmedValue); + + String keyWithoutIndexes = removeArrayNotation(key); + int dotIndex = keyWithoutIndexes.lastIndexOf('.'); + String simpleKey = dotIndex >= 0 ? keyWithoutIndexes.substring(dotIndex + 1) : keyWithoutIndexes; + if (!simpleKey.isBlank()) { + output.putIfAbsent(simpleKey, trimmedValue); + output.putIfAbsent(simpleKey.toLowerCase(Locale.ROOT), trimmedValue); + String snake = toSnakeCase(simpleKey); + if (!snake.equals(simpleKey.toLowerCase(Locale.ROOT))) { + output.putIfAbsent(snake, trimmedValue); + } + } + + if (dotIndex >= 0) { + String prefixWithoutIndexes = keyWithoutIndexes.substring(0, dotIndex); + if (!prefixWithoutIndexes.isBlank() && !simpleKey.isBlank()) { + String camel = prefixWithoutIndexes + Character.toUpperCase(simpleKey.charAt(0)) + simpleKey.substring(1); + output.putIfAbsent(camel, trimmedValue); + if ("name".equalsIgnoreCase(simpleKey)) { + output.putIfAbsent(prefixWithoutIndexes, trimmedValue); + } + } + } + } + + private String removeArrayNotation(String value) { + if (value == null || value.isBlank()) { + return value; + } + + StringBuilder builder = new StringBuilder(); + boolean skipping = false; + for (char c : value.toCharArray()) { + if (c == '[') { + skipping = true; + continue; + } + if (skipping) { + if (c == ']') { + skipping = false; + } + continue; + } + builder.append(c); + } + return builder.toString(); + } + + private String toSnakeCase(String value) { + if (value == null || value.isBlank()) { + return value; + } + + StringBuilder builder = new StringBuilder(); + char[] chars = value.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + if (Character.isUpperCase(c)) { + if (i > 0 && Character.isLetterOrDigit(chars[i - 1])) { + builder.append('_'); + } + builder.append(Character.toLowerCase(c)); + } else { + builder.append(Character.toLowerCase(c)); + } + } + return builder.toString(); + } } diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VoteContext.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VoteContext.java index 0deed84..b2ba81a 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VoteContext.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VoteContext.java @@ -1,6 +1,7 @@ package org.modularsoft.zander.bridge.common.voting; public record VoteContext(String username, + String uuid, String serviceName, String address, String timestamp) { diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VotingRoutineDispatcher.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VotingRoutineDispatcher.java index 19a7721..6a031e7 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VotingRoutineDispatcher.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VotingRoutineDispatcher.java @@ -43,7 +43,43 @@ public void handleVote(VoteContext context) { metadata.put("bridgeEvent", "vote"); metadata.put("platform", platform); metadata.put("serverSlug", config.processor().serverSlug()); - putIfNotBlank(metadata, "username", context.username()); + String username = trimToNull(context.username()); + String uuid = trimToNull(context.uuid()); + + if (username != null) { + metadata.put("username", username); + metadata.put("player", username); + metadata.put("playerName", username); + metadata.put("playerDisplayName", username); + metadata.put("player_display_name", username); + } + + if (uuid != null) { + metadata.put("playerUuid", uuid); + metadata.put("uuid", uuid); + metadata.put("playerId", uuid); + } + + Map playerDetails = new HashMap<>(); + if (username != null) { + playerDetails.put("name", username); + playerDetails.put("username", username); + playerDetails.put("displayName", username); + } + if (uuid != null) { + playerDetails.put("uuid", uuid); + } + if (!playerDetails.isEmpty()) { + metadata.put("playerInfo", playerDetails); + metadata.put("playerDetails", playerDetails); + metadata.put("playerContext", playerDetails); + } + + metadata.put("requiresPlayerOnline", Boolean.TRUE); + metadata.put("requiresOnlinePlayer", Boolean.TRUE); + metadata.put("playerCommand", Boolean.TRUE); + metadata.put("queueUntilOnline", Boolean.TRUE); + putIfNotBlank(metadata, "serviceName", context.serviceName()); putIfNotBlank(metadata, "address", context.address()); putIfNotBlank(metadata, "timestamp", context.timestamp()); @@ -60,6 +96,14 @@ public void handleVote(VoteContext context) { } } + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + private String resolveRoutine(BridgeConfig.VotingIntegration voting, String serviceName) { if (serviceName != null) { String key = serviceName.trim().toLowerCase(Locale.ROOT); diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java index a2b7f70..6c6e27d 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java @@ -1,6 +1,7 @@ package org.modularsoft.zander.bridge.paper.voting; import org.bukkit.Bukkit; +import org.bukkit.entity.Player; import org.bukkit.event.Event; import org.bukkit.event.EventPriority; import org.bukkit.event.HandlerList; @@ -75,8 +76,11 @@ private void handleVoteEvent(Listener listener, Event event) { try { Object vote = eventGetVote.invoke(event); + String username = invokeString(vote, voteGetUsername); + String uuid = resolvePlayerUuid(username); VoteContext context = new VoteContext( - invokeString(vote, voteGetUsername), + username, + uuid, invokeString(vote, voteGetServiceName), invokeString(vote, voteGetAddress), invokeString(vote, voteGetTimestamp) @@ -122,4 +126,36 @@ private boolean isVotifierPresent() { return Bukkit.getPluginManager().getPlugin("Votifier") != null || Bukkit.getPluginManager().getPlugin("NuVotifier") != null; } + + private String resolvePlayerUuid(String username) { + if (username == null || username.isBlank()) { + return null; + } + + try { + Player online = Bukkit.getPlayerExact(username); + if (online != null) { + return online.getUniqueId().toString(); + } + } catch (Exception ignored) { + } + + try { + var offlineCached = Bukkit.getOfflinePlayerIfCached(username); + if (offlineCached != null && offlineCached.getUniqueId() != null) { + return offlineCached.getUniqueId().toString(); + } + } catch (NoSuchMethodError ignored) { + } + + try { + var offline = Bukkit.getOfflinePlayer(username); + if (offline != null && offline.hasPlayedBefore() && offline.getUniqueId() != null) { + return offline.getUniqueId().toString(); + } + } catch (Exception ignored) { + } + + return null; + } } diff --git a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/voting/VotifierVelocityIntegration.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/voting/VotifierVelocityIntegration.java index 7c16e92..a206e03 100644 --- a/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/voting/VotifierVelocityIntegration.java +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/voting/VotifierVelocityIntegration.java @@ -77,8 +77,11 @@ private void handleVoteEvent(Object event) { try { Object vote = eventGetVote.invoke(event); + String username = invokeString(vote, voteGetUsername); + String uuid = resolvePlayerUuid(username); VoteContext context = new VoteContext( - invokeString(vote, voteGetUsername), + username, + uuid, invokeString(vote, voteGetServiceName), invokeString(vote, voteGetAddress), invokeString(vote, voteGetTimestamp) @@ -122,4 +125,13 @@ private boolean resolveReflection() { return false; } } + + private String resolvePlayerUuid(String username) { + if (username == null || username.isBlank()) { + return null; + } + return proxyServer.getPlayer(username) + .map(player -> player.getUniqueId().toString()) + .orElse(null); + } } diff --git a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java index 5fe8bb7..77396cb 100644 --- a/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java +++ b/zander-velocity/src/main/java/org/modularsoft/zander/velocity/events/UserOnProxyPing.java @@ -14,7 +14,15 @@ import org.modularsoft.zander.velocity.ZanderVelocityMain; import org.slf4j.Logger; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Base64; import java.util.Optional; public class UserOnProxyPing { @@ -22,6 +30,7 @@ public class UserOnProxyPing { private static final Logger logger = ZanderVelocityMain.getLogger(); private final ZanderVelocityMain plugin; + private volatile Optional cachedFavicon; public UserOnProxyPing(ZanderVelocityMain plugin) { this.plugin = plugin; @@ -85,6 +94,19 @@ public void onProxyPingEvent(ProxyPingEvent event) { } private Optional resolveProxyFavicon() { + Optional cached = cachedFavicon; + if (cached != null) { + return cached; + } + + Optional resolved = extractConfiguredFavicon() + .or(this::loadServerIconFromWorkingDirectory); + + cachedFavicon = resolved; + return resolved; + } + + private Optional extractConfiguredFavicon() { try { Object proxy = ZanderVelocityMain.getProxy(); if (proxy == null) { @@ -97,22 +119,132 @@ private Optional resolveProxyFavicon() { return Optional.empty(); } + Optional direct = tryConfigurationMethods(configuration, + "getFavicon", + "getServerIcon", + "getConfiguredFavicon"); + if (direct.isPresent()) { + return direct; + } + + return tryConfigurationMethods(configuration, + "getFaviconPath", + "getServerIconPath"); + } catch (ReflectiveOperationException ignored) { + } + + return Optional.empty(); + } + + private Optional tryConfigurationMethods(Object configuration, String... methodNames) { + for (String methodName : methodNames) { try { - Method getFavicon = configuration.getClass().getMethod("getFavicon"); - Object faviconResult = getFavicon.invoke(configuration); - if (faviconResult instanceof Optional optional) { - Object value = optional.orElse(null); - if (value instanceof Favicon resolved) { - return Optional.of(resolved); - } - } else if (faviconResult instanceof Favicon resolved) { - return Optional.of(resolved); + Method method = configuration.getClass().getMethod(methodName); + Object result = method.invoke(configuration); + Optional favicon = convertToFavicon(result); + if (favicon.isPresent()) { + return favicon; } } catch (NoSuchMethodException ignored) { + } catch (ReflectiveOperationException ignored) { + } + } + return Optional.empty(); + } + + private Optional convertToFavicon(Object value) { + if (value == null) { + return Optional.empty(); + } + if (value instanceof Optional optional) { + return optional.flatMap(this::convertToFavicon); + } + if (value instanceof Favicon favicon) { + return Optional.of(favicon); + } + if (value instanceof byte[] bytes) { + return createFaviconFromBytes(bytes); + } + if (value instanceof Path path) { + return loadFaviconFromPath(path); + } + if (value instanceof java.io.File file) { + return loadFaviconFromPath(file.toPath()); + } + if (value instanceof String string) { + Optional fromBase64 = createFaviconFromBase64(string); + if (fromBase64.isPresent()) { + return fromBase64; + } + try { + return loadFaviconFromPath(Paths.get(string)); + } catch (Exception ignored) { + return Optional.empty(); } + } + return Optional.empty(); + } + + private Optional createFaviconFromBase64(String value) { + if (value == null) { + return Optional.empty(); + } + String trimmed = value.trim(); + if (trimmed.isEmpty()) { + return Optional.empty(); + } + int commaIndex = trimmed.indexOf(','); + if (commaIndex >= 0) { + trimmed = trimmed.substring(commaIndex + 1); + } + try { + byte[] data = Base64.getDecoder().decode(trimmed); + return createFaviconFromBytes(data); + } catch (IllegalArgumentException ignored) { + return Optional.empty(); + } + } + + private Optional loadServerIconFromWorkingDirectory() { + return loadFaviconFromPath(Paths.get("server-icon.png")); + } + + private Optional loadFaviconFromPath(Path path) { + if (path == null) { + return Optional.empty(); + } + try { + if (!Files.exists(path)) { + return Optional.empty(); + } + byte[] data = Files.readAllBytes(path); + return createFaviconFromBytes(data); + } catch (IOException exception) { + logger.debug("Unable to read favicon from {}", path, exception); + return Optional.empty(); + } + } + + private Optional createFaviconFromBytes(byte[] data) { + if (data == null || data.length == 0) { + return Optional.empty(); + } + try { + Method bytesMethod = Favicon.class.getMethod("create", byte[].class); + return Optional.of((Favicon) bytesMethod.invoke(null, (Object) data)); } catch (ReflectiveOperationException ignored) { } + try (ByteArrayInputStream input = new ByteArrayInputStream(data)) { + BufferedImage image = ImageIO.read(input); + if (image == null) { + return Optional.empty(); + } + Method imageMethod = Favicon.class.getMethod("create", BufferedImage.class); + return Optional.of((Favicon) imageMethod.invoke(null, image)); + } catch (ReflectiveOperationException | IOException ignored) { + } + return Optional.empty(); } }