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..8578ac2
--- /dev/null
+++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeApiClient.java
@@ -0,0 +1,329 @@
+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.*;
+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");
+ 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 = execute(request);
+ 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 = execute(request);
+ 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 = execute(request);
+ 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");
+ }
+
+ 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();
+ 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 (targetSlug != null && !targetSlug.isBlank()) {
+ payload.addProperty("slug", targetSlug);
+ }
+
+ 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());
+ }
+ }
+
+ 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");
+ 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();
+ }
+
+ 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/BridgeConfig.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeConfig.java
new file mode 100644
index 0000000..1b4cd47
--- /dev/null
+++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeConfig.java
@@ -0,0 +1,144 @@
+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;
+ private final VotingIntegration voting;
+
+ public BridgeConfig(String baseApiUrl,
+ String apiKey,
+ Processor processor,
+ StatusReporter statusReporter,
+ 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() {
+ return baseApiUrl;
+ }
+
+ public String apiKey() {
+ return apiKey;
+ }
+
+ public Processor processor() {
+ return processor;
+ }
+
+ public StatusReporter statusReporter() {
+ return statusReporter;
+ }
+
+ public TebexIntegration tebex() {
+ return tebex;
+ }
+
+ public VotingIntegration voting() {
+ return voting;
+ }
+
+ 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);
+ }
+ }
+ }
+
+ 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/BridgePlatform.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgePlatform.java
new file mode 100644
index 0000000..bfd981f
--- /dev/null
+++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgePlatform.java
@@ -0,0 +1,32 @@
+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();
+
+ 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
new file mode 100644
index 0000000..ab2f866
--- /dev/null
+++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/BridgeService.java
@@ -0,0 +1,451 @@
+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;
+
+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;
+
+public final class BridgeService {
+
+ private final BridgePlatform platform;
+ private final BridgeConfig config;
+ private final BridgeApiClient apiClient;
+
+ private BridgePlatform.ScheduledTask taskPoller;
+ private BridgePlatform.ScheduledTask statusReporter;
+ private int consecutiveTaskFetchFailures;
+
+ 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 (consecutiveTaskFetchFailures > 0) {
+ platform.logger().info("Bridge API task polling recovered after "
+ + consecutiveTaskFetchFailures + " failure(s).");
+ consecutiveTaskFetchFailures = 0;
+ }
+
+ if (tasks.isEmpty()) {
+ return;
+ }
+
+ for (BridgeTask task : tasks) {
+ executeTask(task);
+ }
+ } catch (IOException exception) {
+ 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);
+ }
+ }
+
+ private void executeTask(BridgeTask task) {
+ JsonElement metadata = task.metadata();
+ String resolvedCommand = resolveCommandPlaceholders(task.command(), metadata);
+ PlayerTarget playerTarget = PlayerTarget.fromMetadata(metadata);
+
+ Runnable executeCommand = () -> platform.runSync(() -> {
+ try {
+ 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(), 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) {
+ try {
+ apiClient.reportTask(task.id(), status, result, config.processor().serverSlug(), metadata);
+ } catch (IOException exception) {
+ platform.logger().log(
+ Level.WARNING,
+ "Failed to report task " + task.id() + " to bridge API (status=" + status + ")",
+ exception);
+ }
+ }
+
+ private void reportServerStatus() {
+ try {
+ apiClient.updateServerStatus(platform.collectServerStatus(), Instant.now());
+ } catch (IOException exception) {
+ platform.logger().log(
+ Level.WARNING,
+ "Failed to update server status via bridge API",
+ 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",
+ "player",
+ "player_display_name",
+ "playerDisplayName");
+
+ if (playerUuid == null || playerName == null) {
+ JsonObject playerObject = getObject(object,
+ "player",
+ "playerInfo",
+ "playerDetails",
+ "playerContext",
+ "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 "";
+ }
+ }
+
+ 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/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/common/voting/VoteContext.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VoteContext.java
new file mode 100644
index 0000000..b2ba81a
--- /dev/null
+++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VoteContext.java
@@ -0,0 +1,8 @@
+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
new file mode 100644
index 0000000..6a031e7
--- /dev/null
+++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/common/voting/VotingRoutineDispatcher.java
@@ -0,0 +1,123 @@
+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());
+ 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());
+
+ 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 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);
+ 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/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..5ccaa1c
--- /dev/null
+++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlatform.java
@@ -0,0 +1,187 @@
+package org.modularsoft.zander.bridge.paper;
+
+import org.bukkit.Bukkit;
+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
+ 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) throws Exception {
+ ConsoleCommandSender console = Bukkit.getConsoleSender();
+ boolean success = Bukkit.dispatchCommand(console, command);
+ if (!success) {
+ throw new Exception("Command execution returned false for: " + command);
+ }
+ }
+
+ @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() {
+ Map payload = new HashMap<>();
+ payload.put("platform", "paper");
+ payload.put("serverId", serverId);
+ 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());
+ info.put("world", player.getWorld().getName());
+ return info;
+ }).toList());
+ 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/paper/PaperBridgePlugin.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlugin.java
new file mode 100644
index 0000000..03750c6
--- /dev/null
+++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/PaperBridgePlugin.java
@@ -0,0 +1,173 @@
+package org.modularsoft.zander.bridge.paper;
+
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.configuration.file.FileConfiguration;
+import org.modularsoft.zander.bridge.common.BridgeConfig;
+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;
+
+import java.time.Duration;
+import java.util.Locale;
+import java.util.Map;
+
+public final class PaperBridgePlugin extends JavaPlugin {
+
+ private BridgeService bridgeService;
+ private TebexPaperIntegration tebexIntegration;
+ private VotifierPaperIntegration votifierIntegration;
+
+ @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();
+ }
+
+ 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() + "'");
+ }
+
+ @Override
+ public void onDisable() {
+ if (tebexIntegration != null) {
+ tebexIntegration.stop();
+ tebexIntegration = null;
+ }
+ if (votifierIntegration != null) {
+ votifierIntegration.stop();
+ votifierIntegration = 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 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)));
+
+ 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"));
+ VotingIntegration votingIntegration = loadVotingConfig(configuration.getConfigurationSection("bridge.voting"));
+
+ return new BridgeConfig(baseApiUrl, apiKey, processor, statusReporter, tebexIntegration, votingIntegration);
+ }
+
+ 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 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();
+ }
+
+ 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 extends Event> eventClass = (Class extends Event>) 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/paper/voting/VotifierPaperIntegration.java b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java
new file mode 100644
index 0000000..6c6e27d
--- /dev/null
+++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/paper/voting/VotifierPaperIntegration.java
@@ -0,0 +1,161 @@
+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;
+import org.bukkit.event.Listener;
+import org.bukkit.plugin.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 extends Event> 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()) {
+ logger.warning("Votifier plugin not detected; voting integration disabled.");
+ return;
+ }
+
+ 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() {
+ if (registered) {
+ HandlerList.unregisterAll(this);
+ registered = false;
+ }
+ }
+
+ private void handleVoteEvent(Listener listener, Event event) {
+ if (eventClass == null || !eventClass.isInstance(event)) {
+ return;
+ }
+
+ try {
+ Object vote = eventGetVote.invoke(event);
+ String username = invokeString(vote, voteGetUsername);
+ String uuid = resolvePlayerUuid(username);
+ VoteContext context = new VoteContext(
+ username,
+ uuid,
+ 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 extends Event>) 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() {
+ 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/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