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 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/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 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) 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> 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..7557757 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlatform.java @@ -0,0 +1,188 @@ +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 { + + private final Object pluginInstance; + 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 + public Logger logger() { + return logger; + } + + @Override + public BridgePlatform.ScheduledTask scheduleRepeatingTask(Runnable task, long initialDelay, long repeatInterval, TimeUnit unit) { + com.velocitypowered.api.scheduler.ScheduledTask velocityTask = proxyServer.getScheduler() + .buildTask(pluginInstance, task) + .delay(initialDelay, unit) + .repeat(repeatInterval, unit) + .schedule(); + return new ScheduledTaskWrapper(velocityTask); + } + + @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) throws Exception { + CompletableFuture future = proxyServer.getCommandManager() + .executeAsync(proxyServer.getConsoleCommandSource(), command); + try { + Boolean result = future.get(); + if (Boolean.FALSE.equals(result)) { + throw new Exception("Command execution returned false for: " + command); + } + } catch (Exception exception) { + throw new Exception("Failed to execute command '" + command + "'", 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<>(); + 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 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; + + private ScheduledTaskWrapper(com.velocitypowered.api.scheduler.ScheduledTask scheduledTask) { + this.scheduledTask = scheduledTask; + } + + @Override + public void cancel() { + scheduledTask.cancel(); + } + } + + private final class PlayerConnectionListener { + + @Subscribe + public void onPostLogin(PostLoginEvent event) { + handlePlayerOnline(event.getPlayer().getUniqueId(), event.getPlayer().getUsername()); + } + } +} 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..0dce9a0 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/VelocityBridgePlugin.java @@ -0,0 +1,240 @@ +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.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; +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; + private VotifierVelocityIntegration votifierIntegration; + @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(); + } + 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()); + } + } + + @Subscribe + public void onProxyShutdown(ProxyShutdownEvent event) { + if (bridgeService != null) { + bridgeService.stop(); + bridgeService = null; + } + if (tebexIntegration != null) { + tebexIntegration.stop(); + tebexIntegration = null; + } + if (votifierIntegration != null) { + votifierIntegration.stop(); + votifierIntegration = 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")); + VotingIntegration votingIntegration = loadVotingConfiguration(getSection(bridgeSection, "voting")); + return new BridgeConfig(baseApiUrl, apiKey, processor, statusReporter, tebexIntegration, votingIntegration); + } + + 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 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(); + 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/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..a206e03 --- /dev/null +++ b/zander-bridge/src/main/java/org/modularsoft/zander/bridge/velocity/voting/VotifierVelocityIntegration.java @@ -0,0 +1,137 @@ +package org.modularsoft.zander.bridge.velocity.voting; + +import com.velocitypowered.api.event.EventHandler; +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.proxy.ProxyServer; +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, + 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; + } + + 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() { + if (registered) { + proxyServer.getEventManager().unregisterListeners(plugin); + registered = false; + } + } + + private void handleVoteEvent(Object 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 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; + } + } + + 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-bridge/src/main/resources/config.yml b/zander-bridge/src/main/resources/config.yml index 809c46e..11bfa90 100644 --- a/zander-bridge/src/main/resources/config.yml +++ b/zander-bridge/src/main/resources/config.yml @@ -1,4 +1,29 @@ -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" + 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 88a014f..82cfc2c 100644 --- a/zander-bridge/src/main/resources/plugin.yml +++ b/zander-bridge/src/main/resources/plugin.yml @@ -1,4 +1,12 @@ 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 + - Votifier + - NuVotifier 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..3ce6cdc --- /dev/null +++ b/zander-bridge/src/main/resources/velocity-plugin.json @@ -0,0 +1,22 @@ +{ + "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 + }, + { + "id": "nuvotifier", + "optional": true + } + ], + "main": "org.modularsoft.zander.bridge.velocity.VelocityBridgePlugin" +} 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..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 @@ -5,16 +5,32 @@ 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.util.Favicon; import dev.dejvokep.boostedyaml.route.Route; import io.github.ModularEnigma.Request; import io.github.ModularEnigma.Response; 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 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 { + private static final Logger logger = ZanderVelocityMain.getLogger(); + private final ZanderVelocityMain plugin; + private volatile Optional cachedFavicon; public UserOnProxyPing(ZanderVelocityMain plugin) { this.plugin = plugin; @@ -23,8 +39,15 @@ 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(); + Optional favicon = originalPing.getFavicon(); + if (favicon.isEmpty()) { + favicon = resolveProxyFavicon(); + } + favicon.ifPresent(pingBuilder::favicon); try { // Fetch configuration values @@ -53,7 +76,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")); @@ -68,4 +92,159 @@ 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() { + 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) { + return Optional.empty(); + } + + Method getConfiguration = proxy.getClass().getMethod("getConfiguration"); + Object configuration = getConfiguration.invoke(proxy); + if (configuration == null) { + 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 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(); + } +} 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..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 @@ -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; @@ -23,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")); @@ -45,31 +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()); } - 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.debug("Skipping legacy bridge dispatch; zander-bridge handles proxy routines."); } -} \ 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); - } - -}