Add OpenAPI-based API documentation and new endpoints

Integrated OpenAPI and Swagger plugins for comprehensive API documentation. Added new routes, including settings management (`/settings/*`), guild metadata endpoints (`/guilds/*`), and detailed action status handling. Updated database schema to support guild metadata persistence, revised command subcommand handling for better modularity, and enhanced API key-based authentication for all routes.
This commit is contained in:
anthorob committed 2026-09-29 10:32:28 -04:00
1 parent 35fc415d91
commit a8b353ea73
19 files changed
+947 -49

No files matched your search

@@ -14,21 +14,41 @@ package me.bobert.netneutrality.db;
* column and must never change for an existing setting.
*/
public enum GSettings {
/** Id of 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"),
/** Id of the only text channel music commands may be used in; empty means any channel. */
MUSIC_CHANNEL("MUSIC_CHANNEL", ""),
MUSIC_CHANNEL("MUSIC_CHANNEL", "", "Id of the only text channel music commands may be used in; empty means any channel"),
/** Default playback volume (0-100) applied when a music player is created. */
MUSIC_VOLUME("MUSIC_VOLUME", "30"),
MUSIC_VOLUME("MUSIC_VOLUME", "30", "Default playback volume (0-100) applied when a music player is created"),
/** Playlist size above which the bot asks for confirmation before queueing every track. */
MUSIC_MAXPLAYLISTSIZE("MUSIC_MAXPLAYLISTSIZE", "20");
MUSIC_MAXPLAYLISTSIZE("MUSIC_MAXPLAYLISTSIZE", "20", "Playlist size above which the bot asks for confirmation before queueing every track"),
/** 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"),
/** 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"),
/** 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"),
/** 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"),
/** 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"),
/** Comma-separated list of command names disabled on this guild. */
DISABLED_COMMANDS("DISABLED_COMMANDS", "", "Comma-separated list of command names disabled on this guild"),
/** 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)"),
/** Preferred locale for the bot's replies on this guild. */
LOCALE("LOCALE", "en", "Preferred locale for the bot's replies on this guild"),
/** Timezone used to display timestamps for this guild. */
TIMEZONE("TIMEZONE", "UTC", "Timezone used to display timestamps for this guild");
private final String name;
private final String defaultValue;
private final String description;
GSettings(String name, String defaultValue) {
GSettings(String name, String defaultValue, String description) {
this.name = name;
this.defaultValue = defaultValue;
this.description = description;
}
public String getName() {
@@ -38,4 +58,8 @@ public enum GSettings {
public String getDefaultValue() {
return defaultValue;
}
public String getDescription() {
return description;
}
}
@@ -34,6 +34,12 @@ public class OGuild {
@Column(name = "log_channel")
private String logChannel;
@Column(name = "guild_name")
private String guildName;
@Column(name = "guild_icon_hash")
private String guildIconHash;
@OneToMany(mappedBy = "guild")
private Set<OGuildSetting> guildSettings = new LinkedHashSet<>();
@@ -111,6 +117,22 @@ public class OGuild {
this.guildId = guildId;
}
public String getGuildName() {
return guildName;
}
public void setGuildName(String guildName) {
this.guildName = guildName;
}
public String getGuildIconHash() {
return guildIconHash;
}
public void setGuildIconHash(String guildIconHash) {
this.guildIconHash = guildIconHash;
}
/**
* Identity is the database id: two instances are equal when they have the same non-null id,
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
@@ -83,6 +83,25 @@ public class GuildService {
this.oGuild = guildRepository.save(current);
}
/**
* Mirrors the guild's name/icon into the database so they survive JDA cache eviction. Only
* writes when a value actually changed, to avoid needless updates on every event.
*
* @param name the current guild name
* @param iconHash the current guild icon hash, or {@code null} if the guild has no icon
*/
public void updateMetadata(String name, String iconHash) {
OGuild current = findOrCreateGuild(guildId);
if (Objects.equals(current.getGuildName(), name) && Objects.equals(current.getGuildIconHash(), iconHash)) {
return;
}
current.setGuildName(name);
current.setGuildIconHash(iconHash);
this.oGuild = guildRepository.save(current);
}
public void deleteGuild() {
guildRepository.deleteById(guildId);
blacklistService.removeGuildBlacklist();
@@ -23,12 +23,15 @@ import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.core.command.CommandRegistry;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.events.Event;
import net.dv8tion.jda.api.events.GenericEvent;
import net.dv8tion.jda.api.events.guild.GuildJoinEvent;
import net.dv8tion.jda.api.events.guild.GuildLeaveEvent;
import net.dv8tion.jda.api.events.guild.update.GuildUpdateIconEvent;
import net.dv8tion.jda.api.events.guild.update.GuildUpdateNameEvent;
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
@@ -81,6 +84,12 @@ public class EventsHandler implements EventListener {
Message message = event.getMessage();
String rawMessage = message.getContentDisplay().trim();
String prefix = settings.get("NETNEUTRALITY_BOT_PREFIX", "$");
if (event.isFromGuild()) {
String guildPrefix = guildServices.get(event.getGuild().getId()).getGuildSettingService().getOrDefault(GSettings.COMMAND_PREFIX);
if (!guildPrefix.isEmpty()) {
prefix = guildPrefix;
}
}
if (rawMessage.startsWith(prefix)) {
return new MessageCommandContext(event, settings);
}
@@ -96,12 +105,17 @@ public class EventsHandler implements EventListener {
addListener(MessageReactionAddEvent.class, EmojiTaskContext::new);
addGenericListener(GuildJoinEvent.class, event -> {
GuildService service = guildServices.get(event.getGuild().getId());
service.updateMetadata(event.getGuild().getName(), event.getGuild().getIconId());
if (service.isDebugEnabled()) {
LogHandler.sendMessageToDebugChannel(event.getGuild(), "Error logging enabled on this channel");
}
});
addGenericListener(GuildLeaveEvent.class, event -> onGuildLeave(event.getGuild().getId()));
addGenericListener(GuildUpdateNameEvent.class, event ->
guildServices.get(event.getGuild().getId()).updateMetadata(event.getNewName(), event.getGuild().getIconId()));
addGenericListener(GuildUpdateIconEvent.class, event ->
guildServices.get(event.getGuild().getId()).updateMetadata(event.getGuild().getName(), event.getNewIconId()));
}
/**
@@ -7,10 +7,13 @@ package me.bobert.netneutrality.http.server;
*/
import io.javalin.config.RoutesConfig;
import io.javalin.http.Context;
import io.javalin.http.Handler;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import io.javalin.openapi.HttpMethod;
import io.javalin.openapi.OpenApi;
import io.javalin.openapi.OpenApiContent;
import io.javalin.openapi.OpenApiResponse;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -31,38 +34,40 @@ public final class ActionRoutes {
public static void register(
RoutesConfig routes,
Supplier<JDA> jdaSupplier,
GuildServiceProvider guildServices,
Runnable shutdownHook
) {
routes.get("/actions/status", ctx -> {
JDA jda = GuildRoutes.jda(jdaSupplier);
ctx.json(Map.of(
"health", String.valueOf(jda.getStatus()),
"ping", String.valueOf(jda.getGatewayPing())
));
});
routes.get("/actions/status", ctx -> status(jdaSupplier, ctx));
Handler shutdown = ctx -> {
logger.info("Request to shutdown received");
ctx.result("Shutting down..");
scheduleShutdown(shutdownHook);
};
Handler shutdown = ctx -> shutdown(ctx, shutdownHook);
routes.post("/actions/shutdown", shutdown);
routes.get("/actions/shutdown", shutdown);
}
routes.get("/actions/settings/current/{guildId}", ctx -> {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
Map<String, String> settings = guildServices.get(guild.getId()).getGuildSettingService().snapshot();
@OpenApi(
path = "/actions/status",
methods = HttpMethod.GET,
summary = "Report the current Discord gateway health",
tags = {"Actions"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class))
)
private static void status(Supplier<JDA> jdaSupplier, Context ctx) {
JDA jda = GuildRoutes.jda(jdaSupplier);
ctx.json(Map.of(
"health", String.valueOf(jda.getStatus()),
"ping", String.valueOf(jda.getGatewayPing())
));
}
ctx.json(Map.of("status", "ok", "settings", settings));
});
routes.post("/actions/settings/reload/{guildId}", ctx -> {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
guildServices.get(guild.getId()).getGuildSettingService().reloadSettingsCache();
ctx.json(Map.of("status", "true"));
});
@OpenApi(
path = "/actions/shutdown",
methods = HttpMethod.POST,
summary = "Gracefully shut the bot down",
tags = {"Actions"},
responses = @OpenApiResponse(status = "200")
)
private static void shutdown(Context ctx, Runnable shutdownHook) {
logger.info("Request to shutdown received");
ctx.result("Shutting down..");
scheduleShutdown(shutdownHook);
}
/**
@@ -0,0 +1,57 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import io.javalin.http.Context;
import io.javalin.http.Handler;
import io.javalin.http.UnauthorizedResponse;
import me.bobert.netneutrality.utils.SettingsProvider;
import java.util.List;
import java.util.Objects;
/**
* Checks the {@code X-API-Key} header against the configured {@code NETNEUTRALITY_API_KEY} value.
*
* <p>Registered as a {@code before} filter on every route; public paths (liveness check, Swagger UI,
* OpenAPI JSON, and the Swagger UI's static webjar assets) are skipped so they stay reachable without a key.
*/
public final class ApiKeyFilter implements Handler {
private static final String HEADER = "X-API-Key";
private static final List<String> PUBLIC_PREFIXES = List.of("/openapi", "/swagger", "/webjars");
private static final List<String> PUBLIC_PATHS = List.of("/");
private final SettingsProvider settings;
public ApiKeyFilter(SettingsProvider settings) {
this.settings = Objects.requireNonNull(settings, "settings");
}
@Override
public void handle(Context ctx) {
if (isPublic(ctx.path())) {
return;
}
String expected = settings.get("NETNEUTRALITY_API_KEY");
String provided = ctx.header(HEADER);
if (expected == null || expected.isEmpty() || !expected.equals(provided)) {
throw new UnauthorizedResponse("Missing or invalid API key");
}
}
private static boolean isPublic(String path) {
if (PUBLIC_PATHS.contains(path)) {
return true;
}
return PUBLIC_PREFIXES.stream().anyMatch(path::startsWith);
}
}
@@ -0,0 +1,51 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import io.javalin.http.Context;
import net.dv8tion.jda.api.requests.RestAction;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Resolves a JDA entity from the in-memory cache when possible, falling back to a REST call
* (resolved asynchronously through Javalin's {@link Context#future}) otherwise.
*
* <p>JDA only keeps a short-lived, best-effort cache; this helper compensates for cache misses
* (evicted entries, not-yet-warmed guilds, etc.) without widening {@code MemberCachePolicy} or
* {@code CacheFlag}s.
*/
public final class CacheOrFetch {
private CacheOrFetch() {
}
/**
* Resolves a value, returning the cached one synchronously if present, otherwise falling back
* to the given REST action and responding once it completes.
*
* @param ctx the request context
* @param cached supplies the cached value, or {@code null} on a cache miss
* @param restAction supplies the REST fallback when the cache misses
* @param onResolved called with the resolved value (from cache or REST) to build the response
* @param <T> the resolved entity type
*/
public static <T> void resolve(Context ctx, Supplier<T> cached, Supplier<RestAction<T>> restAction, Function<T, Object> onResolved) {
T value = cached.get();
if (value != null) {
ctx.json(onResolved.apply(value));
return;
}
ctx.future(() -> restAction.get().submit().thenApply(resolved -> {
ctx.json(onResolved.apply(resolved));
return null;
}));
}
}
@@ -10,10 +10,18 @@ import io.javalin.config.RoutesConfig;
import io.javalin.http.Context;
import io.javalin.http.NotFoundResponse;
import io.javalin.http.ServiceUnavailableResponse;
import io.javalin.openapi.HttpMethod;
import io.javalin.openapi.OpenApi;
import io.javalin.openapi.OpenApiContent;
import io.javalin.openapi.OpenApiResponse;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
@@ -26,23 +34,282 @@ public final class GuildRoutes {
}
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier) {
routes.get("/guilds/owner", ctx -> ctx.json(
routes.get("/guilds", ctx -> guilds(jdaSupplier, ctx));
routes.get("/guilds/owner", ctx -> owner(jdaSupplier, ctx));
routes.get("/guilds/owner/{guildId}", ctx -> ownerOf(jdaSupplier, ctx));
routes.get("/guilds/channels/{guildId}", ctx -> channels(jdaSupplier, ctx));
routes.get("/guilds/roles/{guildId}", ctx -> roles(jdaSupplier, ctx));
routes.get("/guilds/members/{guildId}/{userId}", ctx -> member(jdaSupplier, ctx));
routes.get("/guilds/members/{guildId}/{userId}/roles", ctx -> memberRoles(jdaSupplier, ctx));
routes.get("/guilds/voice-channels/{guildId}", ctx -> voiceChannels(jdaSupplier, ctx));
routes.get("/guilds/voice-channels/{guildId}/{channelId}", ctx -> voiceChannel(jdaSupplier, ctx));
}
@OpenApi(
path = "/guilds",
methods = HttpMethod.GET,
summary = "List every guild the bot is currently in",
tags = {"Guilds"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class))
)
private static void guilds(Supplier<JDA> jdaSupplier, Context ctx) {
ctx.json(
jda(jdaSupplier).getGuilds().stream()
.map(GuildRoutes::guildSummary)
.toList()
);
}
/**
* Builds the {@code /guilds} entry for a guild, falling back to a REST fetch for the owner id
* when it is not present in the cache.
*/
private static Map<String, Object> guildSummary(Guild guild) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("guild_id", guild.getId());
summary.put("name", guild.getName());
summary.put("icon_url", guild.getIconUrl());
summary.put("member_count", guild.getMemberCount());
summary.put("owner_id", ownerId(guild));
return summary;
}
/**
* Resolves the owner id from cache when available, otherwise via a REST fallback.
*/
private static String ownerId(Guild guild) {
Member owner = guild.getOwner();
if (owner != null) {
return owner.getId();
}
return guild.getOwnerId();
}
@OpenApi(
path = "/guilds/owner",
methods = HttpMethod.GET,
summary = "List every bot guild with its owner id",
tags = {"Guilds"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class))
)
private static void owner(Supplier<JDA> jdaSupplier, Context ctx) {
ctx.json(
jda(jdaSupplier).getGuilds().stream()
.map(guild -> ordered("guild_id", guild.getId(), "owner_id", guild.getOwnerId()))
.toList()
));
);
}
routes.get("/guilds/channels/{guildId}", ctx -> ctx.json(
@OpenApi(
path = "/guilds/owner/{guildId}",
methods = HttpMethod.GET,
summary = "Get a guild's owner, falling back to a REST fetch on a member cache miss",
tags = {"Guilds"},
pathParams = @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
}
)
private static void ownerOf(Supplier<JDA> jdaSupplier, Context ctx) {
Guild guild = guild(jdaSupplier, ctx);
CacheOrFetch.<Member>resolve(
ctx,
guild::getOwner,
guild::retrieveOwner,
owner -> ordered("guild_id", guild.getId(), "owner_id", owner.getId())
);
}
@OpenApi(
path = "/guilds/channels/{guildId}",
methods = HttpMethod.GET,
summary = "List the text channels of a guild",
tags = {"Guilds"},
pathParams = @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
}
)
private static void channels(Supplier<JDA> jdaSupplier, Context ctx) {
ctx.json(
guild(jdaSupplier, ctx).getTextChannels().stream()
.map(channel -> ordered("channel_id", channel.getId(), "channel_name", channel.getName()))
.toList()
));
);
}
routes.get("/guilds/roles/{guildId}", ctx -> ctx.json(
@OpenApi(
path = "/guilds/roles/{guildId}",
methods = HttpMethod.GET,
summary = "List the roles of a guild",
tags = {"Guilds"},
pathParams = @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
}
)
private static void roles(Supplier<JDA> jdaSupplier, Context ctx) {
ctx.json(
guild(jdaSupplier, ctx).getRoles().stream()
.map(role -> ordered("channel_id", role.getId(), "channel_name", role.getName()))
.toList()
));
);
}
@OpenApi(
path = "/guilds/members/{guildId}/{userId}",
methods = HttpMethod.GET,
summary = "Get a guild member, falling back to a REST fetch on a member cache miss",
tags = {"Guilds"},
pathParams = {
@io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
@io.javalin.openapi.OpenApiParam(name = "userId", description = "The Discord user id")
},
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
}
)
private static void member(Supplier<JDA> jdaSupplier, Context ctx) {
Guild guild = guild(jdaSupplier, ctx);
String userId = ctx.pathParam("userId");
CacheOrFetch.<Member>resolve(
ctx,
() -> guild.getMemberById(userId),
() -> guild.retrieveMemberById(userId),
GuildRoutes::memberSummary
);
}
@OpenApi(
path = "/guilds/members/{guildId}/{userId}/roles",
methods = HttpMethod.GET,
summary = "List the roles of a guild member, falling back to a REST fetch on a member cache miss",
tags = {"Guilds"},
pathParams = {
@io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
@io.javalin.openapi.OpenApiParam(name = "userId", description = "The Discord user id")
},
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
}
)
private static void memberRoles(Supplier<JDA> jdaSupplier, Context ctx) {
Guild guild = guild(jdaSupplier, ctx);
String userId = ctx.pathParam("userId");
CacheOrFetch.<Member>resolve(
ctx,
() -> guild.getMemberById(userId),
() -> guild.retrieveMemberById(userId),
member -> memberRolesSummary(guild, member)
);
}
/**
* Builds the {@code /guilds/members/{guildId}/{userId}} response for a resolved member.
*/
private static Map<String, Object> memberSummary(Member member) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("user_id", member.getId());
summary.put("username", member.getUser().getName());
summary.put("nickname", member.getNickname());
summary.put("role_ids", member.getRoles().stream().map(Role::getId).toList());
return summary;
}
/**
* Builds the {@code /guilds/members/{guildId}/{userId}/roles} response for a resolved member.
*/
private static Map<String, Object> memberRolesSummary(Guild guild, Member member) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("guild_id", guild.getId());
summary.put("user_id", member.getId());
List<Map<String, String>> roles = member.getRoles().stream()
.map(role -> ordered("role_id", role.getId(), "role_name", role.getName()))
.toList();
summary.put("roles", roles);
return summary;
}
@OpenApi(
path = "/guilds/voice-channels/{guildId}",
methods = HttpMethod.GET,
summary = "List the voice channels of a guild, including the members currently connected",
tags = {"Guilds"},
pathParams = @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
}
)
private static void voiceChannels(Supplier<JDA> jdaSupplier, Context ctx) {
ctx.json(
guild(jdaSupplier, ctx).getVoiceChannels().stream()
.map(GuildRoutes::voiceChannelSummary)
.toList()
);
}
@OpenApi(
path = "/guilds/voice-channels/{guildId}/{channelId}",
methods = HttpMethod.GET,
summary = "Get a voice channel, including the members currently connected",
tags = {"Guilds"},
pathParams = {
@io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
@io.javalin.openapi.OpenApiParam(name = "channelId", description = "The Discord voice channel id")
},
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
}
)
private static void voiceChannel(Supplier<JDA> jdaSupplier, Context ctx) {
ctx.json(voiceChannelSummary(voiceChannel(guild(jdaSupplier, ctx), ctx)));
}
/**
* Resolves the {@code channelId} path parameter as a voice channel of the given guild, or
* fails with 404.
*/
private static VoiceChannel voiceChannel(Guild guild, Context ctx) {
VoiceChannel channel = guild.getVoiceChannelById(ctx.pathParam("channelId"));
if (channel == null) {
throw new NotFoundResponse("Unknown voice channel");
}
return channel;
}
/**
* Builds the {@code /guilds/voice-channels} entry for a voice channel, including the members
* currently connected to it.
*/
private static Map<String, Object> voiceChannelSummary(VoiceChannel channel) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("channel_id", channel.getId());
summary.put("channel_name", channel.getName());
summary.put("user_limit", channel.getUserLimit());
summary.put("member_count", channel.getMembers().size());
summary.put("members", channel.getMembers().stream().map(GuildRoutes::voiceMemberSummary).toList());
return summary;
}
/**
* Builds a minimal member entry for the members connected to a voice channel.
*/
private static Map<String, String> voiceMemberSummary(Member member) {
Map<String, String> summary = new LinkedHashMap<>();
summary.put("user_id", member.getId());
summary.put("username", member.getUser().getName());
summary.put("nickname", member.getNickname());
return summary;
}
/**
@@ -0,0 +1,138 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import io.javalin.config.RoutesConfig;
import io.javalin.http.BadRequestResponse;
import io.javalin.http.Context;
import io.javalin.openapi.HttpMethod;
import io.javalin.openapi.OpenApi;
import io.javalin.openapi.OpenApiContent;
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.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.Map;
import java.util.function.Supplier;
/**
* Registers the {@code /settings/*} endpoints on a {@link RoutesConfig} instance.
*/
public final class SettingsRoutes {
private SettingsRoutes() {
}
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices) {
routes.get("/settings/catalog", SettingsRoutes::settingsCatalog);
routes.get("/settings/current/{guildId}", ctx -> settingsCurrent(jdaSupplier, guildServices, ctx));
routes.put("/settings/current/{guildId}/{name}", ctx -> settingsUpdate(jdaSupplier, guildServices, ctx));
routes.put("/settings/reload/{guildId}", ctx -> settingsReload(jdaSupplier, guildServices, ctx));
}
@OpenApi(
path = "/settings/catalog",
methods = HttpMethod.GET,
summary = "List every known per-guild setting with its default value and description",
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()
))
.toList()
);
}
@OpenApi(
path = "/settings/current/{guildId}",
methods = HttpMethod.GET,
summary = "Get the current per-guild setting overrides",
tags = {"Settings"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
}
)
private static void settingsCurrent(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
Map<String, String> settings = guildServices.get(guild.getId()).getGuildSettingService().snapshot();
ctx.json(Map.of("status", "ok", "settings", settings));
}
@OpenApi(
path = "/settings/current/{guildId}/{name}",
methods = HttpMethod.PUT,
summary = "Update (or reset) a per-guild setting",
tags = {"Settings"},
pathParams = {
@OpenApiParam(name = "guildId", description = "The Discord guild id"),
@OpenApiParam(name = "name", description = "The setting name, as returned by /settings/catalog")
},
requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = Map.class)),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)),
@OpenApiResponse(status = "400", content = @OpenApiContent(from = Map.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
}
)
private static void settingsUpdate(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
GSettings setting = setting(ctx.pathParam("name"));
String value = ctx.bodyAsClass(Map.class).get("value") instanceof String s ? s : null;
GuildSettingService settingService = guildServices.get(guild.getId()).getGuildSettingService();
settingService.set(setting, value);
ctx.json(Map.of("status", "ok", "name", setting.getName(), "value", settingService.getOrDefault(setting)));
}
/**
* Resolves the {@code name} path parameter to a known {@link GSettings} or fails with 400.
*/
private static GSettings setting(String name) {
try {
return GSettings.valueOf(name);
} catch (IllegalArgumentException e) {
throw new BadRequestResponse("Unknown setting: " + name);
}
}
@OpenApi(
path = "/settings/reload/{guildId}",
methods = HttpMethod.PUT,
summary = "Reload a guild's cached settings from the database",
tags = {"Settings"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
}
)
private static void settingsReload(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
guildServices.get(guild.getId()).getGuildSettingService().reloadSettingsCache();
ctx.json(Map.of("status", true));
}
}
@@ -0,0 +1,83 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import io.javalin.config.RoutesConfig;
import io.javalin.http.Context;
import io.javalin.openapi.HttpMethod;
import io.javalin.openapi.OpenApi;
import io.javalin.openapi.OpenApiContent;
import io.javalin.openapi.OpenApiParam;
import io.javalin.openapi.OpenApiResponse;
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import java.util.Map;
import java.util.function.Supplier;
/**
* Registers the {@code /stats/*} endpoints on a {@link RoutesConfig} instance.
*/
public final class StatsRoutes {
private StatsRoutes() {
}
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices) {
routes.get("/stats/bot", ctx -> bot(jdaSupplier, ctx));
routes.get("/stats/guilds/{guildId}", ctx -> guild(jdaSupplier, guildServices, ctx));
}
@OpenApi(
path = "/stats/bot",
methods = HttpMethod.GET,
summary = "Report bot-wide stats (guild count, gateway health, uptime)",
tags = {"Stats"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class))
)
private static void bot(Supplier<JDA> jdaSupplier, Context ctx) {
JDA jda = GuildRoutes.jda(jdaSupplier);
ctx.json(Map.of(
"guild_count", jda.getGuilds().size(),
"gateway_ping", jda.getGatewayPing(),
"status", String.valueOf(jda.getStatus()),
"uptime_seconds", uptimeSeconds()
));
}
@OpenApi(
path = "/stats/guilds/{guildId}",
methods = HttpMethod.GET,
summary = "Report per-guild stats (member/channel/role counts, debug flag, blacklist size)",
tags = {"Stats"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
}
)
private static void guild(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
GuildService guildService = guildServices.get(guild.getId());
ctx.json(Map.of(
"guild_id", guild.getId(),
"member_count", guild.getMemberCount(),
"channel_count", guild.getChannels().size(),
"role_count", guild.getRoles().size(),
"debug_enabled", guildService.isDebugEnabled(),
"blacklist_size", guildService.getBlacklistService().list().size()
));
}
private static long uptimeSeconds() {
return java.lang.management.ManagementFactory.getRuntimeMXBean().getUptime() / 1000;
}
}
@@ -8,6 +8,8 @@ package me.bobert.netneutrality.http.server;
import io.javalin.Javalin;
import io.javalin.http.HttpResponseException;
import io.javalin.openapi.plugin.OpenApiPlugin;
import io.javalin.openapi.plugin.swagger.SwaggerPlugin;
import me.bobert.netneutrality.bootstrap.Lifecycle;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.utils.SettingsProvider;
@@ -59,9 +61,22 @@ public class WebApi implements Lifecycle {
app = Javalin.create(config -> {
config.startup.showJavalinBanner = false;
config.registerPlugin(new OpenApiPlugin(pluginConfig -> pluginConfig.withDefinitionConfiguration(
(version, definition) -> definition
.info(info -> info.title("Net Neutrality API"))
.withApiKeyAuth("ApiKeyAuth", "X-API-Key")
.withGlobalSecurity("ApiKeyAuth")
)));
config.registerPlugin(new SwaggerPlugin());
// API key gate for every route except the liveness check and the docs themselves.
config.routes.before(new ApiKeyFilter(settings));
config.routes.get("/", ctx -> ctx.html(LIVENESS_HTML));
GuildRoutes.register(config.routes, jdaSupplier);
ActionRoutes.register(config.routes, jdaSupplier, guildServices, shutdownHook);
ActionRoutes.register(config.routes, jdaSupplier, shutdownHook);
SettingsRoutes.register(config.routes, jdaSupplier, guildServices);
StatsRoutes.register(config.routes, jdaSupplier, guildServices);
config.routes.exception(HttpResponseException.class, (e, ctx) ->
ctx.status(e.getStatus()).json(Map.of("status", "error", "message", e.getMessage()))