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:
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,
|
||||
|
||||
Reference in new issue
Block a user