Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ original location, placed back into survival mode, and their original inventory
- `/spawn` - Enter admin mode at the current world's spawn point
- `/spawn <world>` - Enter admin mode at the provided world's spawn point


### Report System

- `/report <reason>` — Players send a report with their current location and a timestamp. Admins receive a broadcast where the coordinates are clickable to spectate in admin mode.
- `/reports [page]` — View open reports (5 per page). Each entry shows player, coords, and world. Hovering shows the reason, time, and the report ID. The coords are clickable to spectate in admin mode, and an inline `[Resolve]` button lets admins resolve the report.
- `/reports resolve <id>` — Resolve by ID (or click the inline `[Resolve]` button).

Notes:
- Cooldown: players must wait 5 minutes between `/report` submissions.
- Permission: `admintoolbox.reports` is required to view/resolve reports.

#### Navigation

- `/back` - Move to previous location in teleport history
Expand Down Expand Up @@ -91,6 +102,7 @@ screen sharing or live-streaming gameplay.
| `admintoolbox.reveal` | `/reveal` | Reveal while spectating in admin mode |
| `admintoolbox.yell` | `/yell` | Show titles to other players |
| `admintoolbox.freeze` | `/freeze` | Freeze and unfreeze players |
| `admintoolbox.reports` | `/reports` | View and manage player reports |
| `admintoolbox.spawn` | `/spawn` | Spectate at current world spawn |
| `admintoolbox.spawn.all` | `/spawn [world]` | Spectate at all world spawns |
| `admintoolbox.broadcast.receive` | | Receive alerts about other admins' actions |
Expand Down
75 changes: 72 additions & 3 deletions src/main/java/org/modernbeta/admintoolbox/AdminToolboxPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.bukkit.plugin.java.JavaPlugin;
import org.modernbeta.admintoolbox.commands.*;
import org.modernbeta.admintoolbox.managers.FreezeManager;
import org.modernbeta.admintoolbox.managers.ReportManager;
import org.modernbeta.admintoolbox.managers.admin.AdminManager;

import javax.annotation.Nullable;
Expand All @@ -24,6 +25,7 @@ public class AdminToolboxPlugin extends JavaPlugin {

AdminManager adminManager;
FreezeManager freezeManager;
ReportManager reportManager;

PermissionAudience broadcastAudience;

Expand All @@ -35,6 +37,10 @@ public class AdminToolboxPlugin extends JavaPlugin {

private static final String ADMIN_STATE_CONFIG_FILENAME = "admin-state.yml";

private File reportsConfigFile;
private FileConfiguration reportsConfig;
private static final String REPORTS_CONFIG_FILENAME = "reports.yml";

private static final String BROADCAST_AUDIENCE_PERMISSION = "admintoolbox.broadcast.receive";
public static final String BROADCAST_EXEMPT_PERMISSION = "admintoolbox.broadcast.exempt";

Expand All @@ -47,14 +53,19 @@ public void onEnable() {

this.broadcastAudience = new PermissionAudience(BROADCAST_AUDIENCE_PERMISSION);

createAdminStateConfig();
this.adminStateConfig = getAdminStateConfig();

createReportsConfig();
this.reportsConfig = getReportsConfig();

this.reportManager = new ReportManager();

{
RegisteredServiceProvider<LuckPerms> provider = Bukkit.getServicesManager().getRegistration(LuckPerms.class);
if (provider != null) this.luckPermsAPI = provider.getProvider();
}

createAdminStateConfig();
this.adminStateConfig = getAdminStateConfig();

getServer().getPluginManager().registerEvents(adminManager, this);
getServer().getPluginManager().registerEvents(freezeManager, this);

Expand All @@ -68,6 +79,8 @@ public void onEnable() {
getCommand("yell").setExecutor(new YellCommand());
getCommand("spawn").setExecutor(new SpawnCommand());
getCommand("streamermode").setExecutor(new StreamerModeCommand());
getCommand("report").setExecutor(new ReportCommand());
getCommand("reports").setExecutor(new ReportsCommand());

initializeConfig();

Expand All @@ -89,6 +102,41 @@ private void createAdminStateConfig() {
this.adminStateConfig = YamlConfiguration.loadConfiguration(adminStateConfigFile);
}

private void createReportsConfig() {
this.reportsConfigFile = new File(getDataFolder(), REPORTS_CONFIG_FILENAME);
if (!this.reportsConfigFile.exists()) {
this.reportsConfigFile.getParentFile().mkdirs();
if (getResource(REPORTS_CONFIG_FILENAME) != null) {
saveResource(REPORTS_CONFIG_FILENAME, false);
} else {
try {
this.reportsConfigFile.createNewFile();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

this.reportsConfig = YamlConfiguration.loadConfiguration(reportsConfigFile);

ConfigurationSection existing = this.reportsConfig.getConfigurationSection("reports");
ConfigurationSection fromAdmin = this.adminStateConfig.getConfigurationSection("reports");
if ((existing == null || existing.getKeys(false).isEmpty()) && fromAdmin != null) {
ConfigurationSection dest = this.reportsConfig.createSection("reports");
for (String key : fromAdmin.getKeys(false)) {
ConfigurationSection child = fromAdmin.getConfigurationSection(key);
if (child != null) {
dest.createSection(key, child.getValues(true));
}
}
try {
this.reportsConfig.save(reportsConfigFile);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

public FileConfiguration getAdminStateConfig() {
// TODO: this re-reads the file from file system every time, should not be needed
// but we have run into some desynced state somehow. Figure out why!
Expand All @@ -109,6 +157,23 @@ public void saveAdminStateConfig() {
}
}

public FileConfiguration getReportsConfig() {
try {
this.reportsConfig.load(reportsConfigFile);
} catch (Exception e) {
throw new RuntimeException(e);
}
return this.reportsConfig;
}

public void saveReportsConfig() {
try {
this.reportsConfig.save(reportsConfigFile);
} catch (IOException e) {
throw new RuntimeException(e);
}
}

public static AdminToolboxPlugin getInstance() {
return instance;
}
Expand All @@ -121,6 +186,10 @@ public FreezeManager getFreezeManager() {
return freezeManager;
}

public ReportManager getReportManager() {
return reportManager;
}

public PermissionAudience getAdminAudience() {
return broadcastAudience;
}
Expand Down
118 changes: 118 additions & 0 deletions src/main/java/org/modernbeta/admintoolbox/commands/ReportCommand.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package org.modernbeta.admintoolbox.commands;

import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
import org.bukkit.Location;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.modernbeta.admintoolbox.AdminToolboxPlugin;
import org.modernbeta.admintoolbox.models.Report;
import org.modernbeta.admintoolbox.utils.LocationUtils;

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;

public class ReportCommand implements CommandExecutor, TabCompleter {
private final AdminToolboxPlugin plugin = AdminToolboxPlugin.getInstance();
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final long COOLDOWN_MINUTES = 5;
private final Map<UUID, LocalDateTime> cooldowns = new HashMap<>();

private static final List<String> REPORT_SUGGESTIONS = List.of(
"griefing",
"stealing",
"bug"
);

@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if (!(sender instanceof Player player)) {
sender.sendRichMessage("<red>You must be a player to use this command.");
return true;
}

if (args.length == 0) {
player.sendRichMessage("<red>Please provide a reason for your report. Usage: /report <reason>");
return true;
}

LocalDateTime lastReport = cooldowns.get(player.getUniqueId());
if (lastReport != null) {
Duration timeSince = Duration.between(lastReport, LocalDateTime.now());
if (timeSince.toMinutes() < COOLDOWN_MINUTES) {
long remainingSeconds = COOLDOWN_MINUTES * 60 - timeSince.getSeconds();
long minutes = remainingSeconds / 60;
long seconds = remainingSeconds % 60;
player.sendRichMessage("<red>You must wait <time> before sending another report.",
Placeholder.unparsed("time", String.format("%d:%02d", minutes, seconds)));
return true;
}
}

Location loc = player.getLocation();
String reason = String.join(" ", args);

Report report = plugin.getReportManager().createReport(
player.getUniqueId(),
player.getName(),
loc,
reason
);

String coords = String.format("%.1f, %.1f, %.1f", loc.getX(), loc.getY(), loc.getZ());
String timestamp = report.getTimestamp().format(TIME_FORMATTER);

String worldNameForCommand = LocationUtils.getShortWorldName(loc.getWorld());
String targetCommand = String.format("/target %d %d %d %s",
loc.getBlockX(), loc.getBlockY(), loc.getBlockZ(), worldNameForCommand);

Component coordsComponent = Component.text(coords)
.clickEvent(ClickEvent.runCommand(targetCommand))
.hoverEvent(HoverEvent.showText(Component.text("Click to spectate here")));

plugin.getAdminAudience()
.sendMessage(MiniMessage.miniMessage().deserialize(
"<gold>[Report] <player> at <gray><coords></gray> in <world> (<timestamp>):<gray> <reason>",
Placeholder.component("player", player.name()),
Placeholder.component("coords", coordsComponent),
Placeholder.unparsed("world", loc.getWorld() != null ? loc.getWorld().getName() : worldNameForCommand),
Placeholder.unparsed("timestamp", timestamp),
Placeholder.unparsed("reason", reason)
));

player.sendRichMessage("<green>Your report has been submitted. Thank you!");
cooldowns.put(player.getUniqueId(), LocalDateTime.now());

return true;
}

@Override
public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if (args.length == 0) {
return REPORT_SUGGESTIONS;
}

String currentArg = args[args.length - 1].toLowerCase();

List<String> filtered = REPORT_SUGGESTIONS.stream()
.filter(suggestion -> suggestion.toLowerCase().startsWith(currentArg))
.collect(Collectors.toList());

if (args.length == 1) {
return filtered;
}

return filtered.isEmpty() ? List.of() : filtered;
}
}
Loading