package com.mineroster; import com.google.gson.*; import org.bukkit.Bukkit; import org.bukkit.command.*; import org.bukkit.entity.Player; import org.bukkit.event.*; import org.bukkit.event.player.*; import org.bukkit.plugin.java.JavaPlugin; import java.net.URI; import java.net.http.*; import java.nio.file.*; import java.nio.file.attribute.PosixFilePermission; import java.time.*; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicBoolean; public final class MineRosterPlugin extends JavaPlugin implements Listener { private static final String ORIGIN="https://mineroster.com"; private final Gson gson=new Gson(); private final List pending=new ArrayList<>(); private final AtomicBoolean flight=new AtomicBoolean(false); private final ExecutorService network=Executors.newSingleThreadExecutor(r->{Thread t=new Thread(r,"MineRoster-network");t.setDaemon(true);return t;}); private final HttpClient http=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.NEVER).build(); private volatile String token=""; private Path queueFile; @Override public void onEnable(){ saveDefaultConfig();token=getConfig().getString("token",""); queueFile=getDataFolder().toPath().resolve("pending-events.json"); try{if(Files.exists(queueFile)&&Files.size(queueFile)<=1048576){JsonArray data=JsonParser.parseString(Files.readString(queueFile)).getAsJsonArray();for(JsonElement event:data){if(pending.size()>=2000)break;pending.add(event.getAsJsonObject());}}}catch(Exception e){getLogger().warning("Could not read pending events; existing file retained.");} privateFile(getDataFolder().toPath().resolve("config.yml")); Bukkit.getPluginManager().registerEvents(this,this); Bukkit.getScheduler().runTaskTimer(this,()->sendSnapshot(),100L,600L); getLogger().info("Ready. Link using the server console: mineroster link "); } @Override public void onDisable(){persist();network.shutdownNow();} private void privateFile(Path path){try{Files.setPosixFilePermissions(path,Set.of(PosixFilePermission.OWNER_READ,PosixFilePermission.OWNER_WRITE));}catch(Exception ignored){}} private synchronized void persist(){ try{Files.createDirectories(getDataFolder().toPath());Path temp=queueFile.resolveSibling("pending-events.tmp");Files.writeString(temp,gson.toJson(pending));privateFile(temp);Files.move(temp,queueFile,StandardCopyOption.REPLACE_EXISTING);privateFile(queueFile);}catch(Exception e){getLogger().warning("Could not persist pending events.");} } private JsonObject identity(Player player){ UUID id=player.getUniqueId();String edition="java"; try{Class apiClass=Class.forName("org.geysermc.floodgate.api.FloodgateApi");Object api=apiClass.getMethod("getInstance").invoke(null);Object bedrock=apiClass.getMethod("getPlayer",UUID.class).invoke(api,id); if(bedrock!=null){Class playerClass=Class.forName("org.geysermc.floodgate.api.player.FloodgatePlayer");String xuid=(String)playerClass.getMethod("getXuid").invoke(bedrock);id=new UUID(0,Long.parseUnsignedLong(xuid));edition="bedrock";} }catch(ClassNotFoundException ignored){}catch(Exception e){getLogger().warning("Bedrock identity unavailable; retaining server UUID.");} JsonObject result=new JsonObject();result.addProperty("id",id.toString());result.addProperty("name",player.getName());result.addProperty("edition",edition);return result; } private synchronized void event(Player player,String kind){ if(token.isEmpty())return; JsonObject event=identity(player);event.addProperty("eventId",UUID.randomUUID().toString());event.addProperty("kind",kind);event.addProperty("at",Instant.now().toString()); if(pending.size()>=2000){pending.remove(0);getLogger().warning("Pending activity limit reached; oldest event dropped.");} pending.add(event);persist(); } @EventHandler(priority=EventPriority.MONITOR) public void join(PlayerJoinEvent event){event(event.getPlayer(),"join");} @EventHandler(priority=EventPriority.MONITOR) public void leave(PlayerQuitEvent event){event(event.getPlayer(),"leave");} private synchronized void sendSnapshot(){ if(token.isEmpty()||!flight.compareAndSet(false,true))return; try{ pending.removeIf(event->{try{return Instant.parse(event.get("at").getAsString()).isBefore(Instant.now().minusSeconds(86400));}catch(Exception e){return true;}}); JsonArray players=new JsonArray();for(Player player:Bukkit.getOnlinePlayers()){if(players.size()>=500)break;players.add(identity(player));} List events=new ArrayList<>(pending.subList(0,Math.min(200,pending.size()))); JsonObject body=new JsonObject();body.addProperty("version",1);body.addProperty("sentAt",Instant.now().toString());body.add("players",players);body.add("events",gson.toJsonTree(events)); String payload=gson.toJson(body),key=token; if(payload.length()>131000){flight.set(false);getLogger().warning("Activity payload exceeds limit; batch deferred.");return;} network.submit(()->{try{ HttpResponse response=post("/api/plugin/heartbeat",payload,key); if(response.statusCode()==200){synchronized(this){Set delivered=new HashSet<>();for(JsonObject event:events)delivered.add(event.get("eventId").getAsString());pending.removeIf(event->delivered.contains(event.get("eventId").getAsString()));persist();}} else if(response.statusCode()==401){token="";getLogger().warning("Integration revoked. Relink from your MineRoster account.");} else getLogger().warning("Activity delivery deferred (HTTP "+response.statusCode()+")."); }catch(Exception e){getLogger().warning("Activity delivery unavailable; retrying next heartbeat.");}finally{flight.set(false);}}); }catch(Exception e){flight.set(false);getLogger().warning("Could not prepare activity snapshot.");} } private HttpResponse post(String path,String payload,String key)throws Exception{ HttpRequest.Builder request=HttpRequest.newBuilder(URI.create(ORIGIN+path)).timeout(Duration.ofSeconds(10)).header("Content-Type","application/json").POST(HttpRequest.BodyPublishers.ofString(payload)); if(!key.isEmpty())request.header("Authorization","Bearer "+key); return http.send(request.build(),HttpResponse.BodyHandlers.ofString()); } @Override public boolean onCommand(CommandSender sender,Command command,String label,String[] args){ if(!(sender instanceof ConsoleCommandSender)&&!(sender instanceof RemoteConsoleCommandSender)){sender.sendMessage("Use the server console only.");return true;} if(args.length!=2||!args[0].equalsIgnoreCase("link")||!args[1].matches("[A-Za-z0-9_-]{43}")){sender.sendMessage("Usage: mineroster link ");return true;} if(!flight.compareAndSet(false,true)){sender.sendMessage("A request is in progress; try again shortly.");return true;} JsonObject body=new JsonObject();body.addProperty("code",args[1]);String payload=gson.toJson(body); network.submit(()->{try{ HttpResponse response=post("/api/plugin/pair",payload,""); if(response.statusCode()!=200||response.body().length()>4096){getLogger().warning("Link failed. Generate a fresh pairing code in your account.");return;} JsonObject result=JsonParser.parseString(response.body()).getAsJsonObject();String key=result.get("token").getAsString(),id=result.get("serverId").getAsString(); if(!key.matches("[A-Za-z0-9_-]{43}")){getLogger().warning("Invalid pairing response.");return;}UUID.fromString(id); Bukkit.getScheduler().runTask(this,()->{getConfig().set("token",key);getConfig().set("serverId",id);saveConfig();privateFile(getDataFolder().toPath().resolve("config.yml"));synchronized(this){pending.clear();persist();}token=key;getLogger().info("Linked successfully. Player sharing is controlled in your listing settings.");}); }catch(Exception e){getLogger().warning("Link unavailable. Generate a fresh code if necessary.");}finally{flight.set(false);}}); return true; } }