Changed to use internal mc protocol

This commit is contained in:
Andrew Lalis 2023-12-01 14:30:34 -05:00
parent 4efc1002a9
commit c05594256b
4 changed files with 139 additions and 73 deletions

4
deploy.sh Executable file
View File

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

View File

@ -8,27 +8,28 @@ import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder; import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.OnlineStatus; import net.dv8tion.jda.api.OnlineStatus;
import net.dv8tion.jda.api.entities.Activity; 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 net.dv8tion.jda.api.utils.cache.CacheFlag;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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 { public class ServerBot implements Runnable {
private final JDA jda; private final JDA jda;
private final String serverIp; private final String serverIp;
private final long channelId; private final short serverPort;
private final ServerStatusFetcher serverStatusFetcher; private final ServerStatusFetcher serverStatusFetcher;
private int lastPlayerCount = -1; public ServerBot(JDA jda, String serverIp, short serverPort, ServerStatusFetcher serverStatusFetcher) {
private final Set<String> lastPlayerNames = new HashSet<>();
public ServerBot(JDA jda, String serverIp, long channelId, ServerStatusFetcher serverStatusFetcher) {
this.jda = jda; this.jda = jda;
this.serverIp = serverIp; this.serverIp = serverIp;
this.channelId = channelId; this.serverPort = serverPort;
this.serverStatusFetcher = serverStatusFetcher; this.serverStatusFetcher = serverStatusFetcher;
} }
@ -47,14 +48,22 @@ public class ServerBot implements Runnable {
ArrayNode serversArray = configData.withArray("servers"); ArrayNode serversArray = configData.withArray("servers");
List<ServerBot> bots = new ArrayList<>(serversArray.size()); List<ServerBot> bots = new ArrayList<>(serversArray.size());
ServerStatusFetcher serverStatusFetcher = new ServerStatusFetcher(); ServerStatusFetcher serverStatusFetcher = new ServerStatusFetcher();
var virtualPool = Executors.newVirtualThreadPerTaskExecutor();
for (JsonNode node : serversArray) { for (JsonNode node : serversArray) {
String token = node.get("discord-token").asText(); String token = node.get("discord-token").asText();
String serverIp = node.get("server-ip").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()); 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(); JDA jda = builder.build();
bots.add(new ServerBot(jda, serverIp, channelId, serverStatusFetcher)); bots.add(new ServerBot(jda, serverIp, serverPort, serverStatusFetcher));
} }
return bots; return bots;
} }
@ -68,13 +77,13 @@ public class ServerBot implements Runnable {
} }
while (true) { while (true) {
try { try {
ServerStatus status = serverStatusFetcher.fetch(serverIp); ServerStatus status = serverStatusFetcher.fetchViaSocket(serverIp, serverPort);
displayStatus(status); displayStatus(status);
} catch (IOException e) { } catch (IOException e) {
handleError(e); handleError(e);
} }
try { try {
Thread.sleep(10000); Thread.sleep(5000);
} catch (InterruptedException e) { } catch (InterruptedException e) {
e.printStackTrace(System.err); e.printStackTrace(System.err);
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
@ -83,11 +92,10 @@ public class ServerBot implements Runnable {
} }
private void displayStatus(ServerStatus status) { private void displayStatus(ServerStatus status) {
String text = String.format("%d/%d players", status.playersOnline(), status.maxPlayers());
OnlineStatus onlineStatus = jda.getPresence().getStatus(); OnlineStatus onlineStatus = jda.getPresence().getStatus();
OnlineStatus newOnlineStatus = status.playersOnline() > 0 ? OnlineStatus.ONLINE : OnlineStatus.IDLE; OnlineStatus newOnlineStatus = status.playersOnline() > 0 ? OnlineStatus.ONLINE : OnlineStatus.IDLE;
Activity activity = jda.getPresence().getActivity(); Activity activity = jda.getPresence().getActivity();
Activity newActivity = Activity.customStatus(text); Activity newActivity = Activity.customStatus(formatStatusText(status));
boolean shouldUpdate = onlineStatus != newOnlineStatus || boolean shouldUpdate = onlineStatus != newOnlineStatus ||
activity == null || activity == null ||
@ -95,31 +103,17 @@ public class ServerBot implements Runnable {
if (shouldUpdate) { if (shouldUpdate) {
jda.getPresence().setPresence(newOnlineStatus, newActivity); jda.getPresence().setPresence(newOnlineStatus, newActivity);
} }
}
if (lastPlayerCount != -1 && lastPlayerCount != status.playersOnline()) { private String formatStatusText(ServerStatus status) {
final TextChannel channel = jda.getTextChannelById(channelId); if (status.playerNames().isEmpty()) {
for (String name : getPlayersJoined(status.playerNames())) { return "0 players online.";
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());
} }
} String playerNames = "\uD83C\uDFAE " + status.playerNames().stream().sorted().collect(Collectors.joining(", "));
if (playerNames.length() > Activity.MAX_ACTIVITY_NAME_LENGTH) {
private Set<String> getPlayersJoined(Set<String> current) { return playerNames.substring(0, Activity.MAX_ACTIVITY_NAME_LENGTH - 3) + "...";
Set<String> set = new HashSet<>(current); }
set.removeAll(lastPlayerNames); return playerNames;
return set;
}
private Set<String> getPlayersLeft(Set<String> current) {
Set<String> set = new HashSet<>(lastPlayerNames);
set.removeAll(current);
return set;
} }
private void handleError(IOException e) { private void handleError(IOException e) {
@ -128,7 +122,5 @@ public class ServerBot implements Runnable {
jda.getPresence().setActivity(Activity.customStatus("Error.")); jda.getPresence().setActivity(Activity.customStatus("Error."));
} }
e.printStackTrace(System.err); e.printStackTrace(System.err);
lastPlayerCount = -1;
lastPlayerNames.clear();
} }
} }

View File

@ -1,48 +1,68 @@
package com.andrewlalis.mc_status_bot; 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.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException; import java.io.*;
import java.net.URI; import java.net.Socket;
import java.net.http.HttpClient; import java.util.stream.Collectors;
import java.net.http.HttpRequest; import java.util.stream.StreamSupport;
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 { public class ServerStatusFetcher {
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
public ServerStatus fetch(String ip) throws IOException { public ServerStatus fetchViaSocket(String ip, short port) throws IOException {
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.mcsrvstat.us/3/" + ip)) // long start = System.currentTimeMillis();
.GET() try (var socket = new Socket(ip, port)) {
.timeout(Duration.ofSeconds(5)) OutputStream sOut = socket.getOutputStream();
.build(); InputStream sIn = socket.getInputStream();
try {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); // Send the handshake request.
if (response.statusCode() != 200) throw new IOException("Non-200 status code: " + response.statusCode()); ByteArrayOutputStream out = new ByteArrayOutputStream();
ObjectNode data = objectMapper.readValue(response.body(), ObjectNode.class); ServerProtocol.writeVarInt(out, 0x00); // Handshake packet id.
Set<String> playerNames = new HashSet<>(); ServerProtocol.writeVarInt(out, 764); // Protocol version for 1.20.2.
ArrayNode playersArray = data.get("players").withArray("list"); ServerProtocol.writeString(out, ip);
for (JsonNode node : playersArray) { new DataOutputStream(out).writeShort(25565);
playerNames.add(node.get("name").asText()); 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( return new ServerStatus(
data.get("players").get("online").asInt(), obj.get("players").get("online").asInt(),
data.get("players").get("max").asInt(), obj.get("players").get("max").asInt(),
playerNames 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);
} }
} }
} }

View File

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