@@ -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"
|
||||
@@ -0,0 +1,7 @@
|
||||
target/
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
+28
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user