diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..40fbc79 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +mvn clean package +scp target/mc-status-bot-1.0-SNAPSHOT-shaded.jar root@andrewlalis.com:/opt/mc-status-bot/mc-status-bot.jar diff --git a/src/main/java/com/andrewlalis/mc_status_bot/ServerBot.java b/src/main/java/com/andrewlalis/mc_status_bot/ServerBot.java index 80e03db..c50d6dd 100644 --- a/src/main/java/com/andrewlalis/mc_status_bot/ServerBot.java +++ b/src/main/java/com/andrewlalis/mc_status_bot/ServerBot.java @@ -8,27 +8,28 @@ 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.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; public class ServerBot implements Runnable { private final JDA jda; private final String serverIp; - private final long channelId; + private final short serverPort; private final ServerStatusFetcher serverStatusFetcher; - private int lastPlayerCount = -1; - private final Set lastPlayerNames = new HashSet<>(); - - public ServerBot(JDA jda, String serverIp, long channelId, ServerStatusFetcher serverStatusFetcher) { + public ServerBot(JDA jda, String serverIp, short serverPort, ServerStatusFetcher serverStatusFetcher) { this.jda = jda; this.serverIp = serverIp; - this.channelId = channelId; + this.serverPort = serverPort; this.serverStatusFetcher = serverStatusFetcher; } @@ -47,14 +48,22 @@ public class ServerBot implements Runnable { ArrayNode serversArray = configData.withArray("servers"); List bots = new ArrayList<>(serversArray.size()); ServerStatusFetcher serverStatusFetcher = new ServerStatusFetcher(); + var virtualPool = Executors.newVirtualThreadPerTaskExecutor(); 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(); + short serverPort = node.get("server-port").shortValue(); JDABuilder builder = JDABuilder.create(token, Collections.emptyList()); - builder.disableCache(CacheFlag.ACTIVITY, CacheFlag.VOICE_STATE); + builder.disableCache( + CacheFlag.ACTIVITY, CacheFlag.VOICE_STATE, CacheFlag.EMOJI, + CacheFlag.STICKER, CacheFlag.CLIENT_STATUS, CacheFlag.ONLINE_STATUS, + CacheFlag.SCHEDULED_EVENTS + ); + builder.setCallbackPool(virtualPool); + builder.setEventPool(virtualPool); + builder.setRateLimitElastic(virtualPool, true); JDA jda = builder.build(); - bots.add(new ServerBot(jda, serverIp, channelId, serverStatusFetcher)); + bots.add(new ServerBot(jda, serverIp, serverPort, serverStatusFetcher)); } return bots; } @@ -68,13 +77,13 @@ public class ServerBot implements Runnable { } while (true) { try { - ServerStatus status = serverStatusFetcher.fetch(serverIp); + ServerStatus status = serverStatusFetcher.fetchViaSocket(serverIp, serverPort); displayStatus(status); } catch (IOException e) { handleError(e); } try { - Thread.sleep(10000); + Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(System.err); Thread.currentThread().interrupt(); @@ -83,11 +92,10 @@ public class ServerBot implements Runnable { } 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); + Activity newActivity = Activity.customStatus(formatStatusText(status)); boolean shouldUpdate = onlineStatus != newOnlineStatus || activity == null || @@ -95,31 +103,17 @@ public class ServerBot implements Runnable { 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 String formatStatusText(ServerStatus status) { + if (status.playerNames().isEmpty()) { + return "0 players online."; } - } - - 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; + String playerNames = "\uD83C\uDFAE " + status.playerNames().stream().sorted().collect(Collectors.joining(", ")); + if (playerNames.length() > Activity.MAX_ACTIVITY_NAME_LENGTH) { + return playerNames.substring(0, Activity.MAX_ACTIVITY_NAME_LENGTH - 3) + "..."; + } + return playerNames; } private void handleError(IOException e) { @@ -128,7 +122,5 @@ public class ServerBot implements Runnable { 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/ServerStatusFetcher.java b/src/main/java/com/andrewlalis/mc_status_bot/ServerStatusFetcher.java index 14680af..f6ff504 100644 --- a/src/main/java/com/andrewlalis/mc_status_bot/ServerStatusFetcher.java +++ b/src/main/java/com/andrewlalis/mc_status_bot/ServerStatusFetcher.java @@ -1,48 +1,68 @@ package com.andrewlalis.mc_status_bot; -import com.fasterxml.jackson.databind.JsonNode; +import com.andrewlalis.mc_status_bot.server_prot.ServerProtocol; 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; +import java.io.*; +import java.net.Socket; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; 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()); + public ServerStatus fetchViaSocket(String ip, short port) throws IOException { +// long start = System.currentTimeMillis(); + try (var socket = new Socket(ip, port)) { + OutputStream sOut = socket.getOutputStream(); + InputStream sIn = socket.getInputStream(); + + // Send the handshake request. + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ServerProtocol.writeVarInt(out, 0x00); // Handshake packet id. + ServerProtocol.writeVarInt(out, 764); // Protocol version for 1.20.2. + ServerProtocol.writeString(out, ip); + new DataOutputStream(out).writeShort(25565); + ServerProtocol.writeVarInt(out, 1); // Next-state enum: 1 for Status. + + ServerProtocol.writeVarInt(sOut, out.size()); + sOut.write(out.toByteArray()); + sOut.flush(); + + // Immediately send status request. + out.reset(); + ServerProtocol.writeVarInt(out, 0x00); + ServerProtocol.writeVarInt(sOut, out.size()); + sOut.write(out.toByteArray()); + sOut.flush(); + + // Receive the status response. + int responsePacketSize = ServerProtocol.readVarInt(sIn); + byte[] packetIdAndData = new byte[responsePacketSize]; + int bytesRead = 0; + int attempts = 0; + while (bytesRead < responsePacketSize) { + bytesRead += sIn.read(packetIdAndData, bytesRead, packetIdAndData.length - bytesRead); + attempts++; + if (attempts > 100) break; } + if (bytesRead != responsePacketSize) throw new IOException("Couldn't read full packet. Read " + bytesRead + " instead of " + responsePacketSize); + ByteArrayInputStream in = new ByteArrayInputStream(packetIdAndData); + int packetId = ServerProtocol.readVarInt(in); + if (packetId != 0x00) throw new IOException("Received invalid packetId when receiving status response: " + packetId); + String jsonData = ServerProtocol.readString(in); +// long dur = System.currentTimeMillis() - start; +// System.out.println("Received server status in " + dur + " ms."); + ObjectNode obj = objectMapper.readValue(jsonData, ObjectNode.class); + return new ServerStatus( - data.get("players").get("online").asInt(), - data.get("players").get("max").asInt(), - playerNames + obj.get("players").get("online").asInt(), + obj.get("players").get("max").asInt(), + StreamSupport.stream(obj.get("players").withArray("sample").spliterator(), false) + .map(node -> node.get("name").asText()) + .collect(Collectors.toSet()) ); - } catch (IOException | InterruptedException e) { - throw new IOException("Failed to get server status.", e); } } } diff --git a/src/main/java/com/andrewlalis/mc_status_bot/server_prot/ServerProtocol.java b/src/main/java/com/andrewlalis/mc_status_bot/server_prot/ServerProtocol.java new file mode 100644 index 0000000..5b86b8c --- /dev/null +++ b/src/main/java/com/andrewlalis/mc_status_bot/server_prot/ServerProtocol.java @@ -0,0 +1,50 @@ +package com.andrewlalis.mc_status_bot.server_prot; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +public class ServerProtocol { + private static final int SEGMENT_BITS = 0x7F; + private static final int CONTINUE_BIT = 0x80; + + public static int readVarInt(InputStream in) throws IOException { + int value = 0; + int position = 0; + byte currentByte; + while (true) { + currentByte = (byte) in.read(); + value |= (currentByte & SEGMENT_BITS) << position; + if ((currentByte & CONTINUE_BIT) == 0) break; + position += 7; + if (position >= 32) throw new IOException("VarInt is too big."); + } + return value; + } + + public static void writeVarInt(OutputStream out, int value) throws IOException { + while (true) { + if ((value & ~SEGMENT_BITS) == 0) { + out.write(value); + return; + } + out.write((value & SEGMENT_BITS) | CONTINUE_BIT); + value >>>= 7; + } + } + + public static String readString(InputStream in) throws IOException { + int length = readVarInt(in); + byte[] data = new byte[length]; + int bytesRead = in.read(data); + if (bytesRead != length) throw new IOException("Couldn't read full string of length " + length); + return new String(data, StandardCharsets.UTF_8); + } + + public static void writeString(OutputStream out, String s) throws IOException { + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + writeVarInt(out, bytes.length); + out.write(bytes); + } +}