MongoDB Management Bot

This article is expected to be about a 8 minute read.

Table of Contents

MongoDB_Management_Bot_Logo

Background

Back in 2016-2017, when I had commenced the conversion of my Minecraft server into a full-fledged network (complete with a custom website, a geographically redundant MongoDB replica set database, custom maps and gamemodes, and much more) my interest in automation and integration with external services started to grow. Specifically, I wanted to integrate my network (which will be given its own project page on this site in the future) and my MongoDB database with Twitter for automated updates and statistics in the form of tweets and eventually with Discord once it became the “go-to” platform for the online PC gaming community.

My initial idea was to hook into Twitter4J, a Java library used for the Twitter API from each server instance on the network. This was possible, and would seem favorable for live statistics as opposed to database metrics (information polled from a database is subject to a time delay for a multitude of reasons). However, I reasoned that I did not want the additional load on each server instance to impact gameplay performance (even in an asynchronous context with the Twitter API) and that I would ideally want to be able to execute administrative operations against the database in the future - executing these kinds of operations from multiple endpoints would be the antithesis of “ideal” and redundant by design.

Thus, my design transitioned into a single standalone Java program exported as an executable .jar file in order to isolate all administrative and management operations from any of the individual Minecraft servers on the network. In the beginning, this only included the aforementioned automated tweets, such as online player counts and ban announcements. Eventually, I ended up writing several other database management tasks and integrating with Discord4J, a Java wrapper for the Discord Bot API.

Implementation

As any Java developer knows, adding and shading dependencies is made trivial with Maven. The developer is empowered by the pom.xml file, which lists out all external repositories and the scope of each dependency in an easy to understand markdown format. To add Twitter4J to my .jar file, I used the following:

<!-- Twitter4J -->
<dependency>
  <groupId>org.twitter4j</groupId>
  <artifactId>twitter4j-core</artifactId>
  <version>4.0.7</version>
  <scope>compile</scope>
</dependency>

Where:

  • <version> - the version of Twitter4J to be used in the project
  • <scope> - the dependency’s scope (compile tells Maven to export Twitter4J into the project during compilation)

To add support for Maven compilation and shading, I added the maven-compiler-plugin and maven-shade-plugin plugins to my pom.xml file’s <plugins> section:

<plugins>
  <!-- Maven Compile -->
  <plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.8.1</version>
    <configuration>
      <fork>true</fork>
      <source>1.8</source>
      <target>1.8</target>
    </configuration>
  </plugin>
  <!-- Maven Shade -->
  <plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-shade-plugin</artifactId>
    <version>3.2.4</version>
    <configuration>
      <dependencyReducedPomLocation>${project.build.directory}/dependency-reduced-pom.xml</dependencyReducedPomLocation>
    </configuration>
    <executions>
      <execution>
        <phase>package</phase>
        <goals>
          <goal>shade</goal>
        </goals>
      </execution>
    </executions>
  </plugin>
</plugins>

I wrote my own library class Twitter4J.java to handle OAuth registration (keys, tokens, and secrets have been omitted for obvious security reasons):

package me.foncused.network.lib.foncused;

import me.foncused.network.util.BashUtil;
import me.foncused.network.util.DebugUtil;
import twitter4j.Twitter;
import twitter4j.TwitterException;
import twitter4j.TwitterFactory;
import twitter4j.conf.ConfigurationBuilder;

public class Twitter4J {

  private Twitter twitter;
  private String previous;

  public void registerTwitter() {
    try {
      if(this.twitter == null) {
        final ConfigurationBuilder cb = new ConfigurationBuilder();
        cb.setDebugEnabled(true)
            .setOAuthConsumerKey("")
            .setOAuthConsumerSecret("")
            .setOAuthAccessToken("")
            .setOAuthAccessTokenSecret("");
        this.twitter = new TwitterFactory(cb.build()).getInstance();
      }
    } catch(final Exception e) {
      DebugUtil.printStackTrace(e);
    }
  }

  public boolean tweet(final String status) {
    if(this.twitter != null) {
      if(status.equals(previous)) {
        System.out.println(BashUtil.cyan() + "[Twitter] " + BashUtil.reset() + "Skipping tweet, same as previous");
        return false;
      }
      try {
        this.twitter.updateStatus(status);
        this.previous = status;
        System.out.println(BashUtil.cyan() + "[Twitter] " + BashUtil.reset() + status);
      } catch(final TwitterException e) {
        DebugUtil.printStackTrace(e);
        return false;
      }
    } else {
      System.out.println(BashUtil.cyan() + "[Twitter] " + BashUtil.reset() + "Failed to send tweet, twitter is null");
      return false;
    }
    return true;
  }

}

To instantiate Twitter4J and access its API:

private Twitter4J twitter;

public void registerTwitter() {
  this.twitter = new Twitter4J();
  this.twitter.registerTwitter();
}

public Twitter4J getTwitter() {
  return this.twitter;
}

MongoDB_Management_Bot_Twitter4J_API_Access

From here, I had the ability to automate any Twitter account that I owned, provided I register valid OAuth credentials. I created a new Twitter account for my network and named it “FN_Robot” - shorthand for Foncused Network Robot - and generated the OAuth information for my registerTwitter() method.

Similarly, to add support for MongoDB integration, I hooked into my already existing custom API. Essentially, since the MongoDB Java driver classes were already exported into my Network.jar plugin which runs on all my servers, all I needed to do was export Network.jar as a dependency into FN_Robot.jar to access my database code in the standalone application. My Network.jar already handled the Maven dependency for MongoDB:

<!-- Mongo -->
<dependency>
  <groupId>org.mongodb</groupId>
  <artifactId>mongodb-driver</artifactId>
  <version>3.12.7</version>
  <scope>compile</scope>
</dependency>

To register the database connection, I used the following:

void registerDatabase() {
  try {
    this.logger.log("Registering Database...", LoggerType.NORMAL);
    final int port = Integer.parseInt(NetworkMessage.MONGO_PORT.toString());
    this.db = new NetworkDatabase(
      NetworkMessage.MONGO_DB.toString(),
      Arrays.asList(
        new ServerAddress(NetworkMessage.MONGO_0.toString(), port),
        new ServerAddress(NetworkMessage.MONGO_1.toString(), port),
        new ServerAddress(NetworkMessage.MONGO_2.toString(), port),
        new ServerAddress(NetworkMessage.MONGO_3.toString(), port),
        new ServerAddress(NetworkMessage.MONGO_4.toString(), port)
      )
    );
    if((!(this.db.setup())) || (!(this.db.ping()))) {
      final String msg = "Failed to register database. Info: Error with NetworkDatabase#setup or NetworkDatabase#ping.";
      this.logger.log(msg, LoggerType.ERROR);
      this.discord.sendMessage("fnrobot", msg, DiscordLogLevel.ERROR);
      System.exit(1);
    }
    this.logger.log("Registered Database", LoggerType.NORMAL);
  } catch(final Exception e) {
    final String msg = e.getMessage();
    this.logger.log(msg, LoggerType.ERROR);
    this.discord.sendMessage("fnrobot", "Failed to register database. Info: " + msg, DiscordLogLevel.ERROR);
    e.printStackTrace();
    System.exit(1);
  }
}

The setup() method instantiates the MongoClient by being provided with the SSL context information and authentication credentials (I used a Let’s Encrypt certificate to encrypt all network traffic ingressing and egressing the database):

public boolean setup() {
  if(this.client == null) {
    SSLContext context = null;
    final char[] password = Password.SSL_KEY.toString().toCharArray();
    try {
      // KeyStore
      final KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
      final FileInputStream keyStream = new FileInputStream(Path.KEYSTORE.toString());
      keyStore.load(keyStream, password);
      keyStream.close();
      final KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
      kmf.init(keyStore, password);
      // TrustStore
      final KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
      final FileInputStream trustStream = new FileInputStream(Path.TRUSTSTORE.toString());
      trustStore.load(trustStream, password);
      trustStream.close();
      final TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
      tmf.init(trustStore);
      // Context
      context = SSLContext.getInstance("TLS");
      context.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
    } catch(final Exception e) {
      DebugUtil.printStackTrace(e);
    }
    if(context == null) {
      return false;
    }
    final int timeout = 3000;
    this.client = new MongoClient(
      this.addresses,
      MongoCredential.createScramSha1Credential(
        "foncused",
        this.database,
        Password.MONGO_AUTH_KEY.toString().toCharArray()
      ),
      MongoClientOptions.builder()
        .connectTimeout(timeout)
        .readPreference(ReadPreference.primary())
        .requiredReplicaSetName("rs0")
        .serverSelectionTimeout(timeout)
        .sslContext(context)
        .sslEnabled(true)
        .sslInvalidHostNameAllowed(true)
        .build()
    );
    try {
      this.client.getAddress();
    } catch(final Exception e) {
      return false;
    }
  }
  if(this.db == null) {
    this.db = this.client.getDB(this.database);
  }
  this.achievementsCollection = new AchievementsCollection(this.db, "Achievements");
  this.bannedIpsCollection = new BannedIpsCollection(this.db, "BannedIps");
  this.bansCollection = new BansCollection(this.db, "Bans");
  this.banwavesCollection = new BanwavesCollection(this.db, "Banwaves");
  this.boostersCollection = new BoostersCollection(this.db, "Boosters");
  this.commandsCollection = new CommandsCollection(this.db, "Commands");
  this.cosmeticsCollection = new CosmeticsCollection(this.db, "Cosmetics");
  this.crateKeysCollection = new CrateKeysCollection(this.db, "CrateKeys");
  this.cratesCollection = new CratesCollection(this.db, "Crates");
  this.friendsCollection = new FriendsCollection(this.db, "Friends");
  this.hologramsCollection = new HologramsCollection(this.db, "Holograms");
  this.itemsCollection = new ItemsCollection(this.db, "Items");
  this.mapsCollection = new MapsCollection(this.db, "Maps");
  this.mapViewsCollection = new MapViewsCollection(this.db, "MapViews");
  this.minigamesCollection = new MinigamesCollection(this.db, "Minigames");
  this.murdersCollection = new MurdersCollection(this.db, "Murders");
  this.mutesCollection = new MutesCollection(this.db, "Mutes");
  this.parkoursCollection = new ParkoursCollection(this.db, "Parkours");
  this.particlesCollection = new ParticlesCollection(this.db, "Particles");
  this.playersCollection = new PlayersCollection(this.db, "Players");
  this.reportsCollection = new ReportsCollection(this.db, "Reports");
  this.serversCollection = new ServersCollection(this.db, "Servers");
  this.signsCollection = new SignsCollection(this.db, "Signs");
  this.survivalsCollection = new SurvivalsCollection(this.db, "Survivals");
  return this.db != null;
}

It was at this point that I needed to determine which administrative functions that FN_Robot.jar would be responsible for handling. Automated tweets would already be covered - to meet my initial goal mentioned above - but what else? After giving it some thought, I generated the following list of ideas:

  • runBanwaveAddTask - iterate over player data and add entries to the banwave
  • runBanwaveBanTask - ban entries added to the banwave
  • runCleanTask - iterate over the entire database and clean out “stale” data
  • runFriendTask - iterate over friend data and clean out unlinked entries
  • runMarkTask - reset blackmarks of all players to 0
  • runStatusTask - tweet status updates
  • runVoteTask - reset votes of all players to 0 and tweet monthly voters

Running each of these in parallel - each task on its own thread, that is - would prove monumental for this application. Indeed, by running each in an asynchronous context, I was able to schedule and delay execution for each task on an individual basis. One approach to handle this design involves implementation of a ScheduledThreadPoolExecutor, which accepts a pool size parameter in its constructor. To avoid manual adjustment of this parameter, I used reflection to count the number of methods in the class; for every additional method added to the class, the pool size would be automatically incremented. I also used dependency injection to share dependencies with my Bot.java class, in alignment with proper OOP design:

private final NetworkDatabase db;
private final Discord discord;
private final GatewayDiscordClient gateway;
private final Twitter4J twitter;
private final BotLogger logger;
private final ScheduledThreadPoolExecutor executor;
private final int cooldownTime;
private final Set<String> queue;
private final AtomicBoolean cooldown;

public Bot(final FNRobot bot) {
  this.db = bot.getDatabase();
  this.discord = bot.getDiscord();
  this.gateway = bot.getGateway();
  this.twitter = bot.getTwitter();
  this.logger = bot.getLogger();
  this.executor = new ScheduledThreadPoolExecutor(Bot.class.getMethods().length + 1);
  this.cooldownTime = 5 * 1000 * 60;
  this.queue = ConcurrentHashMap.newKeySet();
  this.cooldown = new AtomicBoolean(false);
}

After instantiating executor in the constructor, it was time to schedule tasks and make use of the allocated pool size. Each of the following subsections below details specifics for each task, created and executed by calling this.executor.scheduleAtFixedRate() which schedules a repeating task at a set period, with an initial delay, and at a certain TimeUnit - exactly the same concept implemented into BukkitRunnable for those familiar with the Bukkit API.

runBanwaveAddTask

In online gaming, and especially in Minecraft, cheating is a widespread concern. Fortunately, there are a number of ways to detect cheaters - depending on the type of game, these include statistical analysis, threshold violations, neural network algorithms and other heuristics. Advancements in artificial intelligence have assisted developers with automated pattern recognition - if certain patterns match, conclusions can be drawn.

Unfortunately, a “cat and mouse” game ensues as cheaters are well aware of such advancements. Cheaters understand how certain anti-cheat systems operate and how to evade detection through trial and error. For instance, in the context of Minecraft, if a cheater uses a modified client to gain an unfair advantage in combat and PVP, and the anti-cheat system detects this activity and bans them from the server, the cheater is able to learn and adjust their client software to better evade the anti-cheat’s detection capabilities.

Security through obscurity, despite its limited use-case, offers one solution to this problem; instead of configuring an anti-cheat system to ban cheaters immediately upon detection (and thus providing the cheater with new countermeasures), the preferable option is to flag the cheater’s activity as malicious but delay any immediate action taken until a later time. In this way, the cheater has no possible way of knowing which detections were violated, how many times they were detected, or for which specific reason(s) they were punished assuming that no reason is given by the server owner.

I did not take the time to develop my own anti-cheat system due to the overhead this would place upon me, but I did purchase one regularly maintained and updated through community input and capable of flagging malicious activity. I installed this system on all my servers and modified the configuration to prefer prevention and detection over action and punishment. Then, in FN_Robot.jar, I split the logic into two tasks, as mentioned above - one for adding players to the banwave, and one for banning players added to the banwave. The runBanwaveAddTask() task checks player violations every 30 minutes and adds them to the banwave if they have been suspected of cheating 10 or more times within a given time frame:

public void runBanwaveAddTask() {
  this.executor.scheduleAtFixedRate(() -> {
    try {
      final LoggerType type = LoggerType.BANWAVE_ADD;
      while(this.cooldown.get()) {
        final String msg = "Banwave-Add task waiting...";
        this.logger.log(msg, type);
        this.discord.sendMessage("fnrobot", msg, DiscordLogLevel.INFO);
        Thread.sleep(this.cooldownTime);
      }
      this.cooldown();
      final String msg1 = ">> BANWAVE-ADD HEARTBEAT <<";
      this.logger.log(msg1, type);
      this.discord.sendMessage("fnrobot", msg1, DiscordLogLevel.INFO);
      final PlayersCollection playersCollection = this.db.getPlayersCollection();
      final BanwavesCollection banwavesCollection = this.db.getBanwavesCollection();
      int added = 0;
      for(final UUID uuid : playersCollection.getAllUniqueIds()) {
        final String u = uuid.toString();
        if(playersCollection.getMarks(uuid) >= 10 &&
          (!(this.db.getBannedIpsCollection().contains("IP", playersCollection.getIp(uuid))))
          && (!(this.db.getBansCollection().contains("UUID", u)))
          && (!(banwavesCollection.contains("UUID", u)))
        ) {
          final String msg = "Adding: " + playersCollection.getName(uuid);
          this.logger.log(msg, type);
          this.discord.sendMessage("fnrobot", msg, DiscordLogLevel.INFO);
          banwavesCollection.createPlayer(uuid);
          added++;
        }
      }
      final String msg2 = "Done adding players to the banwave (added: " + added + ")";
      this.logger.log(msg2, type);
      this.discord.sendMessage("fnrobot", msg2, DiscordLogLevel.INFO);
    } catch(final Exception e) {
      final String msg = e.getMessage() + ", " + e.getCause();
      this.logger.log(msg, LoggerType.ERROR);
      this.discord.sendMessage("fnrobot", "Error in Bot#runBanwaveAddTask, details: " + msg, DiscordLogLevel.ERROR);
      e.printStackTrace();
    }
  }, 0, 30, TimeUnit.MINUTES);
}

runBanwaveBanTask

Once players have been added to the banwave, as mentioned above, they are then banned by the runBanwaveBanTask() task which executes once every week:

public void runBanwaveBanTask() {
  this.executor.scheduleAtFixedRate(() -> {
    try {
      final LoggerType type = LoggerType.BANWAVE_BAN;
      while(this.cooldown.get()) {
        final String msg = "Banwave-Ban task waiting...";
        this.logger.log(msg, type);
        this.discord.sendMessage("fnrobot", msg, DiscordLogLevel.INFO);
        Thread.sleep(this.cooldownTime);
      }
      this.cooldown();
      final String msg1 = ">> BANWAVE-BAN HEARTBEAT <<";
      this.logger.log(msg1, type);
      this.discord.sendMessage("fnrobot", msg1, DiscordLogLevel.INFO);
      final BanwavesCollection collection = this.db.getBanwavesCollection();
      final Set<UUID> uuids = collection.getAllUniqueIds();
      if(uuids.size() == 0) {
        final String msg2 = "There were 0 players listed in the banwave";
        this.logger.log(msg2, type);
        this.discord.sendMessage("fnrobot", msg2, DiscordLogLevel.INFO);
        return;
      }
      uuids.forEach(uuid -> {
        final String player = collection.getName(uuid);
        final String ip = this.db.getPlayersCollection().getIp(uuid);
        this.db.getBansCollection().createPlayer(uuid, "Banwave.");
        this.db.getBannedIpsCollection().createIp(ip, player);
        collection.removeOne("UUID", uuid.toString());
        this.logger.log("Banned player: " + player, type);
        this.logger.log("Banned IP: " + ip, type);
      });
      this.tweet(uuids.size()
        + " players have been automagically banned from Foncused Network on the most recent banwave.", type);
    } catch(final Exception e) {
      final String msg = e.getMessage() + ", " + e.getCause();
      this.logger.log(msg, LoggerType.ERROR);
      this.discord.sendMessage("fnrobot", "Error in Bot#runBanwaveBanTask, details: " + msg, DiscordLogLevel.ERROR);
      e.printStackTrace();
    }
  }, 0, 7, TimeUnit.DAYS);
}

runCleanTask

Unless a database is limited in some capacity, it will grow indefinitely over time. This is definitely true in the context of Minecraft; players join once and may not return, so their data is never used but is stored forever nevertheless. In foresight of storing this “stale” data forever, I decided to have FN_Robot.jar clean my database for me. Specifically, I decided not to store player data if it matched one of two scenarios:

  1. If the player has not joined the server in over 365 days and is not a donor
  2. If the player is banned and has not made a successful appeal in 30 days of being banned

Both of these scenarios assume that the player will never rejoin or express interest in rejoining the server - therefore, their data is subject to deletion to save on resources (search times, disk space, etc.) which could be used for better purposes. To delete stale data, I wrote the following code which executes once every week:

public void runCleanTask() {
  this.executor.scheduleAtFixedRate(() -> {
    try {
      final LoggerType type = LoggerType.CLEAN;
      while(this.cooldown.get()) {
        final String msg = "Clean task waiting...";
        this.logger.log(msg, type);
        this.discord.sendMessage("fnrobot", msg, DiscordLogLevel.INFO);
        Thread.sleep(this.cooldownTime);
      }
      this.cooldown();
      final String msg1 = ">> CLEAN HEARTBEAT <<";
      this.logger.log(msg1, type);
      this.discord.sendMessage("fnrobot", msg1, DiscordLogLevel.INFO);
      final PlayersCollection collection = this.db.getPlayersCollection();
      final Set<UUID> uuids = collection.getAllUniqueIds();
      int checked = 0, cleaned = 0;
      for(final UUID uuid : uuids) {
        final String name = collection.getName(uuid);
        final int difference = TimeUtil.getDifference(collection.getSeen(uuid), Format.DATE.toString(), 86400000);
        final int length = name.length();
        final StringBuilder sb = new StringBuilder().append("Checking: ");
        sb.append(name);
        if(length >= 14) {
          sb.append("\t\t");
        } else if(length >= 6) {
          sb.append("\t\t\t");
        } else {
          sb.append("\t\t\t\t");
        }
        sb.append("(");
        sb.append(difference);
        sb.append(")");
        // If banned with no successful appeals to rejoin within 30 days OR if have not joined in over 365 days and are not donors or above
        if((difference > 30 &&
          (this.db.getBansCollection().contains("UUID", uuid.toString()) ||
            this.db.getBannedIpsCollection().contains("IP", collection.getIp(uuid)))) ||
          (difference > 365 &&
            (!(RankUtil.isDonor(collection.getRank(uuid)))))
        ) {
          sb.append("\t[CLEANED]");
          DatabaseUtil.removePlayerFromDatabase(uuid);
          cleaned++;
        } else {
          sb.append("\t[OK]");
        }
        System.out.println(sb.toString());
        checked++;
        Thread.sleep(500);
      }
      final String msg2 = "Done cleaning database (checked: " + checked + ", cleaned: " + cleaned + ")";
      this.logger.log(msg2, type);
      this.discord.sendMessage("fnrobot", msg2, DiscordLogLevel.INFO);
    } catch(final Exception e) {
      final String msg = e.getMessage();
      this.logger.log(msg, LoggerType.ERROR);
      this.discord.sendMessage("fnrobot", "Error in Bot#runCleanTask, details: " + msg, DiscordLogLevel.ERROR);
      e.printStackTrace();
    }
  }, 0, 7, TimeUnit.DAYS);
}

runFriendTask

Similar to runCleanTask(), I wrote a method named runFriendTask() to delete stale friend requests on my server. Friend entries are stored in pairs of player UUIDs - one for the requester and one for the accepter. If two UUIDs are paired together in a single database entry, they are considered “friends” on my server (by using the /friend command in-game). It occurred to me that at some point in the future, I could have thousands of unaccepted entries stored in the database (i.e. unanswered friend requests). To preemptively solve this problem, I wrote the following code to execute once every week:

public void runFriendTask() {
  this.executor.scheduleAtFixedRate(() -> {
    try {
      final LoggerType type = LoggerType.FRIEND;
      while(this.cooldown.get()) {
        final String msg = "Friend task waiting...";
        this.logger.log(msg, type);
        this.discord.sendMessage("fnrobot", msg, DiscordLogLevel.INFO);
        Thread.sleep(this.cooldownTime);
      }
      this.cooldown();
      final String msg1 = ">> FRIEND HEARTBEAT <<";
      this.logger.log(msg1, type);
      this.discord.sendMessage("fnrobot", msg1, DiscordLogLevel.INFO);
      final FriendsCollection collection = this.db.getFriendsCollection();
      collection.getAllIds().stream().filter(id -> (!(collection.isAccepted(id)))).forEach(id -> {
        final String msg2 = "Removing friend pair: " + id + "(" + collection.getRequesterUniqueId(id) + " - "
          + collection.getRequesterName(id) + ", " + collection.getAccepterUniqueId(id) + " - "
          + collection.getAccepterName(id) + ")";
        this.logger.log(msg2, type);
        this.discord.sendMessage("fnrobot", msg2, DiscordLogLevel.INFO);
        collection.removeOne("ID", id);
      });
      final String msg2 = "Done cleaning up friends";
      this.logger.log(msg2, type);
      this.discord.sendMessage("fnrobot", msg2, DiscordLogLevel.INFO);
    } catch(final Exception e) {
      final String msg = e.getMessage() + ", " + e.getCause();
      this.logger.log(msg, LoggerType.ERROR);
      this.discord.sendMessage("fnrobot", "Error in Bot#runFriendTask, details: " + msg, DiscordLogLevel.ERROR);
      e.printStackTrace();
    }
  }, 7, 7, TimeUnit.DAYS);
}

runMarkTask

As mentioned above, I wrote two tasks to handle the anti-cheat banwave logic by checking player violations over a given time frame. I wrote runMarkTask() to reset this time frame at a given interval by resetting violation counts once every 14 days:

public void runMarkTask() {
  this.executor.scheduleAtFixedRate(() -> {
    try {
      final LoggerType type = LoggerType.MARK;
      while(this.cooldown.get()) {
        final String msg = "Mark task waiting...";
        this.logger.log(msg, type);
        this.discord.sendMessage("fnrobot", msg, DiscordLogLevel.INFO);
        Thread.sleep(this.cooldownTime);
      }
      this.cooldown();
      final String msg1 = ">> MARK HEARTBEAT <<";
      this.logger.log(msg1, type);
      this.discord.sendMessage("fnrobot", msg1, DiscordLogLevel.INFO);
      final PlayersCollection collection = this.db.getPlayersCollection();
      final Set<UUID> uuids = collection.getAllUniqueIds();
      uuids.forEach(uuid -> {
        final int marks = collection.getMarks(uuid);
        final String u = uuid.toString();
        if(marks != 0
          && (!(this.db.getBanwavesCollection().contains("UUID", u)))
          && (!(this.db.getBansCollection().contains("UUID", u)))
          && (!(this.db.getBannedIpsCollection().contains("IP", collection.getIp(uuid))))) {
            final String name = collection.getName(uuid);
            this.logger.log("Resetting marks for player: " + name + ", " + marks + " -> 0", type);
            if(collection.getStatus(uuid)) {
              this.db.getCommandsCollection()
                .createCommand(collection.getServer(uuid), "setmarks " + name + " 0");
            }
            collection.setMarks(uuid, 0);
        }
      });
      final String msg2 = "Done resetting marks (reset: " + uuids.size() + ")";
      this.logger.log(msg2, type);
      this.discord.sendMessage("fnrobot", msg2, DiscordLogLevel.INFO);
    } catch(final Exception e) {
      final String msg = e.getMessage() + ", " + e.getCause();
      this.logger.log(msg, LoggerType.ERROR);
      this.discord.sendMessage("fnrobot", "Error in Bot#runMarkTask, details: " + msg, DiscordLogLevel.ERROR);
      e.printStackTrace();
    }
  }, 14, 14, TimeUnit.DAYS);
}

In combination with the banwave tasks, this meant that players would need to be detected 10 or more times over a 2 week interval to be considered for automated punishment, doing well to prevent evasion of the anti-cheat system by casting a “wide net” so to speak.

runStatusTask

Despite not being enabled most of the time, I wrote a generic task to automatically tweet out status updates of online player counts and maintenance updates if the network appeared to be down for any reason. I suspect this task will be used more in the future, but for now, it runs once every 3 hours tweeting out the number of online players and servers on the network:

public void runStatusTask() {
  this.executor.scheduleAtFixedRate(() -> {
    try {
      final LoggerType type = LoggerType.STATUS;
      while(this.cooldown.get()) {
        final String msg = "Status task waiting...";
        this.logger.log(msg, type);
        this.discord.sendMessage("fnrobot", msg, DiscordLogLevel.INFO);
        Thread.sleep(this.cooldownTime);
      }
      this.cooldown();
      final String msg1 = ">> STATUS HEARTBEAT <<";
      this.logger.log(msg1, type);
      this.discord.sendMessage("fnrobot", msg1, DiscordLogLevel.INFO);
      final ServersCollection collection = this.db.getServersCollection();
      final int serverOnlineCount = collection.getOnlineServerCount();
      this.tweet(
        ((serverOnlineCount == 0)
          ? "Foncused Network may be down for maintenance. Please check back later!"
          : "There are currently " + collection.getOnlinePlayerCount() + " players playing on "
            + serverOnlineCount + " of " + (serverOnlineCount + collection.getOfflineServerCount())
            + " servers on Foncused Network!"),
        type
      );
    } catch(final Exception e) {
      final String msg = e.getMessage() + ", " + e.getCause();
      this.logger.log(msg, LoggerType.ERROR);
      this.discord.sendMessage("fnrobot", "Error in Bot#runStatusTask, details: " + msg, DiscordLogLevel.ERROR);
      e.printStackTrace();
    }
  }, 0, 3, TimeUnit.HOURS);
}

runVoteTask

Vote lists are one of many ways servers owners help their Minecraft communities grow in popularity. When a player “votes” for the server on a vote list, all visitors to the site can see how many times players have voted for the server over the course of its lifetime. Votes also determine the relative rank order on many vote lists - the more votes the server receives, the higher up the list it will appear. For example, the top servers listed on minecraftservers.org receive tens of thousands of votes each month.

Normally, players are offered rewards or other perks for voting - typically, this is on a monthly timeline. The top 3-5 players are often offered the best rewards for being the top monthly voters, in recognition of their support for the server.

I figured I could have FN_Robot.jar handle the monthly vote resets and tweet out the top voters since it already had access to my database and its own Twitter account. The following code checks the day of the month once every day, and if the date returned is the first day of the month, the vote counts are reset and winners are determined:

public void runVoteTask() {
  this.executor.scheduleAtFixedRate(() -> {
    try {
      final LoggerType type = LoggerType.VOTE;
      while(this.cooldown.get()) {
        final String msg = "Vote task waiting...";
        this.logger.log(msg, type);
        this.discord.sendMessage("fnrobot", msg, DiscordLogLevel.INFO);
        Thread.sleep(this.cooldownTime);
      }
      this.cooldown();
      final String msg1 = ">> VOTE HEARTBEAT <<";
      this.logger.log(msg1, type);
      this.discord.sendMessage("fnrobot", msg1, DiscordLogLevel.INFO);
      final Calendar calendar = Calendar.getInstance();
      // If first day of month, reset votes and tally
      if(calendar.get(Calendar.DAY_OF_MONTH) == 1) {
        final PlayersCollection collection = this.db.getPlayersCollection();
        final Map<Integer, List<String>> data = collection.getAllSortedByAttribute("Votes", "UUID");
        final List<Integer> votes = new ArrayList<>();
        final List<String> uuids = new ArrayList<>();
        data.forEach((v, lu) -> lu.forEach(u -> {
          votes.add(v);
          uuids.add(u);
        }));
        Collections.reverse(votes);
        Collections.reverse(uuids);
        calendar.add(Calendar.DATE, -1);
        final StringBuilder sb = new StringBuilder().append("Our top 3 voters for ");
        sb.append(new SimpleDateFormat("MMMM, yyyy").format(calendar.getTime()));
        sb.append(":\n");
        int selected = 0;
        for(int i = 0; i < votes.size(); i++) {
          if(selected >= 3) {
            break;
          }
          final UUID uuid = UUID.fromString(uuids.get(i));
          if(RankUtil.isOwner(RankUtil.getRankFromUniqueId(uuid))) {
            continue;
          }
          final int voteCount = votes.get(i);
          if(voteCount <= 0) {
            continue;
          }
          sb.append((selected + 1));
          sb.append(". ");
          sb.append(collection.getName(uuid));
          sb.append(" (");
          sb.append(voteCount);
          sb.append(")\n");
          selected++;
        }
        if(selected == 0) {
          final String msg2 = "All users have 0 votes for the month";
          this.logger.log(msg2, type);
          this.discord.sendMessage("fnrobot", msg2, DiscordLogLevel.INFO);
          return;
        }
        sb.append("Congratulations!");
        this.tweet(sb.toString(), type);
        final Set<UUID> us = collection.getOnlinePlayers();
        us.forEach(u -> {
          this.db.getCommandsCollection()
            .createCommand(collection.getServer(u), "setvotes " + collection.getName(u) + " 0");
          try {
            Thread.sleep(10 * 1000);
          } catch(final InterruptedException e) {
            DebugUtil.printStackTrace(e);
          }
        });
        collection.setVotesAll(0);
        final String msg2 = "Done resetting vote counts (reset: " + collection.getAllUniqueIds().size() + ")";
        this.logger.log(msg2, type);
        this.discord.sendMessage("fnrobot", msg2, DiscordLogLevel.INFO);
      } else {
        final String msg2 = "Not first day of month...";
        this.logger.log(msg2, type);
        this.discord.sendMessage("fnrobot", msg2, DiscordLogLevel.INFO);
      }
    } catch(final Exception e) {
      final String msg = e.getMessage() + ", " + e.getCause();
      this.logger.log(msg, LoggerType.ERROR);
      this.discord.sendMessage("fnrobot", "Error in Bot#runVoteTask, details: " + msg, DiscordLogLevel.ERROR);
      e.printStackTrace();
    }
  }, 0, 1, TimeUnit.DAYS);
}

The getAllSortedByAttribute() method returns a read-only (returned collections should always be unmodifiable) Map<K, List<String>> TreeMap containing “ordered” data from the database based on the specified attribute - in this case, I was interested in vote counts so I passed "Votes" to the method’s attribute parameter.

public <K> Map<K, List<String>> getAllSortedByAttribute(final String attributeName, final String keyName) {
  final Map<K, List<String>> any = new TreeMap<>();
  this.getDBCollection().find().forEach(object -> {
    final K attribute = (K) object.get(attributeName);
    final String key = (String) object.get(keyName);
    if(any.containsKey(attribute)) {
      any.get(attribute).add(key);
    } else {
      final List<String> values = new ArrayList<>();
      values.add(key);
      any.put(attribute, values);
    }
  });
  return Collections.unmodifiableMap(any);
}

Discord4J

Since adding all of the above functionality, I recently added integration with Discord4J to automate interaction with my database from the Discord chat line. Added as a bot on the Discord Developer Portal, and integrating the dependency with Maven, I was able to add custom commands and status updates in the bot’s online presence.

<!-- Discord4J -->
<dependency>
  <groupId>com.discord4j</groupId>
  <artifactId>discord4j-core</artifactId>
  <version>3.1.2</version>
  <scope>compile</scope>
</dependency>
public void runDiscord4JTask() {
  this.executor.schedule(() -> {
    try {
      this.gateway.on(MessageCreateEvent.class).subscribe(event -> {
        final Message message = event.getMessage();
        final String content = message.getContent();
        if(content.startsWith("-")) {
          final MessageChannel channel = message.getChannel().block();
          final Color color = Color.of(255, 128, 128);
          final String author = this.gateway.getApplicationInfo().block().getName();
          final String icon = message.getGuild().block().getIconUrl(Image.Format.GIF).get();
          switch(content.split(" ")[0].replaceFirst("-", "").toLowerCase()) {
            case "stats":
              final PlayersCollection playersCollection = this.db.getPlayersCollection();
              final ServersCollection serversCollection = this.db.getServersCollection();
              channel.createEmbed(embed ->
                embed
                  .setColor(color)
                  .setAuthor(author, "", icon)
                  .setDescription("\n")
                  .addField(
                    "Players",
                    "Online: " + playersCollection.getOnlinePlayers().size() + "\n" +
                    "Max: " + serversCollection.getMaxPlayers("lobby") + "\n" +
                    "Total: " + playersCollection.getAllPlayers().size(),
                    true
                  )
                  .addField(
                    "Servers",
                    "Ready: " + serversCollection.getReadyServers().size() + "\n" +
                    "Online: " + serversCollection.getOnlineServerCount() + "\n" +
                    "Total: " + serversCollection.getAllServers().size(),
                    true
                  )
              ).block();
              break;
            case "help":
            default:
              channel.createEmbed(embed ->
                embed
                  .setColor(color)
                  .setAuthor(author, "", icon)
                  .setDescription("\n")
                  .addField("-help", "Displays this message.", false)
                  .addField("-rules", "Displays the rules.", false)
                  .addField("-stats", "Retrieves basic network stats.", false)
              ).block();
              break;
          }
        }
      });
    } catch(final Exception e) {
      final String msg = e.getMessage() + ", " + e.getCause();
      this.logger.log(msg, LoggerType.ERROR);
      this.discord.sendMessage("fnrobot", "Error in Bot#runDiscord4JTask, details: " + msg, DiscordLogLevel.ERROR);
      e.printStackTrace();
    }
  }, 0, TimeUnit.SECONDS);
}

MongoDB_Management_Bot_Discord4J_Commands

public void runDiscord4JUpdateTask() {
  this.executor.scheduleAtFixedRate(() -> {
    try {
      this.gateway.updatePresence(
        Presence.online(
          Activity.playing(
            this.db.getPlayersCollection().getOnlinePlayers().size() + "/" +
            this.db.getServersCollection().getMaxPlayers("lobby") +
            " | " + NetworkMessage.SERVER.toString() + " | -help"
          )
        )
      ).block();
    } catch(final Exception e) {
      final String msg = e.getMessage() + ", " + e.getCause();
      this.logger.log(msg, LoggerType.ERROR);
      this.discord.sendMessage("fnrobot", "Error in Bot#runDiscord4JUpdateTask, details: " + msg, DiscordLogLevel.ERROR);
      e.printStackTrace();
    }
  }, 0, 1, TimeUnit.MINUTES);
}

MongoDB_Management_Bot_Discord4J_Status

Results

The automation this standalone program has provided me with has been invaluable. I have added and will add more tasks to FN_Robot.jar in the future, but it is my hope that the code and functionality mentioned above demonstrates the multi-use case for a database management plugin - even outside the context of Minecraft.

This program is fully functional in its current state, so this project could be labeled complete - for now, I will leave it open as I come up with new ideas for additional automated functionality.


Changelog

Update: Sep 29, 2021

All of the information regarding my Minecraft server, including wherever relevant above, has been added to its own project page here. It is recommended but not required to read that information prior to reading about the MongoDB Management Bot.