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
@@ -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