API overview
Voxen ships a separate api module with no dependency on the plugin’s internals. Every
type in it is stable; everything else is not.
Depending on it
Section titled “Depending on it”-
Add the repository and the API as a
compileOnlydependency:build.gradle.kts repositories {maven("https://repo.vao.zone/releases")}dependencies {compileOnly("zone.vao:voxen-api:1.0")}build.gradle repositories {maven { url 'https://repo.vao.zone/releases' }}dependencies {compileOnly 'zone.vao:voxen-api:1.0'}pom.xml <repositories><repository><id>vao</id><url>https://repo.vao.zone/releases</url></repository></repositories><dependencies><dependency><groupId>zone.vao</groupId><artifactId>voxen-api</artifactId><version>1.0</version><scope>provided</scope></dependency></dependencies>The plugin jar works as a dependency too, but the API module is smaller and pulls in nothing else.
-
Declare Voxen in your plugin descriptor:
plugin.yml depend: [Voxen]Use
softdependinstead if your plugin should still load without it.
Getting the service
Section titled “Getting the service”// static facadeCollection<ChannelInfo> channels = VoxenApi.channels();
// or through Bukkit's ServicesManagerVoxenService voxen = getServer().getServicesManager().load(VoxenService.class);val channels = VoxenApi.channels()
val voxen = server.servicesManager.load(VoxenService::class.java)The facade throws if Voxen is not loaded yet, so in a softdepend plugin either ask first
or take the ServicesManager route, which returns null instead:
if (VoxenApi.isAvailable()) { // safe from here}Threads
Section titled “Threads”Methods that take a Player read that player’s permissions and state, so Voxen runs them on
the thread that owns them: the main thread on Paper, that player’s own region on Folia.
Calling one from anywhere else still works, but the call waits for the hop, so keep those out
of tight loops and off the network thread.
Everything else — mutes, reports, network lookups, panel pages, the text filter — is safe
from any thread. The report methods are the only ones that reach the database, and they hand
back a CompletableFuture rather than blocking.
What you can do
Section titled “What you can do”Read channels
Section titled “Read channels”Collection<ChannelInfo> all = VoxenApi.channels();ChannelInfo global = VoxenApi.channel("global");ChannelInfo current = VoxenApi.activeChannel(player);ChannelInfo is an immutable snapshot: id, displayName, type, enabled,
readOnly, crossServer, radius, worlds.
Move players between channels
Section titled “Move players between channels”VoxenApi.setActiveChannel(player, "auction"); // joins it and talks in itVoxenApi.joinChannel(player, "trade"); // joins it, keeps talking where they wereVoxenApi.leaveChannel(player, "trade");Collection<ChannelInfo> mine = VoxenApi.joinedChannels(player);All three answer false when the channel is unknown or disabled, or when the player may not
join it; leaveChannel also answers false when they were not in it. Leaving the channel a
player was talking in drops them back to the default one.
setActiveChannel is the one players notice, so it fires
ChannelSwitchEvent first and answers false when a
listener cancels it. This is also how you make a channel you registered yourself usable:
register it, then move the players who should be in it.
Send chat
Section titled “Send chat”// as the player, through the whole pipeline: permissions, mutes, cooldown, filterboolean sent = VoxenApi.sendChannelMessage(player, "global", "hello");
// raw component, skips the pipeline entirelyVoxenApi.broadcastToChannel("global", Component.text("Server restarting"));Check moderation state
Section titled “Check moderation state”VoxenApi.isMuted(uuid);VoxenApi.isMuted(uuid, "global");VoxenApi.isIgnoring(source, target);Private messages, nicknames, parties
Section titled “Private messages, nicknames, parties”VoxenApi.sendPrivateMessage(sender, target, "hi"); // PM toggle, ignores, tag permissionsString nick = VoxenApi.nickname(player);VoxenApi.setNickname(player, "<gold>Steve"); // length limits apply, permissions do notPartyInfo party = VoxenApi.party(uuid);Leave mail for somebody who is not around
Section titled “Leave mail for somebody who is not around”A private message needs both players online. Mail waits instead, so an addon can tell
somebody their auction sold while they are offline or on another server. They read it with
/mail and get the usual notification on their next login.
UUID shop = UUID.nameUUIDFromBytes("MyShop".getBytes(UTF_8));
VoxenApi.sendMail(shop, "MyShop", seller, "Your diamond pickaxe sold for 400g.") .thenAccept(stored -> { if (!stored) getLogger().info("mailbox full, or mail is switched off"); });The sender is yours to pick, so mail can come from your plugin under its own name rather than from a player.
| Method | What it does |
|---|---|
sendMail(from, fromName, to, message) |
leaves mail, false when the mailbox is full |
mailbox(target) |
the whole mailbox, newest first |
unreadMail(target) |
only what has not been opened |
markMailRead(target) |
marks everything read, as opening /mail does |
deleteMail(target, id) |
removes one piece by its MailInfo.id |
clearMail(target) |
empties the mailbox, answers with how many went |
Every one of these reaches the database, so they answer through a CompletableFuture.
This is the programmatic path, not the /mail command. The cooldown, the word filter, the
mute check and allow-when-online are all skipped, and so is the recipient’s ignore list. If
you are relaying one player to another and want ignores honoured, check
VoxenApi.isIgnoring(to, from) first.
Two limits do still apply: mail.max-per-player from modules/mail.yml, so a full mailbox
answers false, and the module switch itself, because mail nobody can open is not worth
storing.
Reuse the filter and the renderer
Section titled “Reuse the filter and the renderer”Voxen’s chat filter and tag renderer work on any text, not only on chat, so a sign, a book, an auction name or a shop title can be held to exactly the rules your server already configured.
FilterResult result = VoxenApi.filterWords(input);if (result.isBlocked()) return;String safe = result.getContent(); // censored parts already replaced
Component pretty = VoxenApi.render(player, safe);| Method | What it does |
|---|---|
filterWords(text) |
the word list and the blocked patterns from modules/moderation.yml |
filterLinks(text) |
the link filter, obfuscated addresses included |
isClean(text) |
true when neither has anything against it |
render(player, text) |
MiniMessage and & codes, limited to the tags that player may use |
stripTags(text) |
the readable text with every tag removed |
FilterResult carries a verdict of CLEAN, BLOCKED or CENSORED and the content to
use from there: unchanged when it is clean or blocked, and with the offending parts already
replaced when it was censored.
Add a format placeholder
Section titled “Add a format placeholder”VoxenApi.registerPlaceholder("level", p -> Component.text(getLevel(p)));<level> is now usable in any chat format. Return null to render nothing. Unregister on
disable.
Decorate somebody else’s message
Section titled “Decorate somebody else’s message”A placeholder puts the same thing in every copy of a line. A decorator runs once for each viewer, so it can show a different thing to each of them.
VoxenApi.registerChatDecorator("friends.marker", (sender, viewer, channel, id, message) -> { if (!friends.areFriends(viewer, sender)) return null; return Component.text("★ ", NamedTextColor.GOLD).append(message);});message is the line as that viewer would have seen it, mention highlighting included.
Return the replacement, or null to leave it alone. Voxen already builds every line per
viewer for its own report and moderation buttons, so hooking in here costs nothing extra.
Ids are [a-z0-9_.-] up to 64 characters; prefix yours with your plugin name. One decorator
per id, and they run in registration order with each one receiving what the last returned. A
decorator that throws is logged and skipped, and the ones after it still run.
Only messages sent on this server go through decorators. A line arriving from another server over the network has no local sender and is passed through untouched.
Register your own channel
Section titled “Register your own channel”VoxenApi.registerChannel(ChannelRegistration.builder("auction", "<gold>Auction") .format("<gold>[A] <player> <dark_gray>» <white><message>") .recipients(sender -> Bukkit.getOnlinePlayers().stream() .filter(this::wantsAuctions) .toList()) .build());The RecipientProvider is optional; without one the channel behaves like a normal global
channel. registerRecipients does the same thing for a channel that already exists in
channels/, which is how you bolt a custom audience onto the bundled ones.
Runtime channels are not written to disk and disappear on restart. Register them in
onEnable, and put the players who belong in one there with
setActiveChannel.
Mute and unmute
Section titled “Mute and unmute”VoxenApi.mute(MuteRequest.builder(uuid, "Steve", "AutoMod") .duration(1, TimeUnit.HOURS) .reason("spam") .build());
VoxenApi.unmute(uuid, null, "AutoMod");A bare request mutes every channel, forever, with no reason given; .channel(id) narrows it
and .duration(...) limits it. Mutes go through the same path as the mute commands: stored,
sent to the other servers on the network, and PlayerMuteEvent
fires first, so another plugin can still refuse or shorten it.
| Method | What it does |
|---|---|
mute(request) |
mutes; false when the channel is unknown or a listener cancelled it |
unmute(target, channelId, moderator) |
lifts one mute; false when there was none |
unmuteAll(target, moderator) |
lifts every mute and returns how many went |
isMuted(uuid) / isMuted(uuid, channelId) |
reads the current state, no database call |
Read and manage reports
Section titled “Read and manage reports”VoxenApi.reports(20).thenAccept(open -> { for (ReportInfo report : open) { getLogger().info(report.getTargetName() + " — " + report.getReason()); }});
VoxenApi.updateReport(id, ReportInfo.Action.RESOLVE, "AutoMod");Reports live in the database, so every one of these methods answers through a
CompletableFuture instead of blocking the caller. Use thenAccept from the server thread,
or join() when you are already on one of your own.
| Method | What it does |
|---|---|
reports(limit) |
the queue: everything still OPEN or CLAIMED, newest first |
reports(statuses, limit) |
the same for any set of statuses; an empty set means all |
report(id) |
one report, or null when it is gone |
updateReport(id, action, moderator) |
claim, resolve, dismiss or delete it |
updateReport goes through the same path as the queue commands: the audit trail records the
action under moderator, and ReportUpdateEvent fires first,
so another plugin can still refuse it. It completes with false when the report is gone, when
it already has that status, or when a listener cancelled the change.
Look around the network
Section titled “Look around the network”if (VoxenApi.networkConnected()) { String where = VoxenApi.serverOf("Steve"); // "survival", or null for (NetworkPlayer player : VoxenApi.networkPlayers()) { getLogger().info(player.getName() + " on " + player.getServer()); }}| Method | What it does |
|---|---|
serverId() |
this server’s network id, from network.server-id |
networkConnected() |
whether the broker is up right now |
serverOf(name) |
which server a player is on, null when nobody answers to the name |
networkPlayers() |
everyone on the network, this server included |
networkPlayers() leaves vanished players out, the way a player list should. serverOf does
not, so a message can still be routed to a hidden moderator.
Remote entries come from presence tracking, so
modules/presence.yml has to be on. Without a network both methods still work and simply
describe this server alone.
Send your own data across the network
Section titled “Send your own data across the network”Voxen already keeps a signed, deduplicated link between your servers over Redis, NATS or RabbitMQ. Addons can put their own payloads on it instead of standing up a second one.
VoxenApi.registerNetworkListener("myshop.sales", (channel, payload, from) -> { Sale sale = gson.fromJson(payload, Sale.class); getServer().broadcast(Component.text(sale.item() + " sold on " + from));});
VoxenApi.sendNetworkMessage("myshop.sales", gson.toJson(sale)); // every serverVoxenApi.sendNetworkMessage("myshop.sales", gson.toJson(sale), "lobby"); // just onePick a channel name prefixed with your plugin, so two addons cannot land on the same one.
Names are [a-z0-9_.-] up to 64 characters, payloads are any text up to 64 KiB. One listener
per channel; a second registration for the same name returns false and leaves the first one
alone.
| Method | What it does |
|---|---|
sendNetworkMessage(channel, payload) |
sends to every other server |
sendNetworkMessage(channel, payload, server) |
sends to one network id |
registerNetworkListener(channel, listener) |
starts listening, false if the name is taken |
unregisterNetworkListener(channel) |
stops listening |
Sending returns false when the network is off or unreachable, or when the channel or payload is refused. True only means Voxen took it, not that it landed: the send is queued and never blocks the caller.
The listener runs on the server thread, so Bukkit calls are safe from it. Voxen checks the signature and drops replays and duplicates first, and a server never hears its own messages, so you do not need to filter yourself out.
One report with everything around it
Section titled “One report with everything around it”VoxenApi.reportCase(id).thenAccept(loaded -> { for (ChatLine line : loaded.getContext()) { boolean reported = line.getId().equals(loaded.getReport().getMessageId()); }});reportCase is report plus the chat before and after the reported message and the audit
trail of what moderators did to it — everything a moderation panel outside the game needs in
one call. How much context comes back is context in modules/reports.yml.
deleteReportedMessage(id, moderator) deletes the message a report points at and records it
in the audit trail. It only works while the message is still deletable: signed messages live
in memory on the server they were typed on, so it answers false after a restart and from
another server on the network.
Warnings, mutes and tickets
Section titled “Warnings, mutes and tickets”VoxenApi.warn(uuid, "Steve", "spam", moderator);VoxenApi.warnings(uuid).thenAccept(list -> ...);List<MuteInfo> mutes = VoxenApi.activeMutes(uuid);
VoxenApi.tickets(TicketInfo.Status.ACTIVE, 25).thenAccept(queue -> ...);VoxenApi.replyToTicket(ticketId, "Looking into it", moderator);VoxenApi.closeTicket(ticketId, moderator);warn goes through the same path as the warn command: it lands on the record, the warning
rules for that count run, and PlayerWarnEvent fires first.
activeMutes reads from memory, which is why it is the one moderation method without a
future. The ticket methods are empty while modules/helpop.yml runs in broadcast mode.
Acting for somebody outside the game
Section titled “Acting for somebody outside the game”Every moderation method takes a moderator name and stores it as written, so an addon acting
for a Discord user, a web dashboard or a bot passes its own identity:
String who = VoxenApi.externalModerator("discord", "123456789"); // "discord:123456789"VoxenApi.updateReport(id, ReportInfo.Action.CLAIM, who);That name comes back on the matching event, and that is the loop guard: check the prefix before reacting, or your addon will answer its own actions.
discord:123456789 is fine in a database and useless on a screen, so register a resolver and
Voxen prints something readable instead:
VoxenApi.registerModeratorResolver("discord", id -> lookup(id)); // "4g0 (Naimad123)"It is used everywhere Voxen prints who did what — report and ticket screens, the web panel,
audit trails, mute and warning lists — while events and the API keep handing back the stored
string, so the prefix stays usable as a loop guard. Return null to leave a name as it was
stored, which is what happens for an id you do not know.
The resolver runs on whichever thread is drawing, possibly once per row, so keep it fast, keep it thread safe and cache on your side. Unregister it when your plugin disables.
@EventHandlerpublic void onReportUpdate(ReportUpdateEvent event) { if (event.getModerator().startsWith("discord:")) return; // we did this ourselves mirrorToDiscord(event);}Add a page to the web panel
Section titled “Add a page to the web panel”VoxenApi.registerPanelPage("shop", "Shop", "shop.view", new PanelPage() { @Override public String render(PanelRequest request) { return PanelHtml.heading("Shop") + PanelHtml.table( List.of("Item", "Seller", "State"), sales().stream().map(sale -> List.of( PanelHtml.Cell.of(sale.item()), PanelHtml.Cell.of(sale.seller()), PanelHtml.Cell.html(PanelHtml.tag("paid", PanelHtml.Style.GOOD)) )).toList()); }});The page appears in the panel sidebar as Shop for every account whose permissions in
modules/web.yml cover shop.view, and answers with 403 for the rest. Return the body of
the page only, Voxen wraps it in the panel frame.
Build the markup with PanelHtml
Section titled “Build the markup with PanelHtml”PanelHtml emits the same markup as the bundled pages, so a page written with it follows the
panel theme and keeps following it when the theme changes. Reaching for the raw CSS class
names still works, but they are internal and can move.
| Builder | What it gives you |
|---|---|
heading, section |
the page title and the smaller grey separators |
card, notice, muted |
the rounded surface, a centred message, small grey text |
table |
a table in a card, taking Cell values |
tabs |
the row of filter links above the content |
tag |
a coloured pill: NEUTRAL, INFO, GOOD, WARN, BAD |
form, button, field |
a form that carries the CSRF token on its own |
link, escape |
a link, and the escaper behind all of the above |
Cell.of escapes the text it is given, Cell.html takes markup you built yourself. Prefer
Cell.of and reach for Cell.html only to nest another builder, otherwise a nickname is
enough to inject markup into the panel.
Forms built with PanelHtml.form already carry the token the panel demands, so they are never
rejected as expired. Read the fields back in submit with request.param:
@Overridepublic String render(PanelRequest request) { return PanelHtml.form(request, PanelHtml.field("item", "Item name", "") + PanelHtml.button("action", "restock", "Restock"));}
@Overridepublic void submit(PanelRequest request) { if ("restock".equals(request.param("action"))) restock(request.param("item"));}Answer the request yourself
Section titled “Answer the request yourself”render and submit cover an HTML page in the panel frame. Override handle for anything
else and return a PanelResponse. Returning null falls back to render and submit, which
is what happens when you do not override it at all.
@Overridepublic PanelResponse handle(PanelRequest request) { List<String> path = request.getPath(); if (path.isEmpty()) return null; return switch (path.get(0)) { case "item" -> PanelResponse.json(itemAsJson(path.get(1))); case "export" -> PanelResponse.download("sales.csv", "text/csv", csv()); case "old" -> PanelResponse.redirect(request.linkTo("shop", List.of(), Map.of())); default -> PanelResponse.status(404, "No such item."); };}| Response | What it does |
|---|---|
page |
HTML wrapped in the panel frame, same as render |
status |
a short message in the frame under an HTTP status |
json, text |
a body served as it is, outside the frame |
raw |
any other content type, outside the frame |
download |
a file the browser saves instead of showing |
redirect |
sends the browser to another panel path |
request.getPath() holds the segments after the page id, so /shop/item/42 gives
["item", "42"]. request.getMethod() tells GET from POST, and a POST reaching
handle has already passed the CSRF check.
Markup content types are refused and redirects have to stay inside the panel. Both would let a page run scripts in the panel’s own origin or bounce a logged in moderator somewhere else, and the panel has no other defence against that.
Titles and switching a page off
Section titled “Titles and switching a page off”Pass a Supplier<String> instead of a plain title and the sidebar entry follows your config
on every reload. Override enabled to hide a page while the feature behind it is off; it
disappears from the sidebar and answers with 404 without being unregistered.
VoxenApi.registerPanelPage("shop", () -> config.getString("panel.title"), "shop.view", page);Pages are not written to disk. Register them in onEnable and drop them with
unregisterPanelPage in onDisable; the two built-in pages cannot be removed this way.
Events
Section titled “Events”Two events cover the message lifecycle. See Events.