commit bdd9b4bb8b1e5570ec5189386bf8aa86f549e4f1 Author: Dylan Garvis Date: Thu Jan 29 21:24:23 2026 -0500 feat: add NATS chat sync plugin diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..96691ee --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,119 @@ +name: Build + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22.14.0 + + - name: Install Node dependencies + run: npm install + + - name: Lint commits + run: | + if [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then + FROM=$(git rev-list --max-parents=0 HEAD) + else + FROM="${{ github.event.before }}" + fi + npx commitlint --from "$FROM" --to "${{ github.sha }}" + + - name: Release version + env: + GIT_AUTHOR_NAME: "automation" + GIT_AUTHOR_EMAIL: "automation@local" + GIT_COMMITTER_NAME: "automation" + GIT_COMMITTER_EMAIL: "automation@local" + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: npx semantic-release + + - name: Build + run: mvn -B package + + - name: Name build artifact + run: | + if [ -f release-version.txt ]; then + VERSION=$(tr -d '\n' < release-version.txt) + else + VERSION=dev-${GITHUB_SHA:0:7} + fi + JAR_PATH=$(ls target/*.jar | head -n 1) + cp "$JAR_PATH" "target/spigot-chat-sync-${VERSION}.jar" + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: spigot-chat-sync-jar + path: target/spigot-chat-sync-*.jar + + - name: Create release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_API: ${{ vars.GITEA_API_URL }} + GITEA_SERVER_URL: ${{ vars.GITEA_SERVER_URL }} + GITHUB_SERVER_URL: ${{ github.server_url }} + run: | + TAG=$(git describe --tags --exact-match 2>/dev/null || true) + if [ -z "$TAG" ]; then + echo "No release tag on HEAD; skipping release." + exit 0 + fi + if [ -z "$GITEA_API" ]; then + if [ -n "$GITEA_SERVER_URL" ]; then + GITEA_API="${GITEA_SERVER_URL}/api/v1" + elif [ -n "$GITHUB_SERVER_URL" ]; then + GITEA_API="${GITHUB_SERVER_URL}/api/v1" + else + echo "GITEA_API_URL is not set and server URL is unavailable." + exit 1 + fi + fi + VERSION=${TAG#v} + JAR_PATH=$(ls target/spigot-chat-sync-*.jar | head -n 1) + RELEASE_RESPONSE=$(curl -sS -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${GITEA_API}/repos/${GITHUB_REPOSITORY}/releases" \ + -d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}") + RELEASE_ID=$(python3 - <<'PY' + import json + import sys + + try: + payload = json.load(sys.stdin) + except json.JSONDecodeError: + payload = {} + + value = payload.get("id", "") + sys.stdout.write(str(value) if value is not None else "") + PY + ) + if [ -z "$RELEASE_ID" ]; then + echo "Failed to create release." + echo "$RELEASE_RESPONSE" + exit 1 + fi + curl -sS -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${JAR_PATH}" \ + "${GITEA_API}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=spigot-chat-sync-${VERSION}.jar" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..60d9496 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +target/ +.classpath +.project +.settings/ +.idea/ +*.iml +.DS_Store diff --git a/.releaserc b/.releaserc new file mode 100644 index 0000000..ef36c9f --- /dev/null +++ b/.releaserc @@ -0,0 +1,28 @@ +{ + "branches": ["main"], + "tagFormat": "v${version}", + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "releaseRules": [ + { "type": "chore", "scope": "deps", "release": "patch" } + ] + } + ], + "@semantic-release/release-notes-generator", + [ + "@semantic-release/exec", + { + "prepareCmd": "node -e \"const fs=require('fs');fs.writeFileSync('release-version.txt', process.env.RELEASE_VERSION + '\\n');\"" + } + ], + [ + "@semantic-release/git", + { + "assets": ["release-version.txt"], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ] + ] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e20bbb0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM maven:3.9.9-eclipse-temurin-21 + +WORKDIR /workspace + +COPY pom.xml /workspace/ + +RUN mvn -q -e package || true + +COPY src /workspace/src + +RUN mvn -q -e package diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b61f736 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +IMAGE_NAME ?= prometheus-spigot-build +CONTAINER_NAME ?= prometheus-spigot-build + +.PHONY: build docker-build docker-jar clean-docker + +build: + mvn package + +docker-build: + docker build -t $(IMAGE_NAME) . + +docker-jar: docker-build + docker rm -f $(CONTAINER_NAME) 2>/dev/null || true + docker create --name $(CONTAINER_NAME) $(IMAGE_NAME) + docker cp $(CONTAINER_NAME):/workspace/target ./target + docker rm -f $(CONTAINER_NAME) + +clean-docker: + docker rm -f $(CONTAINER_NAME) 2>/dev/null || true diff --git a/package.json b/package.json new file mode 100644 index 0000000..9753169 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "spigot-chat-sync", + "private": true, + "devDependencies": { + "@commitlint/cli": "^20.0.0", + "@commitlint/config-conventional": "^20.0.0", + "@semantic-release/exec": "^7.0.0", + "@semantic-release/git": "^10.0.1", + "semantic-release": "^25.0.0" + }, + "scripts": { + "semantic-release": "semantic-release" + } +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..0d21d02 --- /dev/null +++ b/pom.xml @@ -0,0 +1,72 @@ + + 4.0.0 + + com.spigotchatsync + spigot-chat-sync + 1.0.0 + Spigot Chat Sync + Sync chat and death events via NATS + + + 21 + UTF-8 + + + + + spigot-repo + https://hub.spigotmc.org/nexus/content/repositories/snapshots/ + + + + + + org.spigotmc + spigot-api + 1.21.1-R0.1-SNAPSHOT + provided + + + io.nats + jnats + 2.20.5 + + + com.google.code.gson + gson + 2.10.1 + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + + + io.nats + com.spigotchatsync.shaded.io.nats + + + com.google.gson + com.spigotchatsync.shaded.com.google.gson + + + + + + + + + diff --git a/src/main/java/com/spigotchatsync/ChatSyncMessage.java b/src/main/java/com/spigotchatsync/ChatSyncMessage.java new file mode 100644 index 0000000..39570b9 --- /dev/null +++ b/src/main/java/com/spigotchatsync/ChatSyncMessage.java @@ -0,0 +1,79 @@ +package com.spigotchatsync; + +import com.google.gson.annotations.SerializedName; +import java.time.Instant; + +public final class ChatSyncMessage { + @SerializedName("type") + private String type; + + @SerializedName("server_id") + private String serverId; + + @SerializedName("server") + private String server; + + @SerializedName("player_uuid") + private String playerUuid; + + @SerializedName("player_name") + private String playerName; + + @SerializedName("message") + private String message; + + @SerializedName("timestamp") + private long timestamp; + + public static ChatSyncMessage forChat(String serverId, String server, String playerUuid, String playerName, String message) { + ChatSyncMessage payload = new ChatSyncMessage(); + payload.type = "chat"; + payload.serverId = serverId; + payload.server = server; + payload.playerUuid = playerUuid; + payload.playerName = playerName; + payload.message = message; + payload.timestamp = Instant.now().getEpochSecond(); + return payload; + } + + public static ChatSyncMessage forDeath(String serverId, String server, String playerUuid, String playerName, String message) { + ChatSyncMessage payload = new ChatSyncMessage(); + payload.type = "death"; + payload.serverId = serverId; + payload.server = server; + payload.playerUuid = playerUuid; + payload.playerName = playerName; + payload.message = message; + payload.timestamp = Instant.now().getEpochSecond(); + return payload; + } + + public String getType() { + return type; + } + + public String getServerId() { + return serverId; + } + + public String getServer() { + return server; + } + + public String getPlayerUuid() { + return playerUuid; + } + + public String getPlayerName() { + return playerName; + } + + public String getMessage() { + return message; + } + + public long getTimestamp() { + return timestamp; + } +} diff --git a/src/main/java/com/spigotchatsync/ChatSyncPlugin.java b/src/main/java/com/spigotchatsync/ChatSyncPlugin.java new file mode 100644 index 0000000..9ae586e --- /dev/null +++ b/src/main/java/com/spigotchatsync/ChatSyncPlugin.java @@ -0,0 +1,122 @@ +package com.spigotchatsync; + +import java.util.Objects; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.plugin.java.JavaPlugin; + +public final class ChatSyncPlugin extends JavaPlugin implements Listener { + private NatsBridge natsBridge; + private String serverId; + private String serverName; + private String natsUrl; + private String chatSubject; + private String deathSubject; + private String chatFormat; + private String deathFormat; + + @Override + public void onEnable() { + saveDefaultConfig(); + if (!loadSettings()) { + return; + } + + natsBridge = new NatsBridge(getLogger(), natsUrl); + if (!natsBridge.connect(this::handleIncoming, chatSubject, deathSubject)) { + getLogger().severe("Failed to connect to NATS; disabling plugin."); + Bukkit.getPluginManager().disablePlugin(this); + return; + } + + Bukkit.getPluginManager().registerEvents(this, this); + } + + @Override + public void onDisable() { + if (natsBridge != null) { + natsBridge.shutdown(); + } + } + + private boolean loadSettings() { + FileConfiguration config = getConfig(); + serverId = config.getString("server.id", "").trim(); + serverName = config.getString("server.name", "").trim(); + if (serverId.isEmpty()) { + getLogger().severe("server.id is required in config.yml"); + Bukkit.getPluginManager().disablePlugin(this); + return false; + } + if (serverName.isEmpty()) { + serverName = serverId; + getLogger().warning("server.name missing; defaulting to server.id"); + } + + chatSubject = config.getString("subjects.chat", "mc.chat.sync"); + deathSubject = config.getString("subjects.death", "mc.death.sync"); + chatFormat = config.getString("format.chat", "[{server}] {player}: {message}"); + deathFormat = config.getString("format.death", "[{server}] {message}"); + natsUrl = config.getString("nats.url", "nats://localhost:4222").trim(); + if (natsUrl.isEmpty()) { + getLogger().severe("nats.url is required in config.yml"); + Bukkit.getPluginManager().disablePlugin(this); + return false; + } + return true; + } + + @EventHandler + @SuppressWarnings("deprecation") + public void onChat(AsyncPlayerChatEvent event) { + if (event.isCancelled() || natsBridge == null) { + return; + } + + ChatSyncMessage message = ChatSyncMessage.forChat( + serverId, + serverName, + event.getPlayer().getUniqueId().toString(), + event.getPlayer().getName(), + event.getMessage() + ); + natsBridge.publish(chatSubject, message); + } + + @EventHandler + public void onDeath(PlayerDeathEvent event) { + if (natsBridge == null) { + return; + } + String deathMessage = event.getDeathMessage(); + if (deathMessage == null || deathMessage.isBlank()) { + return; + } + + ChatSyncMessage message = ChatSyncMessage.forDeath( + serverId, + serverName, + event.getEntity().getUniqueId().toString(), + event.getEntity().getName(), + deathMessage + ); + natsBridge.publish(deathSubject, message); + } + + private void handleIncoming(String subject, ChatSyncMessage message) { + if (message == null || Objects.equals(serverId, message.getServerId())) { + return; + } + + String format = subject.equals(chatSubject) ? chatFormat : deathFormat; + String formatted = MessageFormatter.format(format, message); + String broadcastMessage = ChatColor.translateAlternateColorCodes('&', formatted); + + Bukkit.getScheduler().runTask(this, () -> Bukkit.broadcastMessage(broadcastMessage)); + } +} diff --git a/src/main/java/com/spigotchatsync/MessageFormatter.java b/src/main/java/com/spigotchatsync/MessageFormatter.java new file mode 100644 index 0000000..b9968ad --- /dev/null +++ b/src/main/java/com/spigotchatsync/MessageFormatter.java @@ -0,0 +1,34 @@ +package com.spigotchatsync; + +import java.time.Instant; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +public final class MessageFormatter { + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm"); + + private MessageFormatter() { + } + + public static String format(String template, ChatSyncMessage message) { + String output = template; + output = output.replace("{server}", nullSafe(message.getServer())); + output = output.replace("{player}", nullSafe(message.getPlayerName())); + output = output.replace("{message}", nullSafe(message.getMessage())); + output = output.replace("{time}", formatTime(message.getTimestamp())); + return output; + } + + private static String nullSafe(String value) { + return value == null ? "" : value; + } + + private static String formatTime(long epochSeconds) { + if (epochSeconds <= 0) { + return ""; + } + LocalTime time = Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.systemDefault()).toLocalTime(); + return TIME_FORMAT.format(time); + } +} diff --git a/src/main/java/com/spigotchatsync/NatsBridge.java b/src/main/java/com/spigotchatsync/NatsBridge.java new file mode 100644 index 0000000..b49e605 --- /dev/null +++ b/src/main/java/com/spigotchatsync/NatsBridge.java @@ -0,0 +1,84 @@ +package com.spigotchatsync; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import io.nats.client.Connection; +import io.nats.client.Dispatcher; +import io.nats.client.Message; +import io.nats.client.Nats; +import io.nats.client.Options; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.TimeoutException; +import java.util.function.BiConsumer; +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class NatsBridge { + private final Logger logger; + private final String url; + private final Gson gson = new Gson(); + private Connection connection; + private Dispatcher chatDispatcher; + private Dispatcher deathDispatcher; + + public NatsBridge(Logger logger, String url) { + this.logger = Objects.requireNonNull(logger, "logger"); + this.url = Objects.requireNonNull(url, "url"); + } + + public boolean connect(BiConsumer handler, String chatSubject, String deathSubject) { + try { + Options options = new Options.Builder().server(url).build(); + connection = Nats.connect(options); + } catch (IOException | InterruptedException ex) { + logger.log(Level.SEVERE, "Failed to connect to NATS", ex); + return false; + } + + chatDispatcher = connection.createDispatcher(message -> handleMessage(handler, chatSubject, message)); + chatDispatcher.subscribe(chatSubject); + + deathDispatcher = connection.createDispatcher(message -> handleMessage(handler, deathSubject, message)); + deathDispatcher.subscribe(deathSubject); + + logger.info("Connected to NATS at " + url); + return true; + } + + public void publish(String subject, ChatSyncMessage message) { + if (connection == null || connection.getStatus() != Connection.Status.CONNECTED) { + logger.warning("NATS is not connected; skipping publish to " + subject); + return; + } + String json = gson.toJson(message); + connection.publish(subject, json.getBytes(StandardCharsets.UTF_8)); + } + + public void shutdown() { + if (connection == null) { + return; + } + try { + connection.drain(Duration.ofSeconds(2)); + connection.close(); + } catch (InterruptedException ex) { + logger.log(Level.WARNING, "Interrupted while shutting down NATS connection", ex); + Thread.currentThread().interrupt(); + } catch (TimeoutException ex) { + logger.log(Level.WARNING, "Timed out while draining NATS connection", ex); + } + } + + private void handleMessage(BiConsumer handler, String subject, Message message) { + String json = new String(message.getData(), StandardCharsets.UTF_8); + try { + ChatSyncMessage payload = gson.fromJson(json, ChatSyncMessage.class); + handler.accept(subject, payload); + } catch (JsonSyntaxException ex) { + logger.log(Level.WARNING, "Invalid JSON from NATS on " + subject + ": " + json, ex); + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..2224ea3 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,14 @@ +server: + id: "server-1" + name: "Server 1" + +nats: + url: "nats://localhost:4222" + +subjects: + chat: "mc.chat.sync" + death: "mc.death.sync" + +format: + chat: "&7[&b{server}&7] &f{player}&7: &f{message}" + death: "&7[&b{server}&7] &f{message}" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..4e24e1e --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,5 @@ +name: SpigotChatSync +main: com.spigotchatsync.ChatSyncPlugin +version: 1.0.0 +api-version: 1.21 +description: Sync chat and death events via NATS