diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..eaab9e6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,37 @@
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
+
+### Mac OS ###
+.DS_Store
+
+config.json
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..f7955e1
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,69 @@
+
+
+ 4.0.0
+
+ com.andrewlalis
+ mc-status-bot
+ 1.0-SNAPSHOT
+
+
+ 21
+ 21
+ UTF-8
+
+
+
+
+ net.dv8tion
+ JDA
+ 5.0.0-beta.18
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.16.0
+
+
+
+ com.fasterxml.jackson.core
+ jackson-core
+ 2.16.0
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ 3.3.0
+
+
+
+ true
+ com.andrewlalis.mc_status_bot.McStatusBot
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.5.0
+
+
+ package
+ shade
+
+ true
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/com/andrewlalis/mc_status_bot/McStatusBot.java b/src/main/java/com/andrewlalis/mc_status_bot/McStatusBot.java
new file mode 100644
index 0000000..f29a0d3
--- /dev/null
+++ b/src/main/java/com/andrewlalis/mc_status_bot/McStatusBot.java
@@ -0,0 +1,15 @@
+package com.andrewlalis.mc_status_bot;
+
+import java.nio.file.Path;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+public class McStatusBot {
+ public static void main(String[] args) throws Exception {
+ Executor executor = Executors.newVirtualThreadPerTaskExecutor();
+ for (ServerBot bot : ServerBot.read(Path.of("config.json"))) {
+ System.out.println("Starting server status bot for " + bot.getServerIp());
+ executor.execute(bot);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/andrewlalis/mc_status_bot/ServerBot.java b/src/main/java/com/andrewlalis/mc_status_bot/ServerBot.java
new file mode 100644
index 0000000..80e03db
--- /dev/null
+++ b/src/main/java/com/andrewlalis/mc_status_bot/ServerBot.java
@@ -0,0 +1,134 @@
+package com.andrewlalis.mc_status_bot;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.JDABuilder;
+import net.dv8tion.jda.api.OnlineStatus;
+import net.dv8tion.jda.api.entities.Activity;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
+import net.dv8tion.jda.api.utils.cache.CacheFlag;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+
+public class ServerBot implements Runnable {
+ private final JDA jda;
+ private final String serverIp;
+ private final long channelId;
+ private final ServerStatusFetcher serverStatusFetcher;
+
+ private int lastPlayerCount = -1;
+ private final Set lastPlayerNames = new HashSet<>();
+
+ public ServerBot(JDA jda, String serverIp, long channelId, ServerStatusFetcher serverStatusFetcher) {
+ this.jda = jda;
+ this.serverIp = serverIp;
+ this.channelId = channelId;
+ this.serverStatusFetcher = serverStatusFetcher;
+ }
+
+ public String getServerIp() {
+ return serverIp;
+ }
+
+ public static Collection read(Path jsonConfigFile) throws Exception {
+ if (Files.notExists(jsonConfigFile)) throw new IOException("File " + jsonConfigFile + " doesn't exist.");
+ ObjectMapper mapper = new ObjectMapper();
+ ObjectNode configData;
+ try (var in = Files.newInputStream(jsonConfigFile)) {
+ configData = mapper.readValue(in, ObjectNode.class);
+ }
+
+ ArrayNode serversArray = configData.withArray("servers");
+ List bots = new ArrayList<>(serversArray.size());
+ ServerStatusFetcher serverStatusFetcher = new ServerStatusFetcher();
+ for (JsonNode node : serversArray) {
+ String token = node.get("discord-token").asText();
+ String serverIp = node.get("server-ip").asText();
+ long channelId = node.get("channel-id").asLong();
+ JDABuilder builder = JDABuilder.create(token, Collections.emptyList());
+ builder.disableCache(CacheFlag.ACTIVITY, CacheFlag.VOICE_STATE);
+ JDA jda = builder.build();
+ bots.add(new ServerBot(jda, serverIp, channelId, serverStatusFetcher));
+ }
+ return bots;
+ }
+
+ @Override
+ public void run() {
+ try {
+ jda.awaitReady();
+ } catch (InterruptedException e) {
+ throw new RuntimeException("Interrupted while awaiting ready status.", e);
+ }
+ while (true) {
+ try {
+ ServerStatus status = serverStatusFetcher.fetch(serverIp);
+ displayStatus(status);
+ } catch (IOException e) {
+ handleError(e);
+ }
+ try {
+ Thread.sleep(10000);
+ } catch (InterruptedException e) {
+ e.printStackTrace(System.err);
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ private void displayStatus(ServerStatus status) {
+ String text = String.format("%d/%d players", status.playersOnline(), status.maxPlayers());
+ OnlineStatus onlineStatus = jda.getPresence().getStatus();
+ OnlineStatus newOnlineStatus = status.playersOnline() > 0 ? OnlineStatus.ONLINE : OnlineStatus.IDLE;
+ Activity activity = jda.getPresence().getActivity();
+ Activity newActivity = Activity.customStatus(text);
+
+ boolean shouldUpdate = onlineStatus != newOnlineStatus ||
+ activity == null ||
+ !activity.getName().equals(newActivity.getName());
+ if (shouldUpdate) {
+ jda.getPresence().setPresence(newOnlineStatus, newActivity);
+ }
+
+ if (lastPlayerCount != -1 && lastPlayerCount != status.playersOnline()) {
+ final TextChannel channel = jda.getTextChannelById(channelId);
+ for (String name : getPlayersJoined(status.playerNames())) {
+ channel.sendMessage(name + " joined the server.").queue();
+ }
+ for (String name : getPlayersLeft(status.playerNames())) {
+ channel.sendMessage(name + " left the server.").queue();
+ }
+ lastPlayerCount = status.playersOnline();
+ lastPlayerNames.clear();
+ lastPlayerNames.addAll(status.playerNames());
+ }
+ }
+
+ private Set getPlayersJoined(Set current) {
+ Set set = new HashSet<>(current);
+ set.removeAll(lastPlayerNames);
+ return set;
+ }
+
+ private Set getPlayersLeft(Set current) {
+ Set set = new HashSet<>(lastPlayerNames);
+ set.removeAll(current);
+ return set;
+ }
+
+ private void handleError(IOException e) {
+ if (jda.getPresence().getStatus() != OnlineStatus.DO_NOT_DISTURB) {
+ jda.getPresence().setStatus(OnlineStatus.DO_NOT_DISTURB);
+ jda.getPresence().setActivity(Activity.customStatus("Error."));
+ }
+ e.printStackTrace(System.err);
+ lastPlayerCount = -1;
+ lastPlayerNames.clear();
+ }
+}
diff --git a/src/main/java/com/andrewlalis/mc_status_bot/ServerStatus.java b/src/main/java/com/andrewlalis/mc_status_bot/ServerStatus.java
new file mode 100644
index 0000000..bdc5063
--- /dev/null
+++ b/src/main/java/com/andrewlalis/mc_status_bot/ServerStatus.java
@@ -0,0 +1,9 @@
+package com.andrewlalis.mc_status_bot;
+
+import java.util.Set;
+
+public record ServerStatus(
+ int playersOnline,
+ int maxPlayers,
+ Set playerNames
+) {}
diff --git a/src/main/java/com/andrewlalis/mc_status_bot/ServerStatusFetcher.java b/src/main/java/com/andrewlalis/mc_status_bot/ServerStatusFetcher.java
new file mode 100644
index 0000000..14680af
--- /dev/null
+++ b/src/main/java/com/andrewlalis/mc_status_bot/ServerStatusFetcher.java
@@ -0,0 +1,48 @@
+package com.andrewlalis.mc_status_bot;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.Executors;
+
+public class ServerStatusFetcher {
+ private final HttpClient client = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(3))
+ .executor(Executors.newVirtualThreadPerTaskExecutor())
+ .build();
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ public ServerStatus fetch(String ip) throws IOException {
+ HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.mcsrvstat.us/3/" + ip))
+ .GET()
+ .timeout(Duration.ofSeconds(5))
+ .build();
+ try {
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() != 200) throw new IOException("Non-200 status code: " + response.statusCode());
+ ObjectNode data = objectMapper.readValue(response.body(), ObjectNode.class);
+ Set playerNames = new HashSet<>();
+ ArrayNode playersArray = data.get("players").withArray("list");
+ for (JsonNode node : playersArray) {
+ playerNames.add(node.get("name").asText());
+ }
+ return new ServerStatus(
+ data.get("players").get("online").asInt(),
+ data.get("players").get("max").asInt(),
+ playerNames
+ );
+ } catch (IOException | InterruptedException e) {
+ throw new IOException("Failed to get server status.", e);
+ }
+ }
+}