diff --git a/bot.properties.example b/bot.properties.example index 8233f0f..83cbb0e 100644 --- a/bot.properties.example +++ b/bot.properties.example @@ -6,6 +6,8 @@ NETNEUTRALITY_BOT_PREFIX=$ NETNEUTRALITY_BOT_OWNER= NETNEUTRALITY_BOT_INVITE= NETNEUTRALITY_API_PORT=9000 +# Optional: number of gateway shards. Leave unset to use Discord's recommended count. +# NETNEUTRALITY_SHARD_TOTAL=1 MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 diff --git a/src/main/java/me/bobert/netneutrality/App.java b/src/main/java/me/bobert/netneutrality/App.java index f3e4dab..8c388e9 100644 --- a/src/main/java/me/bobert/netneutrality/App.java +++ b/src/main/java/me/bobert/netneutrality/App.java @@ -6,7 +6,6 @@ package me.bobert.netneutrality; * https://discord.gg/qUa6zNFZsd */ -import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener; import io.sentry.Sentry; import me.bobert.netneutrality.bootstrap.ApplicationContext; import me.bobert.netneutrality.bootstrap.Lifecycle; @@ -39,11 +38,8 @@ import me.bobert.netneutrality.db.repository.GuildSettingRepository; import me.bobert.netneutrality.db.repository.TaskRepository; import me.bobert.netneutrality.utils.Settings; import me.bobert.netneutrality.http.WebModule; -import net.dv8tion.jda.api.JDA; -import net.dv8tion.jda.api.JDABuilder; +import me.bobert.netneutrality.shard.ShardService; import net.dv8tion.jda.api.entities.Activity; -import net.dv8tion.jda.api.exceptions.InvalidTokenException; -import net.dv8tion.jda.api.requests.GatewayIntent; import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -53,8 +49,6 @@ public class App { private static final Logger logger = LogManager.getLogger(App.class); private static App instance; private final Settings settings; - private JDA jda; - private MusicService music; private EventPublisher events = NoopEventPublisher.INSTANCE; private final ApplicationContext context = new ApplicationContext(); @@ -157,21 +151,21 @@ public class App { } // Music sources (priority 40): initialise the shared player manager; disconnect on shutdown. - music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors, events)); + MusicService music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors, events)); BotState.bind(guildServices, music); - // JDA gateway; disconnected on shutdown. - context.register(lifecycle(60, this::initJDA, this::disconnect)); + // JDA shards (priority 60); the events handler is attached to every shard before it connects. + ShardService shards = context.register(new ShardService( + settings, music, new EventsHandler(settings, guildServices, commands, tasks, events))); - // Register slash commands, attach the event handler and set the presence once JDA is ready. + // Register global slash commands once (shared by all shards) and set the presence. context.register(lifecycle(65, () -> { - commands.registerSlashCommands(jda); - jda.addEventListener(new EventsHandler(settings, guildServices, commands, tasks, events)); - jda.getPresence().setActivity(Activity.watching("you")); + commands.registerSlashCommands(shards.getAnyShard()); + shards.setActivity(Activity.watching("you")); }, () -> { })); // Web API (priority 70) — Lifecycle with graceful stop. - context.register(new WebApi(settings, guildServices, music, this::getJDA, this::shutdown)); + context.register(new WebApi(settings, guildServices, music, shards, this::shutdown)); } private static Lifecycle lifecycle(int priority, ThrowingRunnable start, Runnable stop) { @@ -202,52 +196,10 @@ public class App { return instance; } - public void initJDA() { - String token = settings.get("NETNEUTRALITY_BOT_TOKEN"); - if (token == null) { - SessionManager sessions = context.get(SessionManager.class); - if (sessions != null) { - sessions.stop(); - } - throw new RuntimeException("No bot token provided, exiting."); - } - - try { - jda = JDABuilder - .createDefault(token) - .enableIntents( - GatewayIntent.GUILD_MEMBERS, - GatewayIntent.GUILD_MESSAGE_REACTIONS, - GatewayIntent.GUILD_VOICE_STATES, - GatewayIntent.MESSAGE_CONTENT) - // Voice state/server updates are handed to Lavalink instead of JDA's audio stack; - // DAVE encryption is handled by the node. - .setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(music.getClient())) - .setActivity(Activity.watching("me load...")) - .build(); - jda.awaitReady(); - } catch (InvalidTokenException | IllegalArgumentException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - LogHandler.log(e); - throw new IllegalStateException("Unable to connect to Discord", e); - } - } public void shutdown() { logger.info("[Net Neutrality] Shutting down..."); context.stop(); logger.info("[Net Neutrality] Shutdown complete"); } - - public JDA getJDA() { - return jda; - } - - private void disconnect() { - if (jda != null) { - jda.shutdown(); - } - } } diff --git a/src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java b/src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java index 875a8e0..fc89343 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java +++ b/src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java @@ -14,12 +14,11 @@ import io.javalin.openapi.OpenApi; import io.javalin.openapi.OpenApiContent; import io.javalin.openapi.OpenApiResponse; import me.bobert.netneutrality.http.server.dto.StatusResponse; -import net.dv8tion.jda.api.JDA; +import me.bobert.netneutrality.shard.ShardProvider; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.util.Map; -import java.util.function.Supplier; /** * Registers the {@code /actions/*} endpoints on a {@link RoutesConfig} instance. @@ -34,10 +33,10 @@ public final class ActionRoutes { public static void register( RoutesConfig routes, - Supplier jdaSupplier, + ShardProvider shards, Runnable shutdownHook ) { - routes.get("/actions/status", ctx -> status(jdaSupplier, ctx)); + routes.get("/actions/status", ctx -> status(shards, ctx)); Handler shutdown = ctx -> shutdown(ctx, shutdownHook); routes.post("/actions/shutdown", shutdown); @@ -50,11 +49,12 @@ public final class ActionRoutes { tags = {"Actions"}, responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = StatusResponse.class)) ) - private static void status(Supplier jdaSupplier, Context ctx) { - JDA jda = GuildRoutes.jda(jdaSupplier); + private static void status(ShardProvider shards, Context ctx) { + ShardProvider provider = GuildRoutes.shards(shards); ctx.json(Map.of( - "health", String.valueOf(jda.getStatus()), - "ping", String.valueOf(jda.getGatewayPing()) + "health", String.valueOf(provider.getWorstStatus()), + "ping", String.valueOf((long) provider.getAveragePing()), + "shard_count", provider.getShardCount() )); } diff --git a/src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java b/src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java index 0ecf424..659b2df 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java +++ b/src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java @@ -35,7 +35,7 @@ import me.bobert.netneutrality.http.server.dto.RoleSummaryResponse; import me.bobert.netneutrality.http.server.dto.VoiceChannelSummaryResponse; import me.bobert.netneutrality.utils.MusicAccess; import me.bobert.netneutrality.utils.UserFunction; -import net.dv8tion.jda.api.JDA; +import me.bobert.netneutrality.shard.ShardProvider; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Role; @@ -47,7 +47,6 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.function.Function; -import java.util.function.Supplier; /** * Registers the read-only {@code /guilds/*} endpoints, as well as the debug/logging/log-channel @@ -58,22 +57,22 @@ public final class GuildRoutes { private GuildRoutes() { } - public static void register(RoutesConfig routes, Supplier jdaSupplier, GuildServiceProvider guildServices) { - routes.get("/guilds", ctx -> guilds(jdaSupplier, guildServices, ctx)); - routes.get("/guilds/owner", ctx -> owner(jdaSupplier, ctx)); - routes.get("/guilds/{guildId}/owner", ctx -> ownerOf(jdaSupplier, ctx)); - routes.get("/guilds/{guildId}/channels", ctx -> channels(jdaSupplier, ctx)); - routes.get("/guilds/{guildId}/roles", ctx -> roles(jdaSupplier, ctx)); - routes.get("/guilds/{guildId}/members/{userId}/access", ctx -> memberAccess(jdaSupplier, guildServices, ctx)); - routes.get("/guilds/{guildId}/members/{userId}", ctx -> member(jdaSupplier, ctx)); - routes.get("/guilds/{guildId}/members/{userId}/roles", ctx -> memberRoles(jdaSupplier, ctx)); - routes.get("/guilds/{guildId}/members/{userId}/admin", ctx -> memberAdmin(jdaSupplier, ctx)); - routes.get("/guilds/{guildId}/voice-channels", ctx -> voiceChannels(jdaSupplier, ctx)); - routes.get("/guilds/{guildId}/voice-channels/{channelId}", ctx -> voiceChannel(jdaSupplier, ctx)); - routes.get("/guilds/{guildId}/config", ctx -> config(jdaSupplier, guildServices, ctx)); - routes.put("/guilds/{guildId}/debug", ctx -> updateDebug(jdaSupplier, guildServices, ctx)); - routes.put("/guilds/{guildId}/logging", ctx -> updateLogging(jdaSupplier, guildServices, ctx)); - routes.put("/guilds/{guildId}/log-channel", ctx -> updateLogChannel(jdaSupplier, guildServices, ctx)); + public static void register(RoutesConfig routes, ShardProvider shards, GuildServiceProvider guildServices) { + routes.get("/guilds", ctx -> guilds(shards, guildServices, ctx)); + routes.get("/guilds/owner", ctx -> owner(shards, ctx)); + routes.get("/guilds/{guildId}/owner", ctx -> ownerOf(shards, ctx)); + routes.get("/guilds/{guildId}/channels", ctx -> channels(shards, ctx)); + routes.get("/guilds/{guildId}/roles", ctx -> roles(shards, ctx)); + routes.get("/guilds/{guildId}/members/{userId}/access", ctx -> memberAccess(shards, guildServices, ctx)); + routes.get("/guilds/{guildId}/members/{userId}", ctx -> member(shards, ctx)); + routes.get("/guilds/{guildId}/members/{userId}/roles", ctx -> memberRoles(shards, ctx)); + routes.get("/guilds/{guildId}/members/{userId}/admin", ctx -> memberAdmin(shards, ctx)); + routes.get("/guilds/{guildId}/voice-channels", ctx -> voiceChannels(shards, ctx)); + routes.get("/guilds/{guildId}/voice-channels/{channelId}", ctx -> voiceChannel(shards, ctx)); + routes.get("/guilds/{guildId}/config", ctx -> config(shards, guildServices, ctx)); + routes.put("/guilds/{guildId}/debug", ctx -> updateDebug(shards, guildServices, ctx)); + routes.put("/guilds/{guildId}/logging", ctx -> updateLogging(shards, guildServices, ctx)); + routes.put("/guilds/{guildId}/log-channel", ctx -> updateLogChannel(shards, guildServices, ctx)); } @OpenApi( @@ -87,15 +86,15 @@ public final class GuildRoutes { @OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void guilds(Supplier jdaSupplier, GuildServiceProvider guildServices, Context ctx) { + private static void guilds(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) { String userId = ctx.queryParam("user_id"); if (userId != null) { - userGuilds(jda(jdaSupplier), guildServices, userId, ctx); + userGuilds(shards(shards), guildServices, userId, ctx); return; } ctx.json( - jda(jdaSupplier).getGuilds().stream() + shards(shards).getGuilds().stream() .map(GuildRoutes::guildSummary) .toList() ); @@ -105,12 +104,12 @@ public final class GuildRoutes { * Lists the guilds shared by the bot and the given user, with the user's access flags. Members * missing from the cache are resolved through REST; a failed lookup means "not a member". */ - private static void userGuilds(JDA jda, GuildServiceProvider guildServices, String userId, Context ctx) { + private static void userGuilds(ShardProvider shards, GuildServiceProvider guildServices, String userId, Context ctx) { if (!userId.matches("\\d{1,20}")) { throw new BadRequestResponse("user_id must be a numeric Discord id"); } - List>>> lookups = jda.getGuilds().stream() + List>>> lookups = shards.getGuilds().stream() .map(guild -> resolveMember(guild, userId) .thenApply(member -> Optional.ofNullable(member) .map(m -> userGuildEntry(guild, m, guildServices.get(guild.getId())))) @@ -176,9 +175,9 @@ public final class GuildRoutes { tags = {"Guilds"}, responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = GuildOwnerEntryResponse[].class)) ) - private static void owner(Supplier jdaSupplier, Context ctx) { + private static void owner(ShardProvider shards, Context ctx) { ctx.json( - jda(jdaSupplier).getGuilds().stream() + shards(shards).getGuilds().stream() .map(guild -> ordered("guild_id", guild.getId(), "owner_id", guild.getOwnerId())) .toList() ); @@ -195,8 +194,8 @@ public final class GuildRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void ownerOf(Supplier jdaSupplier, Context ctx) { - Guild guild = guild(jdaSupplier, ctx); + private static void ownerOf(ShardProvider shards, Context ctx) { + Guild guild = guild(shards, ctx); CacheOrFetch.resolve( ctx, @@ -217,9 +216,9 @@ public final class GuildRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void channels(Supplier jdaSupplier, Context ctx) { + private static void channels(ShardProvider shards, Context ctx) { ctx.json( - guild(jdaSupplier, ctx).getTextChannels().stream() + guild(shards, ctx).getTextChannels().stream() .map(channel -> ordered("channel_id", channel.getId(), "channel_name", channel.getName())) .toList() ); @@ -236,9 +235,9 @@ public final class GuildRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void roles(Supplier jdaSupplier, Context ctx) { + private static void roles(ShardProvider shards, Context ctx) { ctx.json( - guild(jdaSupplier, ctx).getRoles().stream() + guild(shards, ctx).getRoles().stream() .filter(role -> !role.isManaged() && !role.isPublicRole()) .map(role -> ordered("role_id", role.getId(), "role_name", role.getName())) .toList() @@ -259,8 +258,8 @@ public final class GuildRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void member(Supplier jdaSupplier, Context ctx) { - Guild guild = guild(jdaSupplier, ctx); + private static void member(ShardProvider shards, Context ctx) { + Guild guild = guild(shards, ctx); String userId = ctx.pathParam("userId"); CacheOrFetch.resolve( @@ -285,8 +284,8 @@ public final class GuildRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void memberRoles(Supplier jdaSupplier, Context ctx) { - Guild guild = guild(jdaSupplier, ctx); + private static void memberRoles(ShardProvider shards, Context ctx) { + Guild guild = guild(shards, ctx); String userId = ctx.pathParam("userId"); CacheOrFetch.resolve( @@ -311,8 +310,8 @@ public final class GuildRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void memberAdmin(Supplier jdaSupplier, Context ctx) { - Guild guild = guild(jdaSupplier, ctx); + private static void memberAdmin(ShardProvider shards, Context ctx) { + Guild guild = guild(shards, ctx); String userId = ctx.pathParam("userId"); CacheOrFetch.resolve( @@ -343,8 +342,8 @@ public final class GuildRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void memberAccess(Supplier jdaSupplier, GuildServiceProvider guildServices, Context ctx) { - Guild guild = guild(jdaSupplier, ctx); + private static void memberAccess(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) { + Guild guild = guild(shards, ctx); String userId = ctx.pathParam("userId"); Function> toBody = member -> { @@ -413,9 +412,9 @@ public final class GuildRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void voiceChannels(Supplier jdaSupplier, Context ctx) { + private static void voiceChannels(ShardProvider shards, Context ctx) { ctx.json( - guild(jdaSupplier, ctx).getVoiceChannels().stream() + guild(shards, ctx).getVoiceChannels().stream() .map(GuildRoutes::voiceChannelSummary) .toList() ); @@ -435,8 +434,8 @@ public final class GuildRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void voiceChannel(Supplier jdaSupplier, Context ctx) { - ctx.json(voiceChannelSummary(voiceChannel(guild(jdaSupplier, ctx), ctx))); + private static void voiceChannel(ShardProvider shards, Context ctx) { + ctx.json(voiceChannelSummary(voiceChannel(guild(shards, ctx), ctx))); } /** @@ -497,8 +496,8 @@ public final class GuildRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void config(Supplier jdaSupplier, GuildServiceProvider guildServices, Context ctx) { - Guild guild = guild(jdaSupplier, ctx); + private static void config(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) { + Guild guild = guild(shards, ctx); ctx.json(guildConfig(guild, guildServices)); } @@ -515,8 +514,8 @@ public final class GuildRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void updateDebug(Supplier jdaSupplier, GuildServiceProvider guildServices, Context ctx) { - Guild guild = guild(jdaSupplier, ctx); + private static void updateDebug(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) { + Guild guild = guild(shards, ctx); boolean value = booleanValue(ctx); GuildService guildService = guildServices.get(guild.getId()); @@ -538,8 +537,8 @@ public final class GuildRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void updateLogging(Supplier jdaSupplier, GuildServiceProvider guildServices, Context ctx) { - Guild guild = guild(jdaSupplier, ctx); + private static void updateLogging(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) { + Guild guild = guild(shards, ctx); boolean value = booleanValue(ctx); GuildService guildService = guildServices.get(guild.getId()); @@ -561,8 +560,8 @@ public final class GuildRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void updateLogChannel(Supplier jdaSupplier, GuildServiceProvider guildServices, Context ctx) { - Guild guild = guild(jdaSupplier, ctx); + private static void updateLogChannel(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) { + Guild guild = guild(shards, ctx); String channelId = ctx.bodyAsClass(Map.class).get("value") instanceof String s ? s : null; if (channelId != null && !channelId.isBlank() && guild.getTextChannelById(channelId) == null) { @@ -600,21 +599,20 @@ public final class GuildRoutes { } /** - * Resolves the JDA client or fails with 503 while it is not ready yet. + * Resolves the shard provider or fails with 503 while the shards are not ready yet. */ - static JDA jda(Supplier jdaSupplier) { - JDA jda = jdaSupplier.get(); - if (jda == null) { + static ShardProvider shards(ShardProvider shards) { + if (shards == null || !shards.isReady()) { throw new ServiceUnavailableResponse("Discord client is not ready"); } - return jda; + return shards; } /** * Resolves the {@code guildId} path parameter or fails with 404. */ - static Guild guild(Supplier jdaSupplier, Context ctx) { - Guild guild = jda(jdaSupplier).getGuildById(ctx.pathParam("guildId")); + static Guild guild(ShardProvider shards, Context ctx) { + Guild guild = shards(shards).getGuildById(ctx.pathParam("guildId")); if (guild == null) { throw new NotFoundResponse("Unknown guild"); } diff --git a/src/main/java/me/bobert/netneutrality/http/server/MusicRoutes.java b/src/main/java/me/bobert/netneutrality/http/server/MusicRoutes.java index a904b5a..ce7ea7a 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/MusicRoutes.java +++ b/src/main/java/me/bobert/netneutrality/http/server/MusicRoutes.java @@ -45,7 +45,7 @@ import me.bobert.netneutrality.music.QueueActionResult; import me.bobert.netneutrality.music.SourceFinder; import me.bobert.netneutrality.music.TrackInfo; import me.bobert.netneutrality.utils.MusicUtils; -import net.dv8tion.jda.api.JDA; +import me.bobert.netneutrality.shard.ShardProvider; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; @@ -55,7 +55,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; -import java.util.function.Supplier; /** * Registers the {@code /music/*} endpoints on a {@link RoutesConfig} instance. @@ -69,26 +68,26 @@ public final class MusicRoutes { private MusicRoutes() { } - public static void register(RoutesConfig routes, Supplier jdaSupplier, MusicService musicService) { + public static void register(RoutesConfig routes, ShardProvider shards, MusicService musicService) { routes.get("/music/sources", MusicRoutes::sources); - routes.get("/music/{guildId}/status", ctx -> status(jdaSupplier, musicService, ctx)); - routes.get("/music/{guildId}/queue", ctx -> queue(jdaSupplier, musicService, ctx)); - routes.post("/music/{guildId}/skip", ctx -> skip(jdaSupplier, musicService, ctx)); - routes.post("/music/{guildId}/stop", ctx -> stop(jdaSupplier, musicService, ctx)); - routes.post("/music/{guildId}/pause", ctx -> pause(jdaSupplier, musicService, ctx)); - routes.post("/music/{guildId}/resume", ctx -> resumePlayback(jdaSupplier, musicService, ctx)); - routes.get("/music/{guildId}/volume", ctx -> getVolume(jdaSupplier, musicService, ctx)); - routes.put("/music/{guildId}/volume", ctx -> volume(jdaSupplier, musicService, ctx)); - routes.put("/music/{guildId}/seek", ctx -> seek(jdaSupplier, musicService, ctx)); - routes.get("/music/{guildId}/vote-skip", ctx -> voteSkipStatus(jdaSupplier, musicService, ctx)); - routes.post("/music/{guildId}/vote-skip/{userId}", ctx -> voteSkip(jdaSupplier, musicService, ctx)); - routes.get("/music/{guildId}/dj/{userId}", ctx -> dj(jdaSupplier, musicService, ctx)); - routes.post("/music/{guildId}/queue", ctx -> play(jdaSupplier, musicService, ctx)); - routes.delete("/music/{guildId}/queue", ctx -> deleteQueue(jdaSupplier, musicService, ctx)); - routes.patch("/music/{guildId}/queue/reorder", ctx -> reorder(jdaSupplier, musicService, ctx)); - routes.post("/music/{guildId}/search", ctx -> search(jdaSupplier, musicService, ctx)); - routes.post("/music/{guildId}/leave", ctx -> leave(jdaSupplier, musicService, ctx)); - routes.post("/music/{guildId}/shuffle", ctx -> shuffle(jdaSupplier, musicService, ctx)); + routes.get("/music/{guildId}/status", ctx -> status(shards, musicService, ctx)); + routes.get("/music/{guildId}/queue", ctx -> queue(shards, musicService, ctx)); + routes.post("/music/{guildId}/skip", ctx -> skip(shards, musicService, ctx)); + routes.post("/music/{guildId}/stop", ctx -> stop(shards, musicService, ctx)); + routes.post("/music/{guildId}/pause", ctx -> pause(shards, musicService, ctx)); + routes.post("/music/{guildId}/resume", ctx -> resumePlayback(shards, musicService, ctx)); + routes.get("/music/{guildId}/volume", ctx -> getVolume(shards, musicService, ctx)); + routes.put("/music/{guildId}/volume", ctx -> volume(shards, musicService, ctx)); + routes.put("/music/{guildId}/seek", ctx -> seek(shards, musicService, ctx)); + routes.get("/music/{guildId}/vote-skip", ctx -> voteSkipStatus(shards, musicService, ctx)); + routes.post("/music/{guildId}/vote-skip/{userId}", ctx -> voteSkip(shards, musicService, ctx)); + routes.get("/music/{guildId}/dj/{userId}", ctx -> dj(shards, musicService, ctx)); + routes.post("/music/{guildId}/queue", ctx -> play(shards, musicService, ctx)); + routes.delete("/music/{guildId}/queue", ctx -> deleteQueue(shards, musicService, ctx)); + routes.patch("/music/{guildId}/queue/reorder", ctx -> reorder(shards, musicService, ctx)); + routes.post("/music/{guildId}/search", ctx -> search(shards, musicService, ctx)); + routes.post("/music/{guildId}/leave", ctx -> leave(shards, musicService, ctx)); + routes.post("/music/{guildId}/shuffle", ctx -> shuffle(shards, musicService, ctx)); } @OpenApi( @@ -118,8 +117,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void status(Supplier jdaSupplier, MusicService musicService, Context ctx) { - MusicManager manager = manager(jdaSupplier, musicService, ctx); + private static void status(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); AudioChannel channel = manager.getConnectedChannel(); Map body = new LinkedHashMap<>(); @@ -146,8 +145,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void queue(Supplier jdaSupplier, MusicService musicService, Context ctx) { - MusicManager manager = manager(jdaSupplier, musicService, ctx); + private static void queue(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); ctx.json( manager.getQueueManager().getQueue().stream() @@ -167,8 +166,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void skip(Supplier jdaSupplier, MusicService musicService, Context ctx) { - MusicManager manager = manager(jdaSupplier, musicService, ctx); + private static void skip(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); QueueActionResult result = manager.getQueueManager().skip(); announce(manager, result.getMessage()); ctx.json(resultBody(result)); @@ -185,8 +184,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void stop(Supplier jdaSupplier, MusicService musicService, Context ctx) { - MusicManager manager = manager(jdaSupplier, musicService, ctx); + private static void stop(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); manager.stopTrack(); announce(manager, "Stopping current track"); ctx.json(Map.of("status", "ok", "message", "Stopping current track")); @@ -203,8 +202,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void pause(Supplier jdaSupplier, MusicService musicService, Context ctx) { - MusicManager manager = manager(jdaSupplier, musicService, ctx); + private static void pause(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); manager.pauseTrack(); announce(manager, "Pausing current track"); ctx.json(Map.of("status", "ok", "message", "Pausing current track")); @@ -221,8 +220,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void resumePlayback(Supplier jdaSupplier, MusicService musicService, Context ctx) { - MusicManager manager = manager(jdaSupplier, musicService, ctx); + private static void resumePlayback(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); manager.unpauseTrack(); ctx.json(Map.of("status", "ok", "message", "Resuming current track")); } @@ -238,8 +237,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void getVolume(Supplier jdaSupplier, MusicService musicService, Context ctx) { - MusicManager manager = manager(jdaSupplier, musicService, ctx); + private static void getVolume(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); ctx.json(Map.of("status", "ok", "volume", manager.getVolume())); } @@ -256,8 +255,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void volume(Supplier jdaSupplier, MusicService musicService, Context ctx) { - MusicManager manager = manager(jdaSupplier, musicService, ctx); + private static void volume(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); int newVolume = volume(ctx); manager.setVolume(newVolume); @@ -297,8 +296,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void seek(Supplier jdaSupplier, MusicService musicService, Context ctx) { - MusicManager manager = manager(jdaSupplier, musicService, ctx); + private static void seek(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); long positionMs = seekPosition(ctx, manager); manager.seek(positionMs); @@ -341,8 +340,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void voteSkipStatus(Supplier jdaSupplier, MusicService musicService, Context ctx) { - MusicManager manager = manager(jdaSupplier, musicService, ctx); + private static void voteSkipStatus(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); AudioChannel channel = manager.getConnectedChannel(); TrackInfo head = manager.getQueueManager().peekHeadOfQueue(); int threshold = manager.getVoteSkipThreshold(); @@ -369,8 +368,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void voteSkip(Supplier jdaSupplier, MusicService musicService, Context ctx) { - Guild guild = GuildRoutes.guild(jdaSupplier, ctx); + private static void voteSkip(ShardProvider shards, MusicService musicService, Context ctx) { + Guild guild = GuildRoutes.guild(shards, ctx); MusicManager manager = musicService.get(guild); String userId = ctx.pathParam("userId"); @@ -400,8 +399,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void dj(Supplier jdaSupplier, MusicService musicService, Context ctx) { - Guild guild = GuildRoutes.guild(jdaSupplier, ctx); + private static void dj(ShardProvider shards, MusicService musicService, Context ctx) { + Guild guild = GuildRoutes.guild(shards, ctx); MusicManager manager = musicService.get(guild); String userId = ctx.pathParam("userId"); @@ -426,8 +425,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void play(Supplier jdaSupplier, MusicService musicService, Context ctx) { - Guild guild = GuildRoutes.guild(jdaSupplier, ctx); + private static void play(ShardProvider shards, MusicService musicService, Context ctx) { + Guild guild = GuildRoutes.guild(shards, ctx); MusicManager manager = musicService.get(guild); QueueRequest body = ctx.bodyAsClass(QueueRequest.class); String userId = requireNonBlank(body.userId(), "userId"); @@ -449,8 +448,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void search(Supplier jdaSupplier, MusicService musicService, Context ctx) { - Guild guild = GuildRoutes.guild(jdaSupplier, ctx); + private static void search(ShardProvider shards, MusicService musicService, Context ctx) { + Guild guild = GuildRoutes.guild(shards, ctx); MusicManager manager = musicService.get(guild); SearchRequest body = ctx.bodyAsClass(SearchRequest.class); String query = requireNonBlank(body.query(), "query"); @@ -480,7 +479,7 @@ public final class MusicRoutes { /** * Queues a previously returned search candidate for a guild member, invoked from {@link - * #play(Supplier, MusicService, Context)} when the request body carries a {@code searchId}. + * #play(ShardProvider, MusicService, Context)} when the request body carries a {@code searchId}. * Exposed via the {@code /music/{guildId}/queue} POST endpoint. */ private static void playSelect(Guild guild, MusicManager manager, QueueRequest body, String userId, Context ctx) { @@ -541,8 +540,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void deleteQueue(Supplier jdaSupplier, MusicService musicService, Context ctx) { - MusicManager manager = manager(jdaSupplier, musicService, ctx); + private static void deleteQueue(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); String position = ctx.queryParam("position"); QueueActionResult result; @@ -578,8 +577,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void leave(Supplier jdaSupplier, MusicService musicService, Context ctx) { - MusicManager manager = manager(jdaSupplier, musicService, ctx); + private static void leave(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); manager.disconnect(); announce(manager, "Leaving voice channel"); ctx.json(new LeaveResponse("ok", "Leaving voice channel")); @@ -596,8 +595,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void shuffle(Supplier jdaSupplier, MusicService musicService, Context ctx) { - MusicManager manager = manager(jdaSupplier, musicService, ctx); + private static void shuffle(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); QueueActionResult result = manager.getQueueManager().shuffle(); announce(manager, result.getMessage()); ctx.json(resultBodyWithQueue(result, manager)); @@ -616,8 +615,8 @@ public final class MusicRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void reorder(Supplier jdaSupplier, MusicService musicService, Context ctx) { - MusicManager manager = manager(jdaSupplier, musicService, ctx); + private static void reorder(ShardProvider shards, MusicService musicService, Context ctx) { + MusicManager manager = manager(shards, musicService, ctx); ReorderRequest body = ctx.bodyAsClass(ReorderRequest.class); QueueActionResult result = manager.getQueueManager().reorder(body.fromPosition(), body.toPosition()); @@ -717,8 +716,8 @@ public final class MusicRoutes { /** * Resolves the {@link MusicManager} for the guild path parameter, creating it on first use. */ - private static MusicManager manager(Supplier jdaSupplier, MusicService musicService, Context ctx) { - return musicService.get(GuildRoutes.guild(jdaSupplier, ctx)); + private static MusicManager manager(ShardProvider shards, MusicService musicService, Context ctx) { + return musicService.get(GuildRoutes.guild(shards, ctx)); } /** 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 24d7b92..d4ff828 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/SettingsRoutes.java +++ b/src/main/java/me/bobert/netneutrality/http/server/SettingsRoutes.java @@ -25,13 +25,12 @@ import me.bobert.netneutrality.http.server.dto.SettingsCurrentResponse; import me.bobert.netneutrality.http.server.dto.SettingsReloadResponse; import me.bobert.netneutrality.http.server.dto.SettingsUpdateRequest; import me.bobert.netneutrality.http.server.dto.SettingsUpdateResponse; -import net.dv8tion.jda.api.JDA; +import me.bobert.netneutrality.shard.ShardProvider; import net.dv8tion.jda.api.entities.Guild; import java.util.Arrays; import java.util.HashMap; import java.util.Map; -import java.util.function.Supplier; /** * Registers the {@code /settings/*} endpoints on a {@link RoutesConfig} instance. @@ -41,14 +40,14 @@ public final class SettingsRoutes { private SettingsRoutes() { } - public static void register(RoutesConfig routes, Supplier jdaSupplier, GuildServiceProvider guildServices) { + public static void register(RoutesConfig routes, ShardProvider shards, GuildServiceProvider guildServices) { routes.get("/settings/catalog", SettingsRoutes::settingsCatalog); - routes.get("/settings/current/{guildId}", ctx -> settingsCurrent(jdaSupplier, guildServices, ctx)); + routes.get("/settings/current/{guildId}", ctx -> settingsCurrent(shards, guildServices, ctx)); - routes.put("/settings/current/{guildId}/{name}", ctx -> settingsUpdate(jdaSupplier, guildServices, ctx)); + routes.put("/settings/current/{guildId}/{name}", ctx -> settingsUpdate(shards, guildServices, ctx)); - routes.put("/settings/reload/{guildId}", ctx -> settingsReload(jdaSupplier, guildServices, ctx)); + routes.put("/settings/reload/{guildId}", ctx -> settingsReload(shards, guildServices, ctx)); } @OpenApi( @@ -87,8 +86,8 @@ public final class SettingsRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void settingsCurrent(Supplier jdaSupplier, GuildServiceProvider guildServices, Context ctx) { - Guild guild = GuildRoutes.guild(jdaSupplier, ctx); + private static void settingsCurrent(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) { + Guild guild = GuildRoutes.guild(shards, ctx); Map settings = guildServices.get(guild.getId()).getGuildSettingService().snapshot(); ctx.json(Map.of("status", "ok", "settings", settings)); @@ -110,8 +109,8 @@ public final class SettingsRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void settingsUpdate(Supplier jdaSupplier, GuildServiceProvider guildServices, Context ctx) { - Guild guild = GuildRoutes.guild(jdaSupplier, ctx); + private static void settingsUpdate(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) { + Guild guild = GuildRoutes.guild(shards, ctx); GSettings setting = setting(ctx.pathParam("name")); String value = ctx.bodyAsClass(Map.class).get("value") instanceof String s ? s : null; @@ -195,8 +194,8 @@ public final class SettingsRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void settingsReload(Supplier jdaSupplier, GuildServiceProvider guildServices, Context ctx) { - Guild guild = GuildRoutes.guild(jdaSupplier, ctx); + private static void settingsReload(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) { + Guild guild = GuildRoutes.guild(shards, ctx); guildServices.get(guild.getId()).getGuildSettingService().reloadSettingsCache(); ctx.json(Map.of("status", true)); diff --git a/src/main/java/me/bobert/netneutrality/http/server/StatsRoutes.java b/src/main/java/me/bobert/netneutrality/http/server/StatsRoutes.java index 7bdadc7..94a1e6e 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/StatsRoutes.java +++ b/src/main/java/me/bobert/netneutrality/http/server/StatsRoutes.java @@ -18,11 +18,10 @@ import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.http.server.dto.BotStatsResponse; import me.bobert.netneutrality.http.server.dto.ErrorResponse; import me.bobert.netneutrality.http.server.dto.GuildStatsResponse; -import net.dv8tion.jda.api.JDA; +import me.bobert.netneutrality.shard.ShardProvider; 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. @@ -32,9 +31,9 @@ public final class StatsRoutes { private StatsRoutes() { } - public static void register(RoutesConfig routes, Supplier jdaSupplier, GuildServiceProvider guildServices) { - routes.get("/stats/bot", ctx -> bot(jdaSupplier, ctx)); - routes.get("/stats/guilds/{guildId}", ctx -> guild(jdaSupplier, guildServices, ctx)); + public static void register(RoutesConfig routes, ShardProvider shards, GuildServiceProvider guildServices) { + routes.get("/stats/bot", ctx -> bot(shards, ctx)); + routes.get("/stats/guilds/{guildId}", ctx -> guild(shards, guildServices, ctx)); } @OpenApi( @@ -44,13 +43,14 @@ public final class StatsRoutes { tags = {"Stats"}, responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = BotStatsResponse.class)) ) - private static void bot(Supplier jdaSupplier, Context ctx) { - JDA jda = GuildRoutes.jda(jdaSupplier); + private static void bot(ShardProvider shards, Context ctx) { + ShardProvider provider = GuildRoutes.shards(shards); ctx.json(Map.of( - "guild_count", jda.getGuilds().size(), - "gateway_ping", jda.getGatewayPing(), - "status", String.valueOf(jda.getStatus()), + "guild_count", provider.getGuilds().size(), + "gateway_ping", (long) provider.getAveragePing(), + "status", String.valueOf(provider.getWorstStatus()), + "shard_count", provider.getShardCount(), "uptime_seconds", uptimeSeconds() )); } @@ -66,8 +66,8 @@ public final class StatsRoutes { @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) } ) - private static void guild(Supplier jdaSupplier, GuildServiceProvider guildServices, Context ctx) { - Guild guild = GuildRoutes.guild(jdaSupplier, ctx); + private static void guild(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) { + Guild guild = GuildRoutes.guild(shards, ctx); GuildService guildService = guildServices.get(guild.getId()); ctx.json(Map.of( diff --git a/src/main/java/me/bobert/netneutrality/http/server/WebApi.java b/src/main/java/me/bobert/netneutrality/http/server/WebApi.java index 798a5c2..144bed6 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/WebApi.java +++ b/src/main/java/me/bobert/netneutrality/http/server/WebApi.java @@ -14,12 +14,11 @@ import me.bobert.netneutrality.bootstrap.Lifecycle; import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.utils.SettingsProvider; -import net.dv8tion.jda.api.JDA; +import me.bobert.netneutrality.shard.ShardProvider; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.util.Map; -import java.util.function.Supplier; /** * Embedded HTTP API exposed as a {@link Lifecycle} component (priority 70, after JDA). @@ -32,7 +31,7 @@ public class WebApi implements Lifecycle { private final SettingsProvider settings; private final GuildServiceProvider guildServices; private final MusicService musicService; - private final Supplier jdaSupplier; + private final ShardProvider shards; private final Runnable shutdownHook; private Javalin app; @@ -40,13 +39,13 @@ public class WebApi implements Lifecycle { SettingsProvider settings, GuildServiceProvider guildServices, MusicService musicService, - Supplier jdaSupplier, + ShardProvider shards, Runnable shutdownHook ) { this.settings = settings; this.guildServices = guildServices; this.musicService = musicService; - this.jdaSupplier = jdaSupplier; + this.shards = shards; this.shutdownHook = shutdownHook; } @@ -77,11 +76,11 @@ public class WebApi implements Lifecycle { config.routes.before(new ApiKeyFilter(settings)); config.routes.get("/", ctx -> ctx.html(LIVENESS_HTML)); - GuildRoutes.register(config.routes, jdaSupplier, guildServices); - ActionRoutes.register(config.routes, jdaSupplier, shutdownHook); - SettingsRoutes.register(config.routes, jdaSupplier, guildServices); - StatsRoutes.register(config.routes, jdaSupplier, guildServices); - MusicRoutes.register(config.routes, jdaSupplier, musicService); + GuildRoutes.register(config.routes, shards, guildServices); + ActionRoutes.register(config.routes, shards, shutdownHook); + SettingsRoutes.register(config.routes, shards, guildServices); + StatsRoutes.register(config.routes, shards, guildServices); + MusicRoutes.register(config.routes, shards, musicService); config.routes.exception(HttpResponseException.class, (e, ctx) -> ctx.status(e.getStatus()).json(Map.of("status", "error", "message", e.getMessage())) diff --git a/src/main/java/me/bobert/netneutrality/shard/ShardProvider.java b/src/main/java/me/bobert/netneutrality/shard/ShardProvider.java new file mode 100644 index 0000000..e492c2a --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/shard/ShardProvider.java @@ -0,0 +1,52 @@ +package me.bobert.netneutrality.shard; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; + +import java.util.List; + +/** + * Read-only, shard-aggregating view of the Discord gateway connections. + */ +public interface ShardProvider { + + /** + * @return {@code true} once every shard has finished starting up and until shutdown + */ + boolean isReady(); + + /** + * Looks a guild up on whichever shard owns it. + * + * @return the guild, or {@code null} if no shard knows it + */ + Guild getGuildById(String id); + + /** + * @return the guilds of all shards + */ + List getGuilds(); + + int getShardCount(); + + /** + * @return the mean gateway ping across shards, or {@code -1} if unknown + */ + double getAveragePing(); + + /** + * @return {@code CONNECTED} when every shard is connected, otherwise the most degraded status + */ + JDA.Status getWorstStatus(); + + /** + * @return an arbitrary shard (for global operations such as command registration), or {@code null} if not ready + */ + JDA getAnyShard(); +} diff --git a/src/main/java/me/bobert/netneutrality/shard/ShardService.java b/src/main/java/me/bobert/netneutrality/shard/ShardService.java new file mode 100644 index 0000000..087d219 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/shard/ShardService.java @@ -0,0 +1,206 @@ +package me.bobert.netneutrality.shard; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener; +import me.bobert.netneutrality.bootstrap.Lifecycle; +import me.bobert.netneutrality.core.LogHandler; +import me.bobert.netneutrality.music.MusicService; +import me.bobert.netneutrality.utils.SettingsProvider; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Activity; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.exceptions.InvalidTokenException; +import net.dv8tion.jda.api.hooks.EventListener; +import net.dv8tion.jda.api.requests.GatewayIntent; +import net.dv8tion.jda.api.sharding.DefaultShardManagerBuilder; +import net.dv8tion.jda.api.sharding.ShardManager; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +/** + * Owns the JDA {@link ShardManager}. The shard count is Discord's recommendation unless + * {@code NETNEUTRALITY_SHARD_TOTAL} overrides it. + */ +public final class ShardService implements Lifecycle, ShardProvider { + public static final String SHARD_TOTAL_KEY = "NETNEUTRALITY_SHARD_TOTAL"; + + private static final Logger logger = LogManager.getLogger(ShardService.class); + + private final SettingsProvider settings; + private final MusicService music; + private final EventListener eventsHandler; + private volatile ShardManager shardManager; + private volatile boolean ready; + + public ShardService(SettingsProvider settings, MusicService music, EventListener eventsHandler) { + this.settings = Objects.requireNonNull(settings, "settings"); + this.music = Objects.requireNonNull(music, "music"); + this.eventsHandler = Objects.requireNonNull(eventsHandler, "eventsHandler"); + } + + /** + * Wraps an already-built manager; used by tests. + */ + ShardService(ShardManager shardManager) { + this.settings = null; + this.music = null; + this.eventsHandler = null; + this.shardManager = shardManager; + this.ready = true; + } + + @Override + public void start() throws Exception { + String token = settings.get("NETNEUTRALITY_BOT_TOKEN"); + if (token == null) { + throw new IllegalStateException("No bot token provided, exiting."); + } + + try { + DefaultShardManagerBuilder builder = DefaultShardManagerBuilder + .createDefault(token) + .enableIntents( + GatewayIntent.GUILD_MEMBERS, + GatewayIntent.GUILD_MESSAGE_REACTIONS, + GatewayIntent.GUILD_VOICE_STATES, + GatewayIntent.MESSAGE_CONTENT) + // Voice state/server updates are handed to Lavalink instead of JDA's audio stack; + // DAVE encryption is handled by the node. + .setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(music.getClient())) + // Attached before connecting so no shard misses early events. + .addEventListeners(eventsHandler) + .setActivity(Activity.watching("me load...")); + + int total = parseShardTotal(settings.get(SHARD_TOTAL_KEY)); + if (total > 0) { + builder.setShardsTotal(total); + } + + shardManager = builder.build(); + awaitAllShards(shardManager); + ready = true; + logger.info("{} shard(s) ready", shardManager.getShardsTotal()); + } catch (InvalidTokenException | IllegalArgumentException e) { + LogHandler.log(e); + throw new IllegalStateException("Unable to connect to Discord", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LogHandler.log(e); + throw new IllegalStateException("Unable to connect to Discord", e); + } + } + + @Override + public void stop() { + ready = false; + ShardManager manager = shardManager; + if (manager != null) { + manager.shutdown(); + } + } + + @Override + public int priority() { + return 60; + } + + /** + * @return the configured shard count, or {@code -1} (automatic) when missing or invalid + */ + static int parseShardTotal(String raw) { + if (raw == null || raw.isBlank()) { + return -1; + } + try { + int value = Integer.parseInt(raw.trim()); + if (value > 0) { + return value; + } + } catch (NumberFormatException ignored) { + // falls through to the warning below + } + logger.warn("Invalid {} '{}', using Discord's recommended shard count", SHARD_TOTAL_KEY, raw); + return -1; + } + + private static void awaitAllShards(ShardManager manager) throws InterruptedException { + int total = manager.getShardsTotal(); + for (int id = 0; id < total; id++) { + JDA shard = manager.getShardById(id); + // Shards are started asynchronously (one identify per 5s), so wait for each to appear. + while (shard == null) { + Thread.sleep(100); + shard = manager.getShardById(id); + } + shard.awaitReady(); + } + } + + public void setActivity(Activity activity) { + ShardManager manager = shardManager; + if (manager != null) { + manager.setActivity(activity); + } + } + + @Override + public boolean isReady() { + return ready && shardManager != null; + } + + @Override + public Guild getGuildById(String id) { + ShardManager manager = shardManager; + return manager == null ? null : manager.getGuildById(id); + } + + @Override + public List getGuilds() { + ShardManager manager = shardManager; + return manager == null ? List.of() : manager.getGuilds(); + } + + @Override + public int getShardCount() { + ShardManager manager = shardManager; + return manager == null ? 0 : manager.getShardsTotal(); + } + + @Override + public double getAveragePing() { + ShardManager manager = shardManager; + return manager == null ? -1 : manager.getAverageGatewayPing(); + } + + @Override + public JDA.Status getWorstStatus() { + ShardManager manager = shardManager; + if (manager == null) { + return JDA.Status.INITIALIZING; + } + return manager.getShards().stream() + .map(JDA::getStatus) + .filter(status -> status != JDA.Status.CONNECTED) + .max(Comparator.naturalOrder()) + .orElse(JDA.Status.CONNECTED); + } + + @Override + public JDA getAnyShard() { + ShardManager manager = shardManager; + if (manager == null || !ready) { + return null; + } + JDA first = manager.getShardById(0); + return first != null ? first : manager.getShards().stream().findFirst().orElse(null); + } +} diff --git a/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java b/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java index d8480e1..fc3e6dd 100644 --- a/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java +++ b/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java @@ -12,6 +12,7 @@ import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildSettingService; import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.utils.SettingsProvider; +import me.bobert.netneutrality.shard.ShardProvider; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.Guild; @@ -48,6 +49,7 @@ class WebApiTest { private WebApi webApi; private int port; private GuildSettingService settingService; + private ShardProvider shards; private final HttpClient http = HttpClient.newHttpClient(); @BeforeEach @@ -79,13 +81,14 @@ class WebApiTest { when(failing.submit()).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("unknown member"))); when(guild.retrieveMemberById(any(String.class))).thenReturn(failing); - JDA jda = mock(JDA.class); - when(jda.getGuilds()).thenReturn(List.of(guild)); - when(jda.getGuildById(GUILD_ID)).thenReturn(guild); - when(jda.getGuildById(any(String.class))).thenAnswer(inv -> + shards = mock(ShardProvider.class); + when(shards.isReady()).thenReturn(true); + when(shards.getGuilds()).thenReturn(List.of(guild)); + when(shards.getGuildById(any(String.class))).thenAnswer(inv -> GUILD_ID.equals(inv.getArgument(0)) ? guild : null); - when(jda.getStatus()).thenReturn(JDA.Status.CONNECTED); - when(jda.getGatewayPing()).thenReturn(12L); + when(shards.getWorstStatus()).thenReturn(JDA.Status.CONNECTED); + when(shards.getAveragePing()).thenReturn(12.0); + when(shards.getShardCount()).thenReturn(3); settingService = mock(GuildSettingService.class); when(settingService.snapshot()).thenReturn(Map.of("prefix", "ü!")); @@ -107,7 +110,7 @@ class WebApiTest { MusicService musicService = mock(MusicService.class); - webApi = new WebApi(settings, provider, musicService, () -> jda, () -> { }); + webApi = new WebApi(settings, provider, musicService, shards, () -> { }); webApi.start(); } @@ -157,6 +160,16 @@ class WebApiTest { assertEquals(200, response.statusCode()); assertTrue(response.body().contains("\"health\":\"CONNECTED\"")); assertTrue(response.body().contains("\"ping\":\"12\"")); + assertTrue(response.body().contains("\"shard_count\":3")); + } + + @Test + void routesReturnServiceUnavailableWhenShardsAreNotReady() throws Exception { + when(shards.isReady()).thenReturn(false); + + assertEquals(503, get("/stats/bot").statusCode()); + assertEquals(503, get("/actions/status").statusCode()); + assertEquals(503, get("/guilds").statusCode()); } @Test @@ -256,6 +269,7 @@ class WebApiTest { assertTrue(response.body().contains("\"guild_count\":1")); assertTrue(response.body().contains("\"status\":\"CONNECTED\"")); assertTrue(response.body().contains("\"gateway_ping\":12")); + assertTrue(response.body().contains("\"shard_count\":3")); } @Test diff --git a/src/test/java/me/bobert/netneutrality/shard/ShardServiceTest.java b/src/test/java/me/bobert/netneutrality/shard/ShardServiceTest.java new file mode 100644 index 0000000..00ebde2 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/shard/ShardServiceTest.java @@ -0,0 +1,83 @@ +package me.bobert.netneutrality.shard; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.sharding.ShardManager; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ShardServiceTest { + + private static JDA shard(JDA.Status status) { + JDA jda = mock(JDA.class); + when(jda.getStatus()).thenReturn(status); + return jda; + } + + @Test + void aggregatesAcrossShards() { + ShardManager manager = mock(ShardManager.class); + Guild a = mock(Guild.class); + Guild b = mock(Guild.class); + JDA shard0 = shard(JDA.Status.CONNECTED); + JDA shard1 = shard(JDA.Status.RECONNECT_QUEUED); + + when(manager.getGuilds()).thenReturn(List.of(a, b)); + when(manager.getGuildById("2")).thenReturn(b); + when(manager.getShardsTotal()).thenReturn(2); + when(manager.getAverageGatewayPing()).thenReturn(42.5); + when(manager.getShards()).thenReturn(List.of(shard0, shard1)); + when(manager.getShardById(0)).thenReturn(shard0); + + ShardService service = new ShardService(manager); + + assertTrue(service.isReady()); + assertEquals(2, service.getGuilds().size()); + assertSame(b, service.getGuildById("2")); + assertNull(service.getGuildById("missing")); + assertEquals(2, service.getShardCount()); + assertEquals(42.5, service.getAveragePing()); + assertEquals(JDA.Status.RECONNECT_QUEUED, service.getWorstStatus()); + assertSame(shard0, service.getAnyShard()); + } + + @Test + void allConnectedReportsConnected() { + ShardManager manager = mock(ShardManager.class); + JDA connected = shard(JDA.Status.CONNECTED); + when(manager.getShards()).thenReturn(List.of(connected)); + + assertEquals(JDA.Status.CONNECTED, new ShardService(manager).getWorstStatus()); + } + + @Test + void notReadyBeforeStartAndAfterStop() { + ShardManager manager = mock(ShardManager.class); + ShardService service = new ShardService(manager); + service.stop(); + + assertFalse(service.isReady()); + assertNull(service.getAnyShard()); + verify(manager).shutdown(); + } + + @Test + void parsesShardTotal() { + assertEquals(-1, ShardService.parseShardTotal(null)); + assertEquals(-1, ShardService.parseShardTotal("")); + assertEquals(-1, ShardService.parseShardTotal("abc")); + assertEquals(-1, ShardService.parseShardTotal("0")); + assertEquals(1, ShardService.parseShardTotal("1")); + assertEquals(4, ShardService.parseShardTotal(" 4 ")); + } +}