Add setting types and validation for guild settings

Introduced `SettingType` to categorize guild settings and enforce validation rules for each type (e.g., integer, boolean, percentage, enum). Updated `GSettings` to include type and allowed values, enhanced `/settings/catalog` to expose additional metadata, and added robust validation logic to reject invalid setting updates. Verified backward compatibility and minimal impact on existing settings.
This commit is contained in:
anthorob committed 2026-09-29 12:02:36 -04:00
1 parent 57b8e4669b
commit 41276c40f6
2 files changed
+96 -20

No files matched your search

@@ -15,40 +15,48 @@ package me.bobert.netneutrality.db;
*/
public enum GSettings {
/** The role required to use music commands; empty means everyone may use them. */
MUSIC_ROLE("MUSIC_ROLE", "", "The role required to use music commands; empty means everyone may use them"),
MUSIC_ROLE("MUSIC_ROLE", "", "The role required to use music commands; empty means everyone may use them", SettingType.ROLE),
/** Id of the only text channel music commands may be used in; empty means any channel. */
MUSIC_CHANNEL("MUSIC_CHANNEL", "", "Id of the only text channel music commands may be used in; empty means any channel"),
MUSIC_CHANNEL("MUSIC_CHANNEL", "", "Id of the only text channel music commands may be used in; empty means any channel", SettingType.CHANNEL),
/** Default playback volume (0-100) applied when a music player is created. */
MUSIC_VOLUME("MUSIC_VOLUME", "30", "Default playback volume (0-100) applied when a music player is created"),
MUSIC_VOLUME("MUSIC_VOLUME", "30", "Default playback volume (0-100) applied when a music player is created", SettingType.INTEGER),
/** Playlist size above which the bot asks for confirmation before queueing every track. */
MUSIC_MAXPLAYLISTSIZE("MUSIC_MAXPLAYLISTSIZE", "20", "Playlist size above which the bot asks for confirmation before queueing every track"),
MUSIC_MAXPLAYLISTSIZE("MUSIC_MAXPLAYLISTSIZE", "20", "Playlist size above which the bot asks for confirmation before queueing every track", SettingType.INTEGER),
/** Role that may skip/stop/adjust volume without needing to be the current DJ; empty means the built-in DJ rule only. */
MUSIC_DJ_ROLE("MUSIC_DJ_ROLE", "", "Role that may control music playback without being the current DJ; empty means the built-in DJ rule only"),
MUSIC_DJ_ROLE("MUSIC_DJ_ROLE", "", "Role that may control music playback without being the current DJ; empty means the built-in DJ rule only", SettingType.ROLE),
/** Percentage (0-100) of connected listeners required to approve a vote-skip. */
MUSIC_VOTE_SKIP_THRESHOLD("MUSIC_VOTE_SKIP_THRESHOLD", "50", "Percentage (0-100) of connected listeners required to approve a vote-skip"),
MUSIC_VOTE_SKIP_THRESHOLD("MUSIC_VOTE_SKIP_THRESHOLD", "50", "Percentage (0-100) of connected listeners required to approve a vote-skip", SettingType.PERCENTAGE),
/** Minutes of voice channel inactivity before the bot automatically disconnects. */
MUSIC_IDLE_TIMEOUT("MUSIC_IDLE_TIMEOUT", "10", "Minutes of voice channel inactivity before the bot automatically disconnects"),
MUSIC_IDLE_TIMEOUT("MUSIC_IDLE_TIMEOUT", "10", "Minutes of voice channel inactivity before the bot automatically disconnects", SettingType.INTEGER),
/** Per-guild override of the bot's message command prefix; empty means the bot-wide default. */
COMMAND_PREFIX("COMMAND_PREFIX", "", "Per-guild override of the bot's message command prefix; empty means the bot-wide default"),
COMMAND_PREFIX("COMMAND_PREFIX", "", "Per-guild override of the bot's message command prefix; empty means the bot-wide default", SettingType.STRING),
/** Role granted admin permissions for admin-only commands, in addition to Discord's own permission checks. */
ADMIN_ROLE("ADMIN_ROLE", "", "Role granted admin permissions for admin-only commands, in addition to Discord's own permission checks"),
ADMIN_ROLE("ADMIN_ROLE", "", "Role granted admin permissions for admin-only commands, in addition to Discord's own permission checks", SettingType.ROLE),
/** Comma-separated list of command names disabled on this guild. */
DISABLED_COMMANDS("DISABLED_COMMANDS", "", "Comma-separated list of command names disabled on this guild"),
DISABLED_COMMANDS("DISABLED_COMMANDS", "", "Comma-separated list of command names disabled on this guild", SettingType.STRING),
/** Minimum severity of events sent to the log channel (e.g. ERROR, WARN, INFO). */
LOG_LEVEL("LOG_LEVEL", "ERROR", "Minimum severity of events sent to the log channel (e.g. ERROR, WARN, INFO)"),
LOG_LEVEL("LOG_LEVEL", "ERROR", "Minimum severity of events sent to the log channel (e.g. ERROR, WARN, INFO)", SettingType.ENUM, "ERROR", "WARN", "INFO", "DEBUG"),
/** Preferred locale for the bot's replies on this guild. */
LOCALE("LOCALE", "en", "Preferred locale for the bot's replies on this guild"),
LOCALE("LOCALE", "en", "Preferred locale for the bot's replies on this guild", SettingType.STRING),
/** Timezone used to display timestamps for this guild. */
TIMEZONE("TIMEZONE", "UTC", "Timezone used to display timestamps for this guild");
TIMEZONE("TIMEZONE", "UTC", "Timezone used to display timestamps for this guild", SettingType.STRING);
private final String name;
private final String defaultValue;
private final String description;
private final SettingType type;
private final String[] allowedValues;
GSettings(String name, String defaultValue, String description) {
GSettings(String name, String defaultValue, String description, SettingType type) {
this(name, defaultValue, description, type, new String[0]);
}
GSettings(String name, String defaultValue, String description, SettingType type, String... allowedValues) {
this.name = name;
this.defaultValue = defaultValue;
this.description = description;
this.type = type;
this.allowedValues = allowedValues;
}
public String getName() {
@@ -62,4 +70,12 @@ public enum GSettings {
public String getDescription() {
return description;
}
public SettingType getType() {
return type;
}
public String[] getAllowedValues() {
return allowedValues;
}
}
@@ -16,12 +16,14 @@ import io.javalin.openapi.OpenApiParam;
import io.javalin.openapi.OpenApiRequestBody;
import io.javalin.openapi.OpenApiResponse;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.SettingType;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildSettingService;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
@@ -46,18 +48,24 @@ public final class SettingsRoutes {
@OpenApi(
path = "/settings/catalog",
methods = HttpMethod.GET,
summary = "List every known per-guild setting with its default value and description",
summary = "List every known per-guild setting with its default value, description, and type",
tags = {"Settings"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class))
)
private static void settingsCatalog(Context ctx) {
ctx.json(
Arrays.stream(GSettings.values())
.map(setting -> Map.of(
"name", setting.getName(),
"default_value", setting.getDefaultValue(),
"description", setting.getDescription()
))
.map(setting -> {
Map<String, Object> entry = new HashMap<>();
entry.put("name", setting.getName());
entry.put("default_value", setting.getDefaultValue());
entry.put("description", setting.getDescription());
entry.put("type", setting.getType().name());
if (setting.getType() == SettingType.ENUM) {
entry.put("allowed_values", setting.getAllowedValues());
}
return entry;
})
.toList()
);
}
@@ -101,6 +109,8 @@ public final class SettingsRoutes {
GSettings setting = setting(ctx.pathParam("name"));
String value = ctx.bodyAsClass(Map.class).get("value") instanceof String s ? s : null;
validate(setting, value);
GuildSettingService settingService = guildServices.get(guild.getId()).getGuildSettingService();
settingService.set(setting, value);
@@ -118,6 +128,56 @@ public final class SettingsRoutes {
}
}
/**
* Validates {@code value} against {@code setting}'s declared {@link SettingType}, throwing a
* {@link BadRequestResponse} on mismatch. Empty/{@code null} values always pass, since they
* represent resetting the setting back to its default.
*/
private static void validate(GSettings setting, String value) {
if (value == null || value.isEmpty()) {
return;
}
switch (setting.getType()) {
case INTEGER -> {
try {
Integer.parseInt(value);
} catch (NumberFormatException e) {
throw new BadRequestResponse(setting.getName() + " must be an integer");
}
}
case PERCENTAGE -> {
int percentage;
try {
percentage = Integer.parseInt(value);
} catch (NumberFormatException e) {
throw new BadRequestResponse(setting.getName() + " must be an integer between 0 and 100");
}
if (percentage < 0 || percentage > 100) {
throw new BadRequestResponse(setting.getName() + " must be an integer between 0 and 100");
}
}
case BOOLEAN -> {
if (!"true".equalsIgnoreCase(value) && !"false".equalsIgnoreCase(value)) {
throw new BadRequestResponse(setting.getName() + " must be \"true\" or \"false\"");
}
}
case ROLE, CHANNEL -> {
if (!value.matches("\\d+")) {
throw new BadRequestResponse(setting.getName() + " must be a numeric Discord id");
}
}
case ENUM -> {
if (Arrays.stream(setting.getAllowedValues()).noneMatch(value::equalsIgnoreCase)) {
throw new BadRequestResponse(setting.getName() + " must be one of " + Arrays.toString(setting.getAllowedValues()));
}
}
case STRING -> {
// no additional validation
}
}
}
@OpenApi(
path = "/settings/reload/{guildId}",
methods = HttpMethod.PUT,