diff --git a/src/main/java/me/bobert/netneutrality/db/GSettings.java b/src/main/java/me/bobert/netneutrality/db/GSettings.java index 32b0776..68482ad 100644 --- a/src/main/java/me/bobert/netneutrality/db/GSettings.java +++ b/src/main/java/me/bobert/netneutrality/db/GSettings.java @@ -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; + } } diff --git a/src/main/java/me/bobert/netneutrality/http/server/SettingsRoutes.java b/src/main/java/me/bobert/netneutrality/http/server/SettingsRoutes.java index b4de6db..549d1c7 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/SettingsRoutes.java +++ b/src/main/java/me/bobert/netneutrality/http/server/SettingsRoutes.java @@ -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 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,