diff --git a/docs/discord.md b/docs/discord.md index 860ccde..18f3fd4 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -1,5 +1,5 @@ # Discord Endpoint -Accessed at https://api.earthmc.net/v3/aurora/discord?query= +Accessed at https://api.earthmc.net/v3/aurora/discord Determine a player's Discord ID from their Minecraft UUID and vice versa using DiscordSRV's link feature. The player needs to have linked their account beforehand (`/discord link` in game). diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..4423ba3 --- /dev/null +++ b/docs/events.md @@ -0,0 +1,290 @@ +# Server-Sent Events (SSE) Endpoint +Accessed at https://api.earthmc.net/v3/events + +> **Server-Sent Events** (SSEs) are a simple, one-way communication method where a server can push real-time updates to clients over HTTP. Unlike WebSockets, SSEs use a persistent HTTP connection, making them ideal for continuous data streams, such as live notifications. +> [[MDN Reference]](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) + +You can easily connect to the event stream from your terminal using + +````bash +curl -H "Accept:text/event-steam" "https://api.earthmc.net/events" +```` + +If the connection was successful, you will receive a `open` event from the server. + +--- + +### Example usage (in JavaScript) +Use the endpoint as an [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) to receive live events. +```javascript +const sse = new EventSource('https://api.earthmc.net/events'); + +/* + * This will listen only for events + * similar to the following: + * + * event: NewNation + * data: Event data (see below) + */ + +sse.addEventListener('NewNation', (e) => { + console.log(e.data); +}); +``` +
Example `NewNation` event +```json5 +{ + "event": "NewNation", + + "data": { + "nation": { + "name": "Egypt", + "uuid": "e82b84fb-d3fd-4065-a43b-013d53416162" + }, + + "king": { + "name": "Lumpeeh", + "uuid": "a03f71f9-625e-419f-9d16-0e5ab50414e4" + }, + + "timestamp": "1651592417137" + } +} +``` + +--- + +### Event data +Below is a list of all the events and the JSON structure of their `data` field. +
*(Each `data` object additionally carries a UNIX timestamp)* + +**Player Connections** +- PlayerJoin (aurora) +```yaml +{ + player: { + name: str + uuid: str + } +} +``` +- PlayerQuit (aurora) +```yaml +{ + player: { + name: str + uuid: str + } +} +``` + +
**Newday** + +- NewDay +```yaml +{ + fallenTowns: str[] // Names + fallenNations: str[] // Names +} +``` + +
**Nation** + +- NewNation +```yaml +{ + nation: { + name: str + uuid: str + } + king: { + name: str + uuid: str + } +} +``` +- DeleteNation +```yaml +{ + nation: { + name: str + uuid: str + } + king: { + name: str + uuid: str + } +} +``` +- RenameNation +```yaml +{ + nation: { + name: str + uuid: str + } + oldName: str +} +``` +- NationKingChange +```yaml +{ + nation: { + name: str + uuid: str + } + newKing: { + name: str + uuid: str + } + oldKing: { + name: str + uuid: str + } + isCapitalChange: bool + + # if isCapitalChange is true: + newCapital: { + name: str + uuid: str + } + oldCapital: { + name: str + uuid: str + } +} +``` +- NationAddTown +```yaml +{ + nation: { + name: str + uuid: str + } + town: { + name: str + uuid: str + } +} +``` +- NationRemoveTown +```yaml +{ + nation: { + name: str + uuid: str + } + town: { + name: str + uuid: str + } +} +``` + +
**Town** + +- NewTown +```yaml +{ + town: { + name: str + uuid: str + } + mayor: { + name: str + uuid: str + } +} +``` +- DeleteTown +```yaml +{ + town: { + name: str + uuid: str + } + mayor: { + name: str + uuid: str + } +} +``` +- RenameTown +```yaml +{ + town: { + name: str + uuid: str + } + oldName: str +} +``` +- TownMayorChanged +```yaml +{ + town: { + name: str + uuid: str + } + newMayor: { + name: str + uuid: str + } + oldMayor: { + name: str + uuid: str + } +} +``` +- TownRuined +```yaml +{ + town: { + name: str + uuid: str + } + oldMayor: { + name: str + uuid: str + } +} +``` +- TownReclaimed +```yaml +{ + town: { + name: str + uuid: str + } + newMayor: { + name: str + uuid: str + } +} +``` +- TownAddResident +```yaml +{ + town: { + name: str + uuid: str + } + resident: { + name: str + uuid: str + } +} +``` +- TownRemoveResident +```yaml +{ + town: { + name: str + uuid: str + } + resident: { + name: str + uuid: str + } +} +``` \ No newline at end of file diff --git a/src/main/java/net/earthmc/emcapi/EMCAPI.java b/src/main/java/net/earthmc/emcapi/EMCAPI.java index 71c89d4..68a1970 100644 --- a/src/main/java/net/earthmc/emcapi/EMCAPI.java +++ b/src/main/java/net/earthmc/emcapi/EMCAPI.java @@ -4,7 +4,10 @@ import io.javalin.util.JavalinLogger; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import net.earthmc.emcapi.listeners.PlayerConnectionListener; +import net.earthmc.emcapi.listeners.TownyListeners; import net.earthmc.emcapi.manager.EndpointManager; +import net.earthmc.emcapi.manager.SSEManager; import net.milkbowl.vault.economy.Economy; import org.bukkit.plugin.RegisteredServiceProvider; import org.bukkit.plugin.java.JavaPlugin; @@ -44,13 +47,19 @@ public void onEnable() { if (getConfig().getBoolean("behaviour.enable_legacy_endpoints")) endpointManager.loadLegacyEndpoints(); + + SSEManager sseManager = new SSEManager(javalin); + sseManager.loadSSE(); + + getServer().getPluginManager().registerEvents(new TownyListeners(), this); + getServer().getPluginManager().registerEvents(new PlayerConnectionListener(), this); } @Override public void onDisable() { javalin.stop(); } - + private void initialiseJavalin() { javalin = Javalin.create(config -> { config.jetty.modifyServer(server -> { diff --git a/src/main/java/net/earthmc/emcapi/listeners/PlayerConnectionListener.java b/src/main/java/net/earthmc/emcapi/listeners/PlayerConnectionListener.java new file mode 100644 index 0000000..1c20211 --- /dev/null +++ b/src/main/java/net/earthmc/emcapi/listeners/PlayerConnectionListener.java @@ -0,0 +1,32 @@ +package net.earthmc.emcapi.listeners; + +import com.google.gson.JsonObject; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +import net.earthmc.emcapi.util.EndpointUtils; +import net.earthmc.emcapi.manager.SSEManager; + + +public class PlayerConnectionListener implements Listener { + + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + JsonObject message = new JsonObject(); + message.add("player", EndpointUtils.generateNameUUIDJsonObject(player.getName(), player.getUniqueId())); + SSEManager.broadcastMessage("PlayerJoin", message); + } + + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) { + Player player = event.getPlayer(); + JsonObject message = new JsonObject(); + message.add("player", EndpointUtils.generateNameUUIDJsonObject(player.getName(), player.getUniqueId())); + SSEManager.broadcastMessage("PlayerQuit", message); + } + +} diff --git a/src/main/java/net/earthmc/emcapi/listeners/TownyListeners.java b/src/main/java/net/earthmc/emcapi/listeners/TownyListeners.java new file mode 100644 index 0000000..b1598ba --- /dev/null +++ b/src/main/java/net/earthmc/emcapi/listeners/TownyListeners.java @@ -0,0 +1,148 @@ +package net.earthmc.emcapi.listeners; + +import com.google.gson.JsonObject; +import com.palmergames.bukkit.towny.event.*; +import com.palmergames.bukkit.towny.event.nation.*; +import com.palmergames.bukkit.towny.event.town.*; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; + +import net.earthmc.emcapi.manager.SSEManager; +import net.earthmc.emcapi.util.EndpointUtils; +import net.earthmc.emcapi.util.JSONUtil; + + +public class TownyListeners implements Listener { + + @EventHandler + public void onNewDay(NewDayEvent event) { + JsonObject message = new JsonObject(); + message.add("fallenTowns", JSONUtil.getJsonArrayFromStringList(event.getFallenTowns())); + message.add("fallenNations", JSONUtil.getJsonArrayFromStringList(event.getFallenNations())); + SSEManager.broadcastMessage("NewDay", message); + } + + + + @EventHandler + public void onNewNation(NewNationEvent event) { + JsonObject message = new JsonObject(); + message.add("nation", EndpointUtils.getNationJsonObject(event.getNation())); + message.add("king", EndpointUtils.getResidentJsonObject(event.getNation().getKing())); + SSEManager.broadcastMessage("NewNation", message); + } + + @EventHandler + public void onDeleteNation(DeleteNationEvent event) { + JsonObject message = new JsonObject(); + message.add("nation", EndpointUtils.generateNameUUIDJsonObject(event.getNationName(), event.getNationUUID())); + message.add("king", EndpointUtils.getResidentJsonObject(event.getLeader())); + SSEManager.broadcastMessage("DeleteNation", message); + } + + @EventHandler + public void onRenameNation(RenameNationEvent event) { + JsonObject message = new JsonObject(); + message.add("nation", EndpointUtils.getNationJsonObject(event.getNation())); + message.addProperty("oldName", event.getOldName()); + SSEManager.broadcastMessage("RenameNation", message); + } + + @EventHandler + 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())); + } + SSEManager.broadcastMessage("NationKingChange", message); + } + + @EventHandler + public void onNationAddTown(NationAddTownEvent event) { + JsonObject message = new JsonObject(); + message.add("nation", EndpointUtils.getNationJsonObject(event.getNation())); + message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); + SSEManager.broadcastMessage("NationAddTown", message); + } + + @EventHandler + public void onNationRemoveTown(NationRemoveTownEvent event) { + JsonObject message = new JsonObject(); + message.add("nation", EndpointUtils.getNationJsonObject(event.getNation())); + message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); + SSEManager.broadcastMessage("NationRemoveTown", message); + } + + + + @EventHandler + public void onNewTown(NewTownEvent event) { + JsonObject message = new JsonObject(); + message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); + message.add("mayor", EndpointUtils.getResidentJsonObject(event.getTown().getMayor())); + SSEManager.broadcastMessage("NewTown", message); + } + + @EventHandler + 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())); + SSEManager.broadcastMessage("DeleteTown", message); + } + + @EventHandler + public void onRenameTown(RenameTownEvent event) { + JsonObject message = new JsonObject(); + message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); + message.addProperty("oldName", event.getOldName()); + SSEManager.broadcastMessage("RenameTown", message); + } + + @EventHandler + 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())); + SSEManager.broadcastMessage("TownMayorChange", message); + } + + @EventHandler + public void onTownRuined(TownPreRuinedEvent event) { + JsonObject message = new JsonObject(); + message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); + message.add("oldMayor", EndpointUtils.getResidentJsonObject(event.getTown().getMayor())); + SSEManager.broadcastMessage("TownRuined", message); + } + + @EventHandler + public void onTownReclaimed(TownReclaimedEvent event) { + JsonObject message = new JsonObject(); + message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); + message.add("newMayor", EndpointUtils.getResidentJsonObject(event.getResident())); + SSEManager.broadcastMessage("TownReclaimed", message); + } + + @EventHandler + public void onTownAddResident(TownAddResidentEvent event) { + JsonObject message = new JsonObject(); + message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); + message.add("resident", EndpointUtils.getResidentJsonObject(event.getResident())); + SSEManager.broadcastMessage("TownAddResident", message); + } + + @EventHandler + public void onTownRemoveResident(TownRemoveResidentEvent event) { + JsonObject message = new JsonObject(); + message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); + message.add("resident", EndpointUtils.getResidentJsonObject(event.getResident())); + SSEManager.broadcastMessage("TownRemoveResident", message); + } + +} diff --git a/src/main/java/net/earthmc/emcapi/manager/SSEManager.java b/src/main/java/net/earthmc/emcapi/manager/SSEManager.java new file mode 100644 index 0000000..7cb150d --- /dev/null +++ b/src/main/java/net/earthmc/emcapi/manager/SSEManager.java @@ -0,0 +1,42 @@ +package net.earthmc.emcapi.manager; + +import com.google.gson.JsonObject; +import io.javalin.Javalin; +import io.javalin.http.sse.SseClient; + +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; + + +public class SSEManager { + + private final Javalin javalin; + private final static Queue clients = new ConcurrentLinkedQueue<>(); + + public SSEManager(Javalin javalin) { + this.javalin = javalin; + } + + public void loadSSE() { + javalin.sse("v3/events", client -> { + client.keepAlive(); + client.sendEvent("open", "Connected to the EarthMC API."); + client.onClose(() -> clients.remove(client)); + clients.add(client); + }); + } + + public static void broadcastMessage(String event, JsonObject data) { + int timestamp = Math.toIntExact(System.currentTimeMillis() / 1000); + data.addProperty("timestamp", timestamp); + String message = data.toString(); + + CompletableFuture.allOf( + clients.stream() + .map(client -> CompletableFuture.runAsync(() -> client.sendEvent(event, message))) + .toArray(CompletableFuture[]::new) + ); + } +} + diff --git a/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java b/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java index d93d98a..0072297 100644 --- a/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java +++ b/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java @@ -16,6 +16,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.UUID; public class EndpointUtils { @@ -163,4 +164,11 @@ public static JsonObject getQuarterObject(Quarter quarter) { return jsonObject; } + + 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;