feat: add NATS chat sync plugin
Build / build (push) Failing after 57s

This commit is contained in:
dmg
2026-01-29 21:24:23 -05:00
commit bdd9b4bb8b
13 changed files with 608 additions and 0 deletions
+119
View File
@@ -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"
+7
View File
@@ -0,0 +1,7 @@
target/
.classpath
.project
.settings/
.idea/
*.iml
.DS_Store
+28
View File
@@ -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}"
}
]
]
}
+11
View File
@@ -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
+19
View File
@@ -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
+14
View File
@@ -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"
}
}
+72
View File
@@ -0,0 +1,72 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.spigotchatsync</groupId>
<artifactId>spigot-chat-sync</artifactId>
<version>1.0.0</version>
<name>Spigot Chat Sync</name>
<description>Sync chat and death events via NATS</description>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>spigot-repo</id>
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.spigotmc</groupId>
<artifactId>spigot-api</artifactId>
<version>1.21.1-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.nats</groupId>
<artifactId>jnats</artifactId>
<version>2.20.5</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<relocations>
<relocation>
<pattern>io.nats</pattern>
<shadedPattern>com.spigotchatsync.shaded.io.nats</shadedPattern>
</relocation>
<relocation>
<pattern>com.google.gson</pattern>
<shadedPattern>com.spigotchatsync.shaded.com.google.gson</shadedPattern>
</relocation>
</relocations>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -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;
}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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<String, ChatSyncMessage> 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<String, ChatSyncMessage> 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);
}
}
}
+14
View File
@@ -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}"
+5
View File
@@ -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