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;