From a1e3c6ae5f2f71cfa3e229316d11fdd635178b65 Mon Sep 17 00:00:00 2001 From: Veyronity Date: Sat, 28 Feb 2026 13:51:34 +0200 Subject: [PATCH 1/3] Add server sent events Also improve API version handling Events: - NewDay - NationCreated, NationDeleted, NationKingChanged, NationMerged - TownCreated, TownDeleted, TownRenamed, TownMayorChanged, TownMerged, TownRuined, TownReclaimed --- src/main/java/net/earthmc/emcapi/EMCAPI.java | 13 ++ .../emcapi/manager/EndpointManager.java | 34 ++-- .../net/earthmc/emcapi/sse/SSEManager.java | 49 ++++++ .../sse/listeners/TownySSEListeners.java | 146 ++++++++++++++++++ .../earthmc/emcapi/util/EndpointUtils.java | 7 + .../net/earthmc/emcapi/util/JSONUtil.java | 12 ++ 6 files changed, 244 insertions(+), 17 deletions(-) create mode 100644 src/main/java/net/earthmc/emcapi/sse/SSEManager.java create mode 100644 src/main/java/net/earthmc/emcapi/sse/listeners/TownySSEListeners.java diff --git a/src/main/java/net/earthmc/emcapi/EMCAPI.java b/src/main/java/net/earthmc/emcapi/EMCAPI.java index 8ce11a9..c1e20e1 100644 --- a/src/main/java/net/earthmc/emcapi/EMCAPI.java +++ b/src/main/java/net/earthmc/emcapi/EMCAPI.java @@ -6,6 +6,8 @@ import jakarta.servlet.http.HttpServletResponse; import net.earthmc.emcapi.integration.Integrations; import net.earthmc.emcapi.manager.EndpointManager; +import net.earthmc.emcapi.sse.SSEManager; +import net.earthmc.emcapi.sse.listeners.TownySSEListeners; import net.earthmc.emcapi.util.EndpointUtils; import net.earthmc.emcapi.command.OptOutCommand; import org.bukkit.command.PluginCommand; @@ -25,6 +27,7 @@ public final class EMCAPI extends JavaPlugin { public static EMCAPI instance; private Javalin javalin; private Integrations pluginIntegrations; + private SSEManager sseManager; @Override public void onLoad() { @@ -58,6 +61,10 @@ public void onEnable() { } catch (IOException e) { getLogger().warning("IOException while loading opted-out players: " + e); } + + sseManager = new SSEManager(this); + sseManager.loadSSE(); + getServer().getPluginManager().registerEvents(new TownySSEListeners(sseManager), this); } @Override @@ -68,6 +75,7 @@ public void onDisable() { } catch (IOException e) { getLogger().warning("IOException while saving opted-out players: " + e); } + sseManager.shutdown(); } private void initialiseJavalin() { @@ -120,4 +128,9 @@ public Javalin getJavalin() { public Integrations integrations() { return this.pluginIntegrations; } + + public String getURLPath() { + String version = getConfig().getString("networking.api_version", "3"); + return "v" + version + "/" + getConfig().getString("networking.url_path"); + } } diff --git a/src/main/java/net/earthmc/emcapi/manager/EndpointManager.java b/src/main/java/net/earthmc/emcapi/manager/EndpointManager.java index 58e8ac0..d218f9a 100644 --- a/src/main/java/net/earthmc/emcapi/manager/EndpointManager.java +++ b/src/main/java/net/earthmc/emcapi/manager/EndpointManager.java @@ -24,12 +24,12 @@ public class EndpointManager { private final EMCAPI plugin; private final Javalin javalin; - private final String v3URLPath; + private final String URLPath; public EndpointManager(EMCAPI plugin) { this.plugin = plugin; this.javalin = plugin.getJavalin(); - this.v3URLPath = "v3/" + plugin.getConfig().getString("networking.url_path"); + this.URLPath = plugin.getURLPath(); } public void loadEndpoints() { @@ -37,10 +37,10 @@ public void loadEndpoints() { javalin.get("/", ctx -> ctx.json(documentationEndpoint.lookup())); ServerEndpoint serverEndpoint = new ServerEndpoint(plugin); - javalin.get(v3URLPath, ctx -> ctx.json(serverEndpoint.lookup())); + javalin.get(URLPath, ctx -> ctx.json(serverEndpoint.lookup())); MysteryMasterEndpoint mysteryMasterEndpoint = new MysteryMasterEndpoint(plugin); - javalin.get(v3URLPath + "/mm", ctx -> { + javalin.get(URLPath + "/mm", ctx -> { plugin.integrations().mysteryMasterIntegration().throwIfDisabled(); ctx.json(mysteryMasterEndpoint.lookup()); }); @@ -80,10 +80,10 @@ private Pair parseBody(String body) { private void loadPlayersEndpoint() { PlayersListEndpoint ple = new PlayersListEndpoint(); - javalin.get(v3URLPath + "/players", ctx -> ctx.json(ple.lookup())); + javalin.get(URLPath + "/players", ctx -> ctx.json(ple.lookup())); PlayersEndpoint playersEndpoint = new PlayersEndpoint(); - javalin.post(v3URLPath + "/players", ctx -> { + javalin.post(URLPath + "/players", ctx -> { Pair parsedBody = parseBody(ctx.body()); ctx.json(playersEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond())); }); @@ -91,10 +91,10 @@ private void loadPlayersEndpoint() { private void loadTownsEndpoint() { TownsListEndpoint tle = new TownsListEndpoint(); - javalin.get(v3URLPath + "/towns", ctx -> ctx.json(tle.lookup())); + javalin.get(URLPath + "/towns", ctx -> ctx.json(tle.lookup())); TownsEndpoint townsEndpoint = new TownsEndpoint(plugin); - javalin.post(v3URLPath + "/towns", ctx -> { + javalin.post(URLPath + "/towns", ctx -> { Pair parsedBody = parseBody(ctx.body()); ctx.json(townsEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond())); }); @@ -102,10 +102,10 @@ private void loadTownsEndpoint() { private void loadNationsEndpoint() { NationsListEndpoint nle = new NationsListEndpoint(); - javalin.get(v3URLPath + "/nations", ctx -> ctx.json(nle.lookup())); + javalin.get(URLPath + "/nations", ctx -> ctx.json(nle.lookup())); NationsEndpoint nationsEndpoint = new NationsEndpoint(); - javalin.post(v3URLPath + "/nations", ctx -> { + javalin.post(URLPath + "/nations", ctx -> { Pair parsedBody = parseBody(ctx.body()); ctx.json(nationsEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond())); }); @@ -115,13 +115,13 @@ private void loadQuartersEndpoint() { QuartersIntegration quartersIntegration = plugin.integrations().quartersIntegration(); QuartersListEndpoint qle = new QuartersListEndpoint(quartersIntegration); - javalin.get(v3URLPath + "/quarters", ctx -> { + javalin.get(URLPath + "/quarters", ctx -> { quartersIntegration.throwIfDisabled(); ctx.json(qle.lookup()); }); QuartersEndpoint quartersEndpoint = new QuartersEndpoint(); - javalin.post(v3URLPath + "/quarters", ctx -> { + javalin.post(URLPath + "/quarters", ctx -> { quartersIntegration.throwIfDisabled(); Pair parsedBody = parseBody(ctx.body()); ctx.json(quartersEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond())); @@ -130,7 +130,7 @@ private void loadQuartersEndpoint() { private void loadLocationEndpoint() { LocationEndpoint locationEndpoint = new LocationEndpoint(); - javalin.post(v3URLPath + "/location", ctx -> { + javalin.post(URLPath + "/location", ctx -> { Pair parsedBody = parseBody(ctx.body()); ctx.json(locationEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond())); }); @@ -138,7 +138,7 @@ private void loadLocationEndpoint() { private void loadNearbyEndpoint() { NearbyEndpoint nearbyEndpoint = new NearbyEndpoint(); - javalin.post(v3URLPath + "/nearby", ctx -> { + javalin.post(URLPath + "/nearby", ctx -> { Pair parsedBody = parseBody(ctx.body()); ctx.json(nearbyEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond())); }); @@ -148,7 +148,7 @@ private void loadDiscordEndpoint() { DiscordEndpoint discordEndpoint = new DiscordEndpoint(); final DiscordIntegration discordIntegration = plugin.integrations().discordIntegration(); - javalin.post(v3URLPath + "/discord", ctx -> { + javalin.post(URLPath + "/discord", ctx -> { discordIntegration.throwIfDisabled(); Pair parsedBody = parseBody(ctx.body()); @@ -159,14 +159,14 @@ private void loadDiscordEndpoint() { private void loadPlayerStatsEndpoint() { PlayerStatsEndpoint playerStatsEndpoint = new PlayerStatsEndpoint(this.plugin); playerStatsEndpoint.initialize(); - javalin.get(v3URLPath + "/player-stats", ctx -> { + javalin.get(URLPath + "/player-stats", ctx -> { ctx.json(playerStatsEndpoint.latestCachedStatistics()); }); } private void loadOnlinePlayersEndpoint() { OnlineEndpoint onlineEndpoint = new OnlineEndpoint(); - javalin.get(v3URLPath + "/online", ctx -> { + javalin.get(URLPath + "/online", ctx -> { ctx.json(onlineEndpoint.lookup()); }); } diff --git a/src/main/java/net/earthmc/emcapi/sse/SSEManager.java b/src/main/java/net/earthmc/emcapi/sse/SSEManager.java new file mode 100644 index 0000000..340af4b --- /dev/null +++ b/src/main/java/net/earthmc/emcapi/sse/SSEManager.java @@ -0,0 +1,49 @@ +package net.earthmc.emcapi.sse; + +import com.google.gson.JsonObject; +import io.javalin.Javalin; +import io.javalin.http.sse.SseClient; +import net.earthmc.emcapi.EMCAPI; + +import java.time.Instant; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public class SSEManager { + private final EMCAPI plugin; + private final Javalin javalin; + private static final Set clients = ConcurrentHashMap.newKeySet(); + + public SSEManager(EMCAPI plugin) { + this.plugin = plugin; + this.javalin = plugin.getJavalin(); + } + + public void loadSSE() { + javalin.sse(plugin.getURLPath() + "/events", client -> { + client.keepAlive(); + client.sendEvent("open", "Connected to the EarthMC API."); + client.onClose(() -> clients.remove(client)); + clients.add(client); + }); + } + + public void shutdown() { + for (SseClient client : clients) { + plugin.getServer().getAsyncScheduler().runNow(plugin, t -> { + client.sendEvent("close", "Disconnected from the EarthMC API."); + client.close(); + }); + } + } + + public void sendEvent(String event, JsonObject data) { + long timestamp = Instant.now().getEpochSecond(); + data.addProperty("timestamp", timestamp); + String message = data.toString(); + + for (SseClient client : clients) { + plugin.getServer().getAsyncScheduler().runNow(plugin, t -> client.sendEvent(event, message)); + } + } +} diff --git a/src/main/java/net/earthmc/emcapi/sse/listeners/TownySSEListeners.java b/src/main/java/net/earthmc/emcapi/sse/listeners/TownySSEListeners.java new file mode 100644 index 0000000..b455767 --- /dev/null +++ b/src/main/java/net/earthmc/emcapi/sse/listeners/TownySSEListeners.java @@ -0,0 +1,146 @@ +package net.earthmc.emcapi.sse.listeners; + +import com.google.gson.JsonObject; +import com.palmergames.bukkit.towny.event.DeleteTownEvent; +import com.palmergames.bukkit.towny.event.NewDayEvent; +import com.palmergames.bukkit.towny.event.NewNationEvent; +import com.palmergames.bukkit.towny.event.NewTownEvent; +import com.palmergames.bukkit.towny.event.PreDeleteNationEvent; +import com.palmergames.bukkit.towny.event.RenameNationEvent; +import com.palmergames.bukkit.towny.event.RenameTownEvent; +import com.palmergames.bukkit.towny.event.nation.NationKingChangeEvent; +import com.palmergames.bukkit.towny.event.nation.NationMergeEvent; +import com.palmergames.bukkit.towny.event.town.TownMayorChangedEvent; +import com.palmergames.bukkit.towny.event.town.TownMergeEvent; +import com.palmergames.bukkit.towny.event.town.TownPreRuinedEvent; +import com.palmergames.bukkit.towny.event.town.TownReclaimedEvent; +import com.palmergames.bukkit.towny.object.Nation; +import net.earthmc.emcapi.sse.SSEManager; +import net.earthmc.emcapi.util.EndpointUtils; +import net.earthmc.emcapi.util.JSONUtil; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +public class TownySSEListeners implements Listener { + private final SSEManager sse; + + public TownySSEListeners(SSEManager sse) { + this.sse = sse; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onNewDay(NewDayEvent event) { + JsonObject message = new JsonObject(); + message.add("fallenTowns", JSONUtil.getJsonArrayFromStringList(event.getFallenTowns())); + message.add("fallenNations", JSONUtil.getJsonArrayFromStringList(event.getFallenNations())); + sse.sendEvent("NewDay", message); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onNewNation(NewNationEvent event) { + JsonObject message = new JsonObject(); + Nation nation = event.getNation(); + message.add("nation", EndpointUtils.getNationJsonObject(nation)); + message.add("king", EndpointUtils.getResidentJsonObject(nation.getKing())); + message.add("capital", EndpointUtils.getTownJsonObject(nation.getCapital())); + sse.sendEvent("NationCreated", message); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDeleteNation(PreDeleteNationEvent event) { + JsonObject message = new JsonObject(); + Nation nation = event.getNation(); + message.add("nation", EndpointUtils.generateNameUUIDJsonObject(nation.getName(), nation.getUUID())); + message.add("king", EndpointUtils.getResidentJsonObject(nation.getKing())); + message.add("capital", EndpointUtils.getTownJsonObject(nation.getCapital())); + sse.sendEvent("NationDeleted", message); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onRenameNation(RenameNationEvent event) { + JsonObject message = new JsonObject(); + message.add("nation", EndpointUtils.getNationJsonObject(event.getNation())); + message.addProperty("oldName", event.getOldName()); + sse.sendEvent("NationRenamed", message); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onNationKingChange(NationKingChangeEvent event) { + JsonObject message = new JsonObject(); + message.add("nation", EndpointUtils.getNationJsonObject(event.getNation())); + message.add("newKing", EndpointUtils.getResidentJsonObject(event.getNewKing())); + message.add("oldKing", EndpointUtils.getResidentJsonObject(event.getOldKing())); + message.addProperty("isCapitalChange", event.isCapitalChange()); + if (event.isCapitalChange()) { + message.add("newCapital", EndpointUtils.getTownJsonObject(event.getNewKing().getTownOrNull())); + message.add("oldCapital", EndpointUtils.getTownJsonObject(event.getOldKing().getTownOrNull())); + } + sse.sendEvent("NationKingChanged", message); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onNationMerge(NationMergeEvent event) { + JsonObject message = new JsonObject(); + message.add("oldNation", EndpointUtils.getNationJsonObject(event.getNation())); + message.add("remainingNation", EndpointUtils.getNationJsonObject(event.getRemainingnation())); + sse.sendEvent("NationMerged", message); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onNewTown(NewTownEvent event) { + JsonObject message = new JsonObject(); + message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); + message.add("mayor", EndpointUtils.getResidentJsonObject(event.getTown().getMayor())); + sse.sendEvent("TownCreated", message); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDeleteTown(DeleteTownEvent event) { + JsonObject message = new JsonObject(); + message.add("town", EndpointUtils.generateNameUUIDJsonObject(event.getTownName(), event.getTownUUID())); + message.add("mayor", EndpointUtils.getResidentJsonObject(event.getMayor())); + sse.sendEvent("TownDeleted", message); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onRenameTown(RenameTownEvent event) { + JsonObject message = new JsonObject(); + message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); + message.addProperty("oldName", event.getOldName()); + sse.sendEvent("TownRenamed", message); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTownMayorChanged(TownMayorChangedEvent event) { + JsonObject message = new JsonObject(); + message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); + message.add("newMayor", EndpointUtils.getResidentJsonObject(event.getNewMayor())); + message.add("oldMayor", EndpointUtils.getResidentJsonObject(event.getOldMayor())); + sse.sendEvent("TownMayorChanged", message); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTownMerge(TownMergeEvent event) { + JsonObject message = new JsonObject(); + message.add("oldTown", EndpointUtils.generateNameUUIDJsonObject(event.getSuccumbingTownName(), event.getSuccumbingTownUUID())); + message.add("remainingTown", EndpointUtils.getTownJsonObject(event.getRemainingTown())); + sse.sendEvent("TownMerged", message); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTownRuined(TownPreRuinedEvent event) { + JsonObject message = new JsonObject(); + message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); + message.add("oldMayor", EndpointUtils.getResidentJsonObject(event.getTown().getMayor())); + sse.sendEvent("TownRuined", message); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTownReclaimed(TownReclaimedEvent event) { + JsonObject message = new JsonObject(); + message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); + message.add("newMayor", EndpointUtils.getResidentJsonObject(event.getResident())); + sse.sendEvent("TownReclaimed", message); + } +} diff --git a/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java b/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java index 4afdb27..c2fe088 100644 --- a/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java +++ b/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java @@ -214,4 +214,11 @@ public static void saveOptOut(Path path) throws IOException { Files.write(path.resolve(optOutFile), lines, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); } + + public static JsonObject generateNameUUIDJsonObject(String name, UUID uuid) { + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("name", name); + jsonObject.addProperty("uuid", uuid.toString()); + return jsonObject; + } } diff --git a/src/main/java/net/earthmc/emcapi/util/JSONUtil.java b/src/main/java/net/earthmc/emcapi/util/JSONUtil.java index 7257c59..38acc3f 100644 --- a/src/main/java/net/earthmc/emcapi/util/JSONUtil.java +++ b/src/main/java/net/earthmc/emcapi/util/JSONUtil.java @@ -3,6 +3,8 @@ import com.google.gson.*; import io.javalin.http.BadRequestResponse; +import java.util.List; + public class JSONUtil { public static JsonObject getJsonObjectFromString(String string) { @@ -13,6 +15,16 @@ public static JsonObject getJsonObjectFromString(String string) { } } + public static JsonArray getJsonArrayFromStringList(List stringList) { + JsonArray jsonArray = new JsonArray(); + if (stringList == null) return jsonArray; + + for (String item : stringList) { + jsonArray.add(item); + } + return jsonArray; + } + public static String getJsonElementAsStringOrNull(JsonElement element) { if (element == null) return null; From a0d8ab6e5e22bb4cabf1dd0d10e7d787ff24d7ad Mon Sep 17 00:00:00 2001 From: Veyronity Date: Sat, 28 Feb 2026 18:37:57 +0200 Subject: [PATCH 2/3] Remove broken shutdown function - Update plugin version - Update config.yml --- gradle.properties | 2 +- src/main/java/net/earthmc/emcapi/EMCAPI.java | 1 - src/main/java/net/earthmc/emcapi/sse/SSEManager.java | 9 --------- src/main/resources/config.yml | 5 ++++- 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/gradle.properties b/gradle.properties index 52d728a..be58298 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ group = net.earthmc.emcapi -version = 3.0.0-SNAPSHOT +version = 3.1.0-SNAPSHOT description = EMCAPI org.gradle.configuration-cache=true diff --git a/src/main/java/net/earthmc/emcapi/EMCAPI.java b/src/main/java/net/earthmc/emcapi/EMCAPI.java index c1e20e1..c9bc198 100644 --- a/src/main/java/net/earthmc/emcapi/EMCAPI.java +++ b/src/main/java/net/earthmc/emcapi/EMCAPI.java @@ -75,7 +75,6 @@ public void onDisable() { } catch (IOException e) { getLogger().warning("IOException while saving opted-out players: " + e); } - sseManager.shutdown(); } private void initialiseJavalin() { diff --git a/src/main/java/net/earthmc/emcapi/sse/SSEManager.java b/src/main/java/net/earthmc/emcapi/sse/SSEManager.java index 340af4b..32a6511 100644 --- a/src/main/java/net/earthmc/emcapi/sse/SSEManager.java +++ b/src/main/java/net/earthmc/emcapi/sse/SSEManager.java @@ -28,15 +28,6 @@ public void loadSSE() { }); } - public void shutdown() { - for (SseClient client : clients) { - plugin.getServer().getAsyncScheduler().runNow(plugin, t -> { - client.sendEvent("close", "Disconnected from the EarthMC API."); - client.close(); - }); - } - } - public void sendEvent(String event, JsonObject data) { long timestamp = Instant.now().getEpochSecond(); data.addProperty("timestamp", timestamp); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 0dbb4a2..c026564 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -7,7 +7,10 @@ networking: # Port the API will listen on port: 8080 - # The URL path, for instance https://example.com/v3/aurora where aurora is the string written below, version numbers are always present + # The version number for endpoints + api_version: 3 + + # The URL path, formatted as: https://example.com/{api_version}/{url_path} url_path: aurora behaviour: From c5cbd72dc494a85eee20a386c0ee118cb2eae078 Mon Sep 17 00:00:00 2001 From: Veyronity Date: Fri, 6 Mar 2026 13:40:25 +0200 Subject: [PATCH 3/3] Add API keys, Shop integration, and allow specifying events --- build.gradle.kts | 7 + gradle/libs.versions.toml | 2 + src/main/java/net/earthmc/emcapi/EMCAPI.java | 27 +++- .../earthmc/emcapi/command/ApiCommand.java | 121 ++++++++++++++++++ .../earthmc/emcapi/command/OptOutCommand.java | 59 --------- .../net/earthmc/emcapi/sse/SSEManager.java | 116 ++++++++++++++++- .../sse/listeners/AbstractSSEListener.java | 12 ++ .../emcapi/sse/listeners/ShopSSEListener.java | 68 ++++++++++ ...SEListeners.java => TownySSEListener.java} | 55 ++++++-- .../earthmc/emcapi/util/EndpointUtils.java | 66 ++++++++++ 10 files changed, 457 insertions(+), 76 deletions(-) create mode 100644 src/main/java/net/earthmc/emcapi/command/ApiCommand.java delete mode 100644 src/main/java/net/earthmc/emcapi/command/OptOutCommand.java create mode 100644 src/main/java/net/earthmc/emcapi/sse/listeners/AbstractSSEListener.java create mode 100644 src/main/java/net/earthmc/emcapi/sse/listeners/ShopSSEListener.java rename src/main/java/net/earthmc/emcapi/sse/listeners/{TownySSEListeners.java => TownySSEListener.java} (75%) diff --git a/build.gradle.kts b/build.gradle.kts index 62c2c4b..e9ff33c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,6 +23,10 @@ repositories { maven("https://repo.earthmc.net/public/") { mavenContent { includeGroupAndSubgroups("net.earthmc") } } + + maven("https://repo.codemc.io/repository/maven-public/") { + mavenContent { includeGroup("org.maxgamer") } + } } dependencies { @@ -33,6 +37,9 @@ dependencies { compileOnly(libs.discordsrv) compileOnly(libs.superbvote) compileOnly(libs.mysterymaster.api) + compileOnly(libs.quickshop) { + exclude("*") + } } tasks { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6bc1adf..969fd0b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ towny = "0.102.0.0" mysterymaster-api = "1.0.0" superbvote = "0.6.0" conventions = "1.0.8" +quickshop = "5.1.2.5-SNAPSHOT" [libraries] discordsrv = { group = "com.discordsrv", name = "discordsrv", version.ref = "discordsrv" } @@ -16,6 +17,7 @@ javalin = { group = "io.javalin", name = "javalin", version.ref = "javalin" } paper = { group = "io.papermc.paper", name = "paper-api", version.ref = "paper" } mysterymaster-api = { group = "net.earthmc.mysterymaster", name = "mysterymaster-api", version.ref = "mysterymaster-api" } superbvote = { group = "net.earthmc.superbvote", name = "SuperbVote", version.ref = "superbvote" } +quickshop = { group = "org.maxgamer", name = "QuickShop", version.ref = "quickshop" } [plugins] conventions-java = { id = "net.earthmc.conventions.java", version.ref = "conventions" } diff --git a/src/main/java/net/earthmc/emcapi/EMCAPI.java b/src/main/java/net/earthmc/emcapi/EMCAPI.java index c9bc198..1531b1e 100644 --- a/src/main/java/net/earthmc/emcapi/EMCAPI.java +++ b/src/main/java/net/earthmc/emcapi/EMCAPI.java @@ -7,10 +7,12 @@ import net.earthmc.emcapi.integration.Integrations; import net.earthmc.emcapi.manager.EndpointManager; import net.earthmc.emcapi.sse.SSEManager; -import net.earthmc.emcapi.sse.listeners.TownySSEListeners; +import net.earthmc.emcapi.sse.listeners.ShopSSEListener; +import net.earthmc.emcapi.sse.listeners.TownySSEListener; import net.earthmc.emcapi.util.EndpointUtils; -import net.earthmc.emcapi.command.OptOutCommand; +import net.earthmc.emcapi.command.ApiCommand; import org.bukkit.command.PluginCommand; +import org.bukkit.plugin.PluginManager; import org.bukkit.plugin.java.JavaPlugin; import org.eclipse.jetty.server.Connector; import org.eclipse.jetty.server.HttpConnectionFactory; @@ -52,7 +54,7 @@ public void onEnable() { if (apiCommand == null) { getLogger().warning("API command not found."); } else { - OptOutCommand cmd = new OptOutCommand(); + ApiCommand cmd = new ApiCommand(); apiCommand.setExecutor(cmd); apiCommand.setTabCompleter(cmd); } @@ -64,7 +66,18 @@ public void onEnable() { sseManager = new SSEManager(this); sseManager.loadSSE(); - getServer().getPluginManager().registerEvents(new TownySSEListeners(sseManager), this); + PluginManager pm = getServer().getPluginManager(); + if (pm.isPluginEnabled("Towny")) { + pm.registerEvents(new TownySSEListener(sseManager), this); + } + if (pm.isPluginEnabled("QuickShop")) { + pm.registerEvents(new ShopSSEListener(sseManager), this); + } + try { + EndpointUtils.loadApiKeys(getDataFolder().toPath()); + } catch (IOException e) { + getSLF4JLogger().warn("IOException while loading API keys: ", e); + } } @Override @@ -75,6 +88,12 @@ public void onDisable() { } catch (IOException e) { getLogger().warning("IOException while saving opted-out players: " + e); } + sseManager.shutdown(); + try { + EndpointUtils.saveApiKeys(getDataFolder().toPath()); + } catch (IOException e) { + getSLF4JLogger().warn("IOException while saving API keys: ", e); + } } private void initialiseJavalin() { diff --git a/src/main/java/net/earthmc/emcapi/command/ApiCommand.java b/src/main/java/net/earthmc/emcapi/command/ApiCommand.java new file mode 100644 index 0000000..ff48b0a --- /dev/null +++ b/src/main/java/net/earthmc/emcapi/command/ApiCommand.java @@ -0,0 +1,121 @@ +package net.earthmc.emcapi.command; + +import net.earthmc.emcapi.sse.SSEManager; +import net.earthmc.emcapi.util.EndpointUtils; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; + +public class ApiCommand implements TabExecutor { + private static final Component infoMessage = Component.text() + .append(Component.text("- The API provides real-time information about players, towns, and nations. The API can be accessed ", NamedTextColor.AQUA)) + .append(Component.text("here", NamedTextColor.AQUA, TextDecoration.UNDERLINED).clickEvent(ClickEvent.openUrl("https://api.earthmc.net/"))) + .appendNewline() + .append(Component.text("- Read the docs ", NamedTextColor.GREEN)) + .append(Component.text("here", NamedTextColor.GREEN, TextDecoration.UNDERLINED).clickEvent(ClickEvent.openUrl("https://earthmc.net/docs/api"))) + .appendNewline() + .append(Component.text("- If you'd like to connect to the API's Server-Sent-Events endpoint, please create an API key using /api key create", NamedTextColor.GREEN)) + .appendNewline() + .append(Component.text("- If you'd like to opt out of your information being public on the API, you can use /api opt-out", NamedTextColor.RED)) + .build(); + + @Override + public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] args) { + if (!(commandSender instanceof Player player)) { + commandSender.sendMessage(Component.text("Only players may use this command.", NamedTextColor.RED)); + return true; + } + + if (args.length < 1) { + player.sendMessage(infoMessage); + return true; + } + String action = args[0].toLowerCase(); + switch (action) { + case "opt-in" -> { + player.sendMessage(Component.text("You have opted back in for your information being public on the API.", NamedTextColor.GREEN)); + EndpointUtils.setOptedOut(player.getUniqueId(), false); + } + case "opt-out" -> { + player.sendMessage(Component.text("You have opted out of your information being public on the API", NamedTextColor.RED)); + EndpointUtils.setOptedOut(player.getUniqueId(), true); + } + case "key" -> handleKey(player, args); + default -> player.sendMessage(Component.text("Usage: /api [opt-in|opt-out|key]", NamedTextColor.RED)); + } + + return true; + } + + @Override + public @Nullable List onTabComplete(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] args) { + if (args.length == 1) { + return Stream.of("opt-in", "opt-out", "key").filter(str -> str.toLowerCase().startsWith(args[0].toLowerCase())).toList(); + } + if (args.length == 2 && args[0].equalsIgnoreCase("key")) { + return Stream.of("create", "delete", "copy").filter(str -> str.toLowerCase().startsWith(args[1].toLowerCase())).toList(); + } + + return List.of(); + } + + private void handleKey(Player player, String[] args) { + if (!player.hasPermission("emcapi.key")) { + player.sendMessage(Component.text("You do not have permission to use this command", NamedTextColor.RED)); + return; + } + + UUID playerID = player.getUniqueId(); + UUID key; + if (args.length == 1) { + key = EndpointUtils.getPlayerKey(playerID); + if (key != null) { + player.sendMessage(Component.text("Click to copy your API key.", NamedTextColor.GREEN).clickEvent(ClickEvent.copyToClipboard(key.toString()))); + } else { + player.sendMessage(Component.text("You do not have an API key. Use /api key create to create one.", NamedTextColor.RED)); + } + return; + } + String action = args[1].toLowerCase(); + switch (action) { + case "create" -> { + if (EndpointUtils.getPlayerKey(playerID) != null) { + player.sendMessage(Component.text("You already have an API key! Use /api key to get it.", NamedTextColor.RED)); + } else { + key = EndpointUtils.createApiKey(playerID); + player.sendMessage(Component.text("Key created! Click to copy.", NamedTextColor.GREEN).clickEvent(ClickEvent.copyToClipboard(key.toString()))); + } + } + case "delete" -> { + key = EndpointUtils.getPlayerKey(playerID); + if (key != null) { + SSEManager.deleteKey(key); + EndpointUtils.deletePlayerKey(playerID); + player.sendMessage(Component.text("Successfully deleted your API key", NamedTextColor.GREEN)); + } else { + player.sendMessage(Component.text("You do not have an API key.", NamedTextColor.RED)); + } + } + case "copy" -> { + key = EndpointUtils.getPlayerKey(playerID); + if (key != null) { + player.sendMessage(Component.text("Click to copy your API key.", NamedTextColor.GREEN).clickEvent(ClickEvent.copyToClipboard(key.toString()))); + } else { + player.sendMessage(Component.text("You do not have an API key. Use /api key create to create one.", NamedTextColor.RED)); + } + } + default -> player.sendMessage(Component.text("Usage: /api key ", NamedTextColor.RED)); + } + } +} diff --git a/src/main/java/net/earthmc/emcapi/command/OptOutCommand.java b/src/main/java/net/earthmc/emcapi/command/OptOutCommand.java deleted file mode 100644 index 1d6f9d3..0000000 --- a/src/main/java/net/earthmc/emcapi/command/OptOutCommand.java +++ /dev/null @@ -1,59 +0,0 @@ -package net.earthmc.emcapi.command; - -import net.earthmc.emcapi.util.EndpointUtils; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.format.TextDecoration; -import org.bukkit.command.Command; -import org.bukkit.command.CommandSender; -import org.bukkit.command.TabExecutor; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.List; -import java.util.stream.Stream; - -public class OptOutCommand implements TabExecutor { - private static final Component infoMessage = Component.text("The API provides real-time information about players, towns, and nations. The API can be accessed ", NamedTextColor.BLUE) - .append(Component.text("here", NamedTextColor.BLUE, TextDecoration.UNDERLINED).clickEvent(ClickEvent.openUrl("https://api.earthmc.net/v3/aurora/"))) - .appendNewline() - .append(Component.text("Read the docs ", NamedTextColor.GREEN)) - .append(Component.text("here", NamedTextColor.GREEN, TextDecoration.UNDERLINED).clickEvent(ClickEvent.openUrl("https://earthmc.net/docs/api"))) - .appendNewline() - .append(Component.text("If you'd like to opt out of your information being public on the API, you can use /api opt-out", NamedTextColor.DARK_RED)); - - @Override - public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] strings) { - if (!(commandSender instanceof Player player)) { - commandSender.sendMessage(Component.text("Only players may use this command.", NamedTextColor.RED)); - return true; - } - - if (strings.length < 1) { - player.sendMessage(infoMessage); - return true; - } - if (strings[0].equalsIgnoreCase("opt-in")) { - player.sendMessage(Component.text("You have opted back in for your information being public on the API.", NamedTextColor.GREEN)); - EndpointUtils.setOptedOut(player.getUniqueId(), false); - } else if (strings[0].equalsIgnoreCase("opt-out")){ - player.sendMessage(Component.text("You have opted out of your information being public on the API", NamedTextColor.RED)); - EndpointUtils.setOptedOut(player.getUniqueId(), true); - } else { - player.sendMessage(Component.text("Usage: /api [opt-in|opt-out]", NamedTextColor.RED)); - } - - return true; - } - - @Override - public @Nullable List onTabComplete(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] strings) { - if (strings.length == 1) { - return Stream.of("opt-in", "opt-out").filter(str -> str.startsWith(strings[0])).toList(); - } - - return List.of(); - } -} diff --git a/src/main/java/net/earthmc/emcapi/sse/SSEManager.java b/src/main/java/net/earthmc/emcapi/sse/SSEManager.java index 32a6511..3166308 100644 --- a/src/main/java/net/earthmc/emcapi/sse/SSEManager.java +++ b/src/main/java/net/earthmc/emcapi/sse/SSEManager.java @@ -2,17 +2,33 @@ import com.google.gson.JsonObject; import io.javalin.Javalin; +import io.javalin.http.Context; import io.javalin.http.sse.SseClient; import net.earthmc.emcapi.EMCAPI; +import net.earthmc.emcapi.util.EndpointUtils; +import javax.annotation.Nullable; import java.time.Instant; +import java.util.HashSet; +import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; public class SSEManager { private final EMCAPI plugin; private final Javalin javalin; + private static final Map clientsMap = new ConcurrentHashMap<>(); private static final Set clients = ConcurrentHashMap.newKeySet(); + private static final Set ALLOWED_EVENTS = Set.of( + "NewDay", + "NationCreated", "NationDeleted", "NationRenamed", "NationKingChanged", "NationMerged", + "TownCreated", "TownDeleted", "TownRenamed", "TownMayorChanged", "TownMerged", "TownRuined", "TownReclaimed", + "TownJoinedNation", "TownLeftNation", + "ResidentJoinedTown", "ResidentLeftTown", + "ShopSoldItem", "ShopBoughtItem", "ShopOutOfStock", "ShopOutOfSpace", "ShopOutOfGold" + ); public SSEManager(EMCAPI plugin) { this.plugin = plugin; @@ -21,20 +37,114 @@ public SSEManager(EMCAPI plugin) { public void loadSSE() { javalin.sse(plugin.getURLPath() + "/events", client -> { + Context ctx = client.ctx(); + String auth = ctx.header("Authorization"); + + if (auth == null || !auth.startsWith("Bearer ")) { + ctx.status(401).result("Missing API key"); + client.close(); + return; + } + + UUID key; + try { + key = UUID.fromString(auth.substring("Bearer ".length())); + } catch (IllegalArgumentException ignored) { + ctx.status(401).result("Invalid API key format"); + client.close(); + return; + } + + UUID owner = EndpointUtils.getKeyOwner(key); + if (owner == null) { + ctx.status(403).result("Invalid API key"); + client.close(); + return; + } + if (clientsMap.containsKey(key)) { + ctx.status(403).result("This API key is already in use."); + client.close(); + return; + } + + Set events = new HashSet<>(); + Set invalid = new HashSet<>(); + + String listenStr = ctx.queryParam("listen"); + if (listenStr != null) { + for (String event : listenStr.split(",")) { + if (ALLOWED_EVENTS.contains(event)) { + events.add(event); + } else { + invalid.add(event); + } + } + if (events.isEmpty()) { + ctx.status(400).result("No valid events specified"); + client.close(); + return; + } + } else { + events.addAll(new HashSet<>(ALLOWED_EVENTS)); + } + ClientData data = new ClientData(client, events, owner); client.keepAlive(); client.sendEvent("open", "Connected to the EarthMC API."); - client.onClose(() -> clients.remove(client)); + client.sendEvent("listening", "Listening to the following events: " + String.join(", ", events)); + if (!invalid.isEmpty()) { + client.sendEvent("invalid", "The following events are invalid: " + String.join(", ", invalid)); + } + client.onClose(() -> { + clients.remove(client); + clientsMap.remove(key, data); + }); + clients.add(client); + clientsMap.put(key, data); }); } + public void shutdown() { + for (SseClient client : clients) { + client.sendEvent("close", "EarthMC API shut down."); + client.close(); + } + clientsMap.clear(); + } + public void sendEvent(String event, JsonObject data) { + sendEvent(event, data, (Predicate) null); + } + + public void sendEvent(String event, JsonObject data, UUID targetPlayerID) { + sendEvent(event, data, client -> client.playerID.equals(targetPlayerID)); + } + + public void sendEvent(String event, JsonObject data, @Nullable Predicate predicate) { long timestamp = Instant.now().getEpochSecond(); data.addProperty("timestamp", timestamp); String message = data.toString(); - for (SseClient client : clients) { - plugin.getServer().getAsyncScheduler().runNow(plugin, t -> client.sendEvent(event, message)); + plugin.getServer().getAsyncScheduler().runNow(plugin, t -> { + for (ClientData clientData : clientsMap.values()) { + if (!clientData.events.contains(event)) { + continue; + } + if (predicate != null && !predicate.test(clientData)) + continue; + clientData.client.sendEvent(event, message); + } + }); + } + + public static void deleteKey(UUID uuid) { + ClientData data = clientsMap.remove(uuid); + if (data != null) { + SseClient client = data.client; + client.sendEvent("disconnected", "This API key was deleted by the owner"); + client.close(); } } + + public record ClientData(SseClient client, Set events, UUID playerID) {} } diff --git a/src/main/java/net/earthmc/emcapi/sse/listeners/AbstractSSEListener.java b/src/main/java/net/earthmc/emcapi/sse/listeners/AbstractSSEListener.java new file mode 100644 index 0000000..5b3c3e1 --- /dev/null +++ b/src/main/java/net/earthmc/emcapi/sse/listeners/AbstractSSEListener.java @@ -0,0 +1,12 @@ +package net.earthmc.emcapi.sse.listeners; + +import net.earthmc.emcapi.sse.SSEManager; +import org.bukkit.event.Listener; + +public abstract class AbstractSSEListener implements Listener { + protected final SSEManager sse; + + protected AbstractSSEListener(SSEManager sseManager) { + this.sse = sseManager; + } +} diff --git a/src/main/java/net/earthmc/emcapi/sse/listeners/ShopSSEListener.java b/src/main/java/net/earthmc/emcapi/sse/listeners/ShopSSEListener.java new file mode 100644 index 0000000..cd5ed26 --- /dev/null +++ b/src/main/java/net/earthmc/emcapi/sse/listeners/ShopSSEListener.java @@ -0,0 +1,68 @@ +package net.earthmc.emcapi.sse.listeners; + +import com.google.gson.JsonObject; +import com.palmergames.bukkit.towny.TownyAPI; +import com.palmergames.bukkit.towny.object.Resident; +import net.earthmc.emcapi.sse.SSEManager; +import net.earthmc.emcapi.util.EndpointUtils; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.maxgamer.quickshop.api.event.ShopSuccessPurchaseEvent; +import org.maxgamer.quickshop.api.shop.Shop; + +import java.util.UUID; + +public class ShopSSEListener extends AbstractSSEListener { + + public ShopSSEListener(SSEManager sseManager) { + super(sseManager); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onShopPurchase(ShopSuccessPurchaseEvent event) { + Shop shop = event.getShop(); + UUID owner = shop.getOwner(); + + boolean isSelling = shop.isSelling(); + String purchaser = getPlayerName(event.getPurchaser()); + JsonObject saleMessage = new JsonObject(); + saleMessage.add("shop", EndpointUtils.getShopObject(shop)); + if (isSelling) { + saleMessage.addProperty("buyer", purchaser); + sse.sendEvent("ShopSoldItem", saleMessage, owner); + } else { + saleMessage.addProperty("seller", purchaser); + sse.sendEvent("ShopBoughtItem", saleMessage, owner); + } + + checkOwnerBalance(owner); + checkShopOut(shop); + } + + private void checkOwnerBalance(UUID owner) { + Resident res = TownyAPI.getInstance().getResident(owner); + if (res == null || res.getAccount().getHoldingBalance() > 0) return; + + sse.sendEvent("ShopOutOfGold", new JsonObject(), owner); + } + + private void checkShopOut(Shop shop) { + boolean isSelling = shop.isSelling(); + if (isSelling && shop.getRemainingStock() > 0 || !isSelling && shop.getRemainingSpace() > 0) { + return; + } + JsonObject alertMessage = new JsonObject(); + alertMessage.addProperty("action", isSelling ? "out_of_stock" : "out_of_space"); + alertMessage.add("shop", EndpointUtils.getShopObject(shop)); + sse.sendEvent("ShopOutOf" + (isSelling ? "Stock " : "Space"), alertMessage, shop.getOwner()); + } + + private String getPlayerName(UUID uuid) { + Resident res = TownyAPI.getInstance().getResident(uuid); + if (res != null) { + return res.getName(); + } else { + return "Unknown player `(" + uuid + ")`"; + } + } +} diff --git a/src/main/java/net/earthmc/emcapi/sse/listeners/TownySSEListeners.java b/src/main/java/net/earthmc/emcapi/sse/listeners/TownySSEListener.java similarity index 75% rename from src/main/java/net/earthmc/emcapi/sse/listeners/TownySSEListeners.java rename to src/main/java/net/earthmc/emcapi/sse/listeners/TownySSEListener.java index b455767..2f4b885 100644 --- a/src/main/java/net/earthmc/emcapi/sse/listeners/TownySSEListeners.java +++ b/src/main/java/net/earthmc/emcapi/sse/listeners/TownySSEListener.java @@ -2,12 +2,16 @@ import com.google.gson.JsonObject; import com.palmergames.bukkit.towny.event.DeleteTownEvent; +import com.palmergames.bukkit.towny.event.NationAddTownEvent; +import com.palmergames.bukkit.towny.event.NationRemoveTownEvent; import com.palmergames.bukkit.towny.event.NewDayEvent; import com.palmergames.bukkit.towny.event.NewNationEvent; import com.palmergames.bukkit.towny.event.NewTownEvent; import com.palmergames.bukkit.towny.event.PreDeleteNationEvent; import com.palmergames.bukkit.towny.event.RenameNationEvent; import com.palmergames.bukkit.towny.event.RenameTownEvent; +import com.palmergames.bukkit.towny.event.TownAddResidentEvent; +import com.palmergames.bukkit.towny.event.TownRemoveResidentEvent; import com.palmergames.bukkit.towny.event.nation.NationKingChangeEvent; import com.palmergames.bukkit.towny.event.nation.NationMergeEvent; import com.palmergames.bukkit.towny.event.town.TownMayorChangedEvent; @@ -15,26 +19,21 @@ import com.palmergames.bukkit.towny.event.town.TownPreRuinedEvent; import com.palmergames.bukkit.towny.event.town.TownReclaimedEvent; import com.palmergames.bukkit.towny.object.Nation; +import com.palmergames.bukkit.towny.object.Town; import net.earthmc.emcapi.sse.SSEManager; import net.earthmc.emcapi.util.EndpointUtils; -import net.earthmc.emcapi.util.JSONUtil; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -public class TownySSEListeners implements Listener { - private final SSEManager sse; +public class TownySSEListener extends AbstractSSEListener { - public TownySSEListeners(SSEManager sse) { - this.sse = sse; + public TownySSEListener(SSEManager sse) { + super(sse); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onNewDay(NewDayEvent event) { - JsonObject message = new JsonObject(); - message.add("fallenTowns", JSONUtil.getJsonArrayFromStringList(event.getFallenTowns())); - message.add("fallenNations", JSONUtil.getJsonArrayFromStringList(event.getFallenNations())); - sse.sendEvent("NewDay", message); + sse.sendEvent("NewDay", new JsonObject()); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @@ -143,4 +142,40 @@ public void onTownReclaimed(TownReclaimedEvent event) { message.add("newMayor", EndpointUtils.getResidentJsonObject(event.getResident())); sse.sendEvent("TownReclaimed", message); } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTownJoin(NationAddTownEvent event) { + Nation nation = event.getNation(); + JsonObject message = new JsonObject(); + message.add("nation", EndpointUtils.getNationJsonObject(nation)); + message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); + sse.sendEvent("TownJoinedNation", message, nation.getKing().getUUID()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTownLeave(NationRemoveTownEvent event) { + Nation nation = event.getNation(); + JsonObject message = new JsonObject(); + message.add("nation", EndpointUtils.getNationJsonObject(nation)); + message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); + sse.sendEvent("TownLeftNation", message, nation.getKing().getUUID()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTownJoin(TownAddResidentEvent event) { + Town town = event.getTown(); + JsonObject message = new JsonObject(); + message.add("town", EndpointUtils.getTownJsonObject(town)); + message.add("resident", EndpointUtils.getResidentJsonObject(event.getResident())); + sse.sendEvent("ResidentJoinedTown", message, town.getMayor().getUUID()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTownLeave(TownRemoveResidentEvent event) { + Town town = event.getTown(); + JsonObject message = new JsonObject(); + message.add("town", EndpointUtils.getTownJsonObject(town)); + message.add("resident", EndpointUtils.getResidentJsonObject(event.getResident())); + sse.sendEvent("ResidentLeftTown", message, town.getMayor().getUUID()); + } } diff --git a/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java b/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java index c2fe088..f9b412c 100644 --- a/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java +++ b/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java @@ -10,11 +10,14 @@ import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.maxgamer.quickshop.api.shop.Shop; import javax.annotation.Nullable; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.Set; import java.util.HashSet; @@ -22,10 +25,15 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.concurrent.ConcurrentHashMap; public class EndpointUtils { private static final Set optedOut = new HashSet<>(); private static final String optOutFile = "opt-out.txt"; + private static final Map playerKeyMap = new ConcurrentHashMap<>(); + private static final Map keyPlayerMap = new ConcurrentHashMap<>(); + private static final Set apiKeys = ConcurrentHashMap.newKeySet(); + private static final String apiKeyFile = "api_keys.txt"; public static int getNumOnlineNomads() { int numOnlineNomads = 0; @@ -215,10 +223,68 @@ public static void saveOptOut(Path path) throws IOException { Files.write(path.resolve(optOutFile), lines, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); } + public static void loadApiKeys(Path path) throws IOException { + final Path file = path.resolve(apiKeyFile); + if (!Files.exists(file)) { + return; + } + + Files.readAllLines(file).forEach(result -> { + try { + String[] split = result.split(","); + if (split.length != 2) return; + UUID player = UUID.fromString(split[0]); + UUID key = UUID.fromString(split[1]); + playerKeyMap.put(player, key); + keyPlayerMap.put(key, player); + apiKeys.add(key); + } catch (IllegalArgumentException ignored) {} + }); + } + + public static void saveApiKeys(Path path) throws IOException { + final List lines = playerKeyMap.entrySet().stream().map(entry -> entry.getKey() + "," + entry.getValue()).toList(); + + Files.write(path.resolve(apiKeyFile), lines, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } + + public static @Nullable UUID getPlayerKey(UUID player) { + return playerKeyMap.get(player); + } + + public static @NotNull UUID createApiKey(UUID player) { + UUID newID = UUID.randomUUID(); + playerKeyMap.put(player, newID); + keyPlayerMap.put(newID, player); + apiKeys.add(newID); + return newID; + } + + public static void deletePlayerKey(UUID player) { + UUID key = playerKeyMap.remove(player); + if (key != null) { + keyPlayerMap.remove(key); + apiKeys.remove(key); + } + } + + public static UUID getKeyOwner(UUID key) { + return keyPlayerMap.get(key); + } + public static JsonObject generateNameUUIDJsonObject(String name, UUID uuid) { JsonObject jsonObject = new JsonObject(); jsonObject.addProperty("name", name); jsonObject.addProperty("uuid", uuid.toString()); return jsonObject; } + + public static JsonObject getShopObject(Shop shop) { + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("item", shop.getItem().getType().name()); + jsonObject.addProperty("amount", shop.getItem().getAmount()); + jsonObject.addProperty("type", shop.isSelling() ? "selling" : "buying"); + jsonObject.addProperty("stock", shop.isSelling() ? shop.getRemainingStock() : shop.getRemainingSpace()); + return jsonObject; + } }