BlazeBans API/Building an addon

Build one end to end

A complete addon, from an empty directory to a jar running on a server. Everything here compiles as written.

What we are building

BlazeBans Join Alerts. Three features, chosen because together they touch every part of the API:

  1. When a player joins, warn staff if that player has active punishments or a heavy record. This covers querying and threading.
  2. Let /appeal work while a player is muted. This covers events used as exemptions.
  3. Register the addon and report its status in /blazebans addons. This covers addon metadata and console output.

It is about 200 lines and genuinely useful on a real server.

1. Project structure

txt
join-alerts/
  build.gradle
  settings.gradle
  libs/
    blazebans-api-1.0.0.jar
  src/main/
    java/com/example/joinalerts/
      JoinAlertsPlugin.java
      JoinAlertListener.java
      MuteExemptionListener.java
    resources/
      plugin.yml
      config.yml

Download the API jar from Project setup into libs/.

2. Build script

settings.gradle:

groovy
rootProject.name = 'join-alerts'

build.gradle:

groovy
plugins {
    id 'java'
    id 'xyz.jpenilla.run-paper' version '3.0.2'
}

group = 'com.example'
version = '1.0.0'

repositories {
    mavenCentral()
    maven {
        name = "PaperMC"
        url = "https://repo.papermc.io/repository/maven-public/"
    }
}

dependencies {
    compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT")
    compileOnly(files("libs/blazebans-api-1.0.0.jar"))
}

java {
    toolchain.languageVersion = JavaLanguageVersion.of(21)
}

tasks.withType(JavaCompile).configureEach {
    options.encoding = 'UTF-8'
    options.release.set(21)
}

processResources {
    def props = [version: version]
    inputs.properties props
    filteringCharset = 'UTF-8'
    filesMatching('plugin.yml') {
        expand props
    }
}

tasks {
    runServer {
        minecraftVersion("1.21.11")
    }
}

3. plugin.yml

yaml
name: JoinAlerts
version: '${version}'
main: com.example.joinalerts.JoinAlertsPlugin
api-version: '1.21'
authors: [ you ]
description: Warns staff when a player with history joins.
depend:
  - BlazeBans
permissions:
  blazebans.addon.joinalerts.receive:
    description: Receives join alerts.
    default: op

depend guarantees BlazeBans is enabled before us.

4. config.yml

yaml
# Alert staff when a joining player has at least this many total records.
history-threshold: 3

# Always alert when the joining player has an active punishment.
alert-on-active: true

# Commands a muted player may still use. Leading slash optional.
mute-exempt-commands:
  - "appeal"
  - "support"

5. The main class

java
package com.example.joinalerts;

import java.util.List;
import java.util.Locale;
import net.blazebans.api.BlazeBansApi;
import net.blazebans.api.BlazeBansProvider;
import net.blazebans.api.addon.BlazeBansAddon;
import net.blazebans.api.console.AddonConsole;
import org.bukkit.plugin.java.JavaPlugin;

public final class JoinAlertsPlugin extends JavaPlugin {
    private static final String ADDON_ID = "JoinAlerts";

    private BlazeBansApi blazeBans;
    private AddonConsole console;

    @Override
    public void onEnable() {
        this.console = new AddonConsole(
            "JoinAlerts",
            component -> getServer().getConsoleSender().sendMessage(component)
        );

        saveDefaultConfig();

        this.blazeBans = BlazeBansProvider.optional().orElse(null);
        if (this.blazeBans == null) {
            this.console.error("The BlazeBans API is unavailable. Check hooks.api in settings.yml.");
            registerAddon("disabled: API unavailable");
            getServer().getPluginManager().disablePlugin(this);
            return;
        }

        this.console.step("Connecting to BlazeBans " + this.blazeBans.version() + "...");

        getServer().getPluginManager().registerEvents(
            new JoinAlertListener(this, this.blazeBans), this);
        getServer().getPluginManager().registerEvents(
            new MuteExemptionListener(exemptCommands()), this);

        registerAddon("enabled");
        this.console.success("Join alerts are active.");
    }

    @Override
    public void onDisable() {
        BlazeBansProvider.unregisterAddon(ADDON_ID);
    }

    private void registerAddon(String status) {
        BlazeBansProvider.registerAddon(new BlazeBansAddon(
            ADDON_ID,
            "Join Alerts",
            getPluginMeta().getVersion(),
            "Warns staff when a player with history joins.",
            status
        ));
    }

    public int historyThreshold() {
        return Math.max(1, getConfig().getInt("history-threshold", 3));
    }

    public boolean alertOnActive() {
        return getConfig().getBoolean("alert-on-active", true);
    }

    private List<String> exemptCommands() {
        return getConfig().getStringList("mute-exempt-commands").stream()
            .map(command -> command.toLowerCase(Locale.ROOT).replaceFirst("^/", ""))
            .filter(command -> !command.isBlank())
            .toList();
    }
}

Note the addon is registered even on the failure path, with a status saying why. An operator running /blazebans addons then sees the reason instead of an absence.

6. The join listener

This is where threading matters. We are on the main thread when the event fires, we need three database lookups, and we need to be back on a safe thread to message anyone.

java
package com.example.joinalerts;

import java.util.List;
import java.util.concurrent.CompletableFuture;
import net.blazebans.api.BlazeBansApi;
import net.blazebans.api.punishment.Punishment;
import net.blazebans.api.punishment.PunishmentType;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;

public final class JoinAlertListener implements Listener {
    private static final String ALERT_PERMISSION = "blazebans.addon.joinalerts.receive";

    private final JoinAlertsPlugin plugin;
    private final BlazeBansApi blazeBans;

    public JoinAlertListener(JoinAlertsPlugin plugin, BlazeBansApi blazeBans) {
        this.plugin = plugin;
        this.blazeBans = blazeBans;
    }

    @EventHandler
    public void onJoin(PlayerJoinEvent event) {
        Player player = event.getPlayer();
        String name = player.getName();
        java.util.UUID uuid = player.getUniqueId();

        // Start every lookup at once rather than chaining them.
        CompletableFuture<List<Punishment>> history = this.blazeBans.punishments().history(name);
        CompletableFuture<Boolean> muted = this.blazeBans.punishments().isMuted(name, uuid);

        history.thenCombine(muted, (records, isMuted) -> summarise(records, isMuted))
            .thenAccept(summary -> {
                if (summary == null) {
                    return;
                }
                // Back onto a thread where Bukkit is safe before touching players.
                Bukkit.getGlobalRegionScheduler().run(this.plugin, task -> broadcast(name, summary));
            })
            .exceptionally(error -> {
                this.plugin.getLogger().warning(
                    "Could not check history for " + name + ": " + error.getMessage());
                return null;
            });
    }

    /** Returns the alert text, or null when this player is not worth alerting about. */
    private String summarise(List<Punishment> records, boolean muted) {
        long bans = records.stream()
            .filter(record -> record.type() == PunishmentType.BAN
                || record.type() == PunishmentType.IP_BAN)
            .count();
        long warnings = records.stream()
            .filter(record -> record.type() == PunishmentType.WARN)
            .filter(Punishment::active)
            .count();

        boolean heavy = records.size() >= this.plugin.historyThreshold();
        boolean active = this.plugin.alertOnActive() && (muted || warnings > 0);

        if (!heavy && !active) {
            return null;
        }

        StringBuilder summary = new StringBuilder();
        summary.append(records.size()).append(" records");
        if (bans > 0) {
            summary.append(", ").append(bans).append(" bans");
        }
        if (warnings > 0) {
            summary.append(", ").append(warnings).append(" active warnings");
        }
        if (muted) {
            summary.append(", currently muted");
        }
        return summary.toString();
    }

    private void broadcast(String playerName, String summary) {
        Component message = Component.text("[JoinAlerts] ", NamedTextColor.GOLD)
            .append(Component.text(playerName, NamedTextColor.WHITE))
            .append(Component.text(" joined: ", NamedTextColor.GRAY))
            .append(Component.text(summary, NamedTextColor.YELLOW));

        for (Player staff : Bukkit.getOnlinePlayers()) {
            if (staff.hasPermission(ALERT_PERMISSION)) {
                staff.sendMessage(message);
            }
        }
    }
}

Three things worth pointing out.

thenCombine runs both lookups concurrently. Chaining them with thenCompose would mean two round trips one after another for no reason.

summarise returns null for "nothing to say", and the callback returns early on it. That keeps the decision off the scheduled task.

broadcast only runs inside getGlobalRegionScheduler().run, because it iterates online players and sends messages. That is the hop from the database thread back to a safe one.

7. The mute exemption listener

java
package com.example.joinalerts;

import java.util.List;
import java.util.Locale;
import net.blazebans.api.event.BlazeBansMutedCommandEvent;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;

public final class MuteExemptionListener implements Listener {
    private final List<String> exempt;

    public MuteExemptionListener(List<String> exempt) {
        this.exempt = exempt;
    }

    @EventHandler
    public void onMutedCommand(BlazeBansMutedCommandEvent event) {
        String command = event.command().toLowerCase(Locale.ROOT).replaceFirst("^/", "");
        String root = command.split("\\s+", 2)[0];

        if (this.exempt.contains(root)) {
            // Cancelling tells BlazeBans NOT to block this command.
            event.setCancelled(true);
        }
    }
}

Remember the inversion: cancelling the BlazeBans event is what allows the command.

This listener touches no Bukkit API, so it needs no scheduling even though the event fires asynchronously.

8. Build and run

bash
./gradlew build

Your jar lands in build/libs/. Copy BlazeBans into run/plugins/ once, then:

bash
./gradlew runServer

Console should show:

txt
[BlazeBans JoinAlerts] Connecting to BlazeBans 1.0.0...
[BlazeBans JoinAlerts] Join alerts are active.

9. Test it

minecraft
/blazebans addons

Join Alerts should be listed as enabled.

Give yourself a record and rejoin:

minecraft
/warn YourName Testing
/warn YourName Testing again
/warn YourName Testing a third time

Reconnect and you should see the alert. Then test the exemption:

minecraft
/mute YourName 10m Testing

As that player, /appeal should still be permitted by BlazeBans while /msg is blocked.

Where to take it

The obvious next steps, each a small change:

Cache the result. A player rejoining repeatedly re-queries. A short-lived map keyed by UUID fixes it. See Threading.

Listen for changes. BlazeBansPunishmentCreatedEvent lets you invalidate a cache entry the moment a punishment lands instead of waiting for it to expire.

Add a command. /joinalerts reload reloading your own config is what operators expect.

Report status properly. Re-register the addon with a status naming the count of alerts sent, or a failure reason if lookups keep erroring.

Post it somewhere. The same summary is more useful in a staff Discord channel than in chat that scrolls away.

The full listing

Everything above in one place, in the order the files appear in the project:

  • build.gradle and settings.gradle from step 2
  • src/main/resources/plugin.yml from step 3
  • src/main/resources/config.yml from step 4
  • src/main/java/com/example/joinalerts/JoinAlertsPlugin.java from step 5
  • src/main/java/com/example/joinalerts/JoinAlertListener.java from step 6
  • src/main/java/com/example/joinalerts/MuteExemptionListener.java from step 7

If something does not behave, Common mistakes covers the usual causes.