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); + } + } +}