5 Commits
Author SHA1 Message Date
anthorob d376e245b7 Add author and duration to queue metadata
Included track author and duration in queue metadata. Adjusted key naming for consistency with snake_case formatting.
2026-10-28 04:48:54 -04:00
anthorob 6e99fe5477 **Add artwork URL to queue metadata**
Enhanced queue metadata by including the track's artwork URL in the serialized output. This allows clients to display album art or related visuals alongside track information.
2026-10-28 04:21:13 -04:00
anthorob dba7a84819 **Fix nickname retrieval in GuildRoutes**
Replaced `getNickname()` with `getEffectiveName()` to ensure the correct name is retrieved, falling back to the username when no nickname is set. This improves accuracy and consistency in member summaries.
2026-10-28 03:58:31 -04:00
anthorob 87ba09b001 **Improve event handling with new music and queue updates**
Added new `MusicEvent` types, including `TRACK_ERROR`, `TRACK_STUCK`, and `PLAYER_POSITION`, providing enhanced diagnostics and feedback. Improved queue mutation notifications by introducing granular events like `added`, `removed`, `moved`, `cleared`, and `shuffled`. Updated tests and documentation to reflect the changes, ensuring consistency and clarity across the event system.
2026-10-28 03:58:19 -04:00
anthorob 75b495b08c **Replace JDA with ShardProvider for improved sharding**
Refactored core classes and APIs to use `ShardProvider` instead of `JDA` directly. Added `ShardService` for managing shard connections and activity, improving scalability and separation of concerns. Integrated test coverage to validate shard-level operations and bot endpoints.
2026-10-28 02:59:24 -04:00
21 changed files with 868 additions and 245 deletions

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ When `NETNEUTRALITY_WEBHOOK_URL` is set, the bot POSTs JSON music-state events (
`TRACK_START` / `TRACK_END` payloads are `{ identifier, title, author, length, uri, artworkUrl }` (plus `reason` on `TRACK_END`). `uri` and `artworkUrl` may be `null` when the source doesn't provide them.
`VOICE_CONNECTED` / `VOICE_DISCONNECTED` are sent for the bot itself and for any member joining or leaving the voice channel the bot is currently in. Their payload is `{ channelId, userId, bot, self }`, where `bot` marks bot accounts and `self` marks this bot's own voice changes.
`VOICE_CONNECTED` / `VOICE_DISCONNECTED` are sent for the bot itself and for any member joining or leaving the voice channel the bot is currently in. Their payload is `{ channelId, userId, username, nickname, bot, self }`, where `nickname` is the member's effective display name in the guild, `bot` marks bot accounts and `self` marks this bot's own voice changes.
## Running with Docker Compose
+2
View File
@@ -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
+15
View File
@@ -91,6 +91,21 @@ Key points:
`TaskManager.getInstance().handle(context)`.
- **`null` contexts** (bot messages, consumer-only events) are simply not routed anywhere.
## Webhook music events
`MusicEvent` instances are delivered by `WebhookDispatcher`. Every event carries `type`, `guildId`,
`timestamp` and `seq`; the payloads of the queue and player events are:
| Type | Payload |
|---|---|
| `QUEUE_UPDATED` | `size` (int), `change` (`added`/`removed`/`moved`/`cleared`/`shuffled`), `items` (list). Each item has `index` (0-based position, before the mutation for removals), `identifier`, `title`, `uri`, `requesterId`. `moved` items also carry `fromIndex` and `toIndex`. `cleared` and `shuffled` have an empty `items` (reload the queue on `shuffled`). |
| `QUEUE_SHUFFLED` | `size` |
| `TRACK_ERROR` | `track` (`identifier`, `title`, `author`, `length`, `uri`, `artworkUrl`), `message`, `severity` |
| `TRACK_STUCK` | `track` (same fields as above), `thresholdMs` |
| `PLAYER_POSITION` | `position` (ms), `timestamp` (node time, epoch ms), `connected`, `ping` (ms). Sent from Lavalink `PlayerUpdateEvent`, at most once every 5 seconds per guild and only while a track is playing. |
`size` in `QUEUE_UPDATED` is unchanged, so existing consumers keep working.
## Extending the event system
To handle a new event type:
+9 -57
View File
@@ -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();
}
}
}
@@ -27,7 +27,11 @@ public record MusicEvent(Type type, String guildId, long timestamp, long seq, Ob
PLAYER_SEEKED,
VOTE_SKIP_UPDATED,
VOICE_CONNECTED,
VOICE_DISCONNECTED
VOICE_DISCONNECTED,
QUEUE_SHUFFLED,
TRACK_ERROR,
TRACK_STUCK,
PLAYER_POSITION
}
/**
@@ -171,6 +171,8 @@ public class EventsHandler implements EventListener {
return Map.of(
"channelId", channelId,
"userId", event.getEntity().getId(),
"username", event.getEntity().getUser().getName(),
"nickname", event.getEntity().getEffectiveName(),
"bot", event.getEntity().getUser().isBot(),
"self", self);
}
@@ -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<JDA> 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<JDA> 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()
));
}
@@ -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<JDA> 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<JDA> 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<CompletableFuture<Optional<Map<String, Object>>>> lookups = jda.getGuilds().stream()
List<CompletableFuture<Optional<Map<String, Object>>>> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<Member, Map<String, Object>> toBody = member -> {
@@ -413,9 +412,9 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void voiceChannels(Supplier<JDA> 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<JDA> 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)));
}
/**
@@ -472,7 +471,7 @@ public final class GuildRoutes {
Map<String, String> summary = new LinkedHashMap<>();
summary.put("user_id", member.getId());
summary.put("username", member.getUser().getName());
summary.put("nickname", member.getNickname());
summary.put("nickname", member.getEffectiveName());
return summary;
}
@@ -497,8 +496,8 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void config(Supplier<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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");
}
@@ -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<JDA> 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<JDA> 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<String, Object> body = new LinkedHashMap<>();
@@ -146,8 +145,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void queue(Supplier<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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<JDA> 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));
}
/**
@@ -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<JDA> 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<JDA> 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<String, String> 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<JDA> 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<JDA> 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));
@@ -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<JDA> 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<JDA> 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<JDA> 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(
@@ -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<JDA> 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<JDA> 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()))
@@ -10,6 +10,7 @@ import dev.arbjerg.lavalink.client.Helpers;
import dev.arbjerg.lavalink.client.LavalinkClient;
import dev.arbjerg.lavalink.client.LavalinkNode;
import dev.arbjerg.lavalink.client.NodeOptions;
import dev.arbjerg.lavalink.client.event.PlayerUpdateEvent;
import dev.arbjerg.lavalink.client.event.ReadyEvent;
import dev.arbjerg.lavalink.client.event.TrackEndEvent;
import dev.arbjerg.lavalink.client.event.TrackExceptionEvent;
@@ -209,6 +210,11 @@ public class MusicServiceImpl implements MusicService {
subscriptions.add(client.on(TrackStuckEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackStuck(event.getTrack(), event.getThresholdMs()))));
subscriptions.add(client.on(PlayerUpdateEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onPlayerUpdate(
event.getState().getTime(), event.getState().getPosition(),
event.getState().getConnected(), event.getState().getPing()))));
subscriptions.add(client.on(WebSocketClosedEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onWebSocketClosed(event.getCode(), event.getReason(), event.getByRemote()))));
}
@@ -15,6 +15,7 @@ import org.apache.logging.log4j.Logger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.LinkedBlockingDeque;
@@ -63,18 +64,34 @@ public class QueueManager {
public void removeHeadOfQueue() {
synchronized (queue) {
queue.poll();
notifyQueueUpdated();
TrackInfo head = queue.poll();
notifyQueueUpdated("removed", head == null ? List.of() : List.of(item(0, head)));
}
}
private void notifyQueueUpdated() {
private static Map<String, Object> item(int index, TrackInfo info) {
Map<String, Object> item = new HashMap<>();
item.put("index", index);
item.put("identifier", info.getTrack().getInfo().getIdentifier());
item.put("title", info.getTrack().getInfo().getTitle());
item.put("author", info.getTrack().getInfo().getAuthor());
item.put("duration_ms", info.getTrack().getInfo().getLength());
item.put("uri", info.getTrack().getInfo().getUri());
item.put("artwork_url", info.getTrack().getInfo().getArtworkUrl());
item.put("requester_id", info.getRequester() == null ? null : info.getRequester().getId());
return item;
}
private void notifyQueueUpdated(String change, List<Map<String, Object>> items) {
MusicManager musicManager = scheduler.getMusicManager();
if (musicManager == null || musicManager.getEvents() == null) {
return;
}
musicManager.getEvents().publish(MusicEvent.of(MusicEvent.Type.QUEUE_UPDATED, guild.getId(),
Map.of("size", queue.size())));
Map<String, Object> payload = new HashMap<>();
payload.put("size", queue.size());
payload.put("change", change);
payload.put("items", items);
musicManager.getEvents().publish(MusicEvent.of(MusicEvent.Type.QUEUE_UPDATED, guild.getId(), payload));
}
public boolean isEmpty() {
@@ -91,7 +108,7 @@ public class QueueManager {
}
queue.clear();
notifyQueueUpdated();
notifyQueueUpdated("cleared", List.of());
scheduler.getCallback().onQueueEmpty();
return QueueActionResult.success(String.format("Removed %d track%s from the queue", queueSize, (queueSize > 1 ? "s" : "")));
}
@@ -109,8 +126,16 @@ public class QueueManager {
}
TrackInfo first = queue.peek();
List<Map<String, Object>> removed = new ArrayList<>();
int position = 0;
for (TrackInfo trackInfo : queue) {
if (member.equals(trackInfo.getRequester())) {
removed.add(item(position, trackInfo));
}
position++;
}
queue.removeIf(trackInfo -> member.equals(trackInfo.getRequester()));
notifyQueueUpdated();
notifyQueueUpdated("removed", removed);
if (first != null && member.equals(first.getRequester())) {
skip(1);
@@ -148,7 +173,7 @@ public class QueueManager {
} else {
queue.add(info);
}
notifyQueueUpdated();
notifyQueueUpdated("added", List.of(item(queue.peekFirst() == info ? 0 : queue.size() - 1, info)));
if (idle) {
TrackInfo head = queue.peek();
@@ -196,8 +221,8 @@ public class QueueManager {
public void startNextTrack() {
synchronized (queue) {
queue.poll();
notifyQueueUpdated();
TrackInfo polled = queue.poll();
notifyQueueUpdated("removed", polled == null ? List.of() : List.of(item(0, polled)));
TrackInfo next = queue.peek();
if (next != null) {
@@ -259,7 +284,12 @@ public class QueueManager {
queue.clear();
queue.add(current);
queue.addAll(newQueue);
notifyQueueUpdated();
notifyQueueUpdated("shuffled", List.of());
MusicManager musicManager = scheduler.getMusicManager();
if (musicManager != null && musicManager.getEvents() != null) {
musicManager.getEvents().publish(MusicEvent.of(MusicEvent.Type.QUEUE_SHUFFLED, guild.getId(),
Map.of("size", queue.size())));
}
return QueueActionResult.success("The queue is now shuffled.");
}
@@ -279,11 +309,11 @@ public class QueueManager {
if (index > 1) {
List<TrackInfo> newQueue = new ArrayList<>(queue);
newQueue.remove(index - 1);
TrackInfo removedInfo = newQueue.remove(index - 1);
queue.clear();
queue.addAll(newQueue);
notifyQueueUpdated();
notifyQueueUpdated("removed", List.of(item(index - 1, removedInfo)));
return QueueActionResult.success(String.format("Removed track #%d from the queue", index));
}
@@ -327,7 +357,10 @@ public class QueueManager {
queue.clear();
queue.addAll(newQueue);
notifyQueueUpdated();
Map<String, Object> movedItem = item(toIndex - 1, moved);
movedItem.put("fromIndex", fromIndex - 1);
movedItem.put("toIndex", toIndex - 1);
notifyQueueUpdated("moved", List.of(movedItem));
return QueueActionResult.success(String.format("Moved track #%d to position #%d", fromIndex, toIndex));
}
@@ -33,6 +33,8 @@ public class TrackScheduler {
private volatile Context context;
private volatile Track currentTrack;
private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler;
private static final long POSITION_THROTTLE_MS = 5000;
private volatile long lastPositionSent = Long.MIN_VALUE;
public TrackScheduler(MusicManager manager) {
this.manager = manager;
@@ -137,10 +139,7 @@ public class TrackScheduler {
// clone of this back to your queue
}
private void publish(MusicEvent.Type type, Track track, String reason) {
if (manager.getEvents() == null) {
return;
}
private static java.util.Map<String, Object> trackPayload(Track track) {
java.util.Map<String, Object> payload = new java.util.HashMap<>();
payload.put("identifier", track.getInfo().getIdentifier());
payload.put("title", track.getInfo().getTitle());
@@ -148,6 +147,14 @@ public class TrackScheduler {
payload.put("length", track.getInfo().getLength());
payload.put("uri", track.getInfo().getUri());
payload.put("artworkUrl", track.getInfo().getArtworkUrl());
return payload;
}
private void publish(MusicEvent.Type type, Track track, String reason) {
if (manager.getEvents() == null) {
return;
}
java.util.Map<String, Object> payload = trackPayload(track);
if (reason != null) {
payload.put("reason", reason);
}
@@ -157,6 +164,14 @@ public class TrackScheduler {
public void onTrackException(Track track, TrackException exception) {
logger.error("Guild {}: exception while playing track: {}", guild.getId(), track.getInfo().getIdentifier());
if (manager.getEvents() != null) {
java.util.Map<String, Object> payload = new java.util.HashMap<>();
payload.put("track", trackPayload(track));
payload.put("message", exception.getMessage());
payload.put("severity", exception.getSeverity() == null ? null : exception.getSeverity().name());
manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.TRACK_ERROR, guild.getId(), payload));
}
LogHandler.log(new ContextException(
String.format("Error while playing track: %s", track.getInfo().getTitle()),
getContext(),
@@ -166,9 +181,36 @@ public class TrackScheduler {
public void onTrackStuck(Track track, long thresholdMs) {
logger.error("Guild {}: currently playing track is stuck: {} (threshold {}ms)", guild.getId(), track.getInfo().getIdentifier(), thresholdMs);
if (manager.getEvents() != null) {
java.util.Map<String, Object> payload = new java.util.HashMap<>();
payload.put("track", trackPayload(track));
payload.put("thresholdMs", thresholdMs);
manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.TRACK_STUCK, guild.getId(), payload));
}
stuckTrackRecoveryHandler.handle(track, context);
}
public void onPlayerUpdate(long timestamp, long position, boolean connected, long ping) {
onPlayerUpdate(timestamp, position, connected, ping, System.currentTimeMillis());
}
void onPlayerUpdate(long timestamp, long position, boolean connected, long ping, long now) {
if (manager.getEvents() == null || currentTrack == null) {
return;
}
if (lastPositionSent != Long.MIN_VALUE && now - lastPositionSent < POSITION_THROTTLE_MS) {
return;
}
lastPositionSent = now;
java.util.Map<String, Object> payload = new java.util.HashMap<>();
payload.put("position", position);
payload.put("timestamp", timestamp);
payload.put("connected", connected);
payload.put("ping", ping);
manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.PLAYER_POSITION, guild.getId(), payload));
}
public void onWebSocketClosed(int code, String reason, boolean byRemote) {
logger.warn("Guild {}: voice websocket closed with code {} ({}), by remote: {}", guild.getId(), code, reason, byRemote);
currentTrack = null;
@@ -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<Guild> 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();
}
@@ -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<Guild> 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);
}
}
@@ -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
@@ -0,0 +1,147 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.client.player.Track;
import me.bobert.netneutrality.events.EventPublisher;
import me.bobert.netneutrality.events.MusicEvent;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
public class QueueManagerEventsTest {
private final List<MusicEvent> events = new ArrayList<>();
private TrackScheduler scheduler;
private QueueManager queueManager;
private Member member;
@BeforeEach
void setUp() {
Guild guild = mock(Guild.class);
when(guild.getId()).thenReturn("guild1");
scheduler = mock(TrackScheduler.class);
MusicManager musicManager = mock(MusicManager.class);
EventPublisher publisher = events::add;
when(scheduler.getGuild()).thenReturn(guild);
when(scheduler.getMusicManager()).thenReturn(musicManager);
when(scheduler.getCallback()).thenReturn(mock(MusicManagerCallback.class));
when(musicManager.getEvents()).thenReturn(publisher);
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(QueueManagerTest.mockTrack("t1"));
member = mock(Member.class);
when(member.getId()).thenReturn("u1");
queueManager = new QueueManager(scheduler);
}
private void fill(int count) {
for (int i = 1; i <= count; i++) {
Track track = QueueManagerTest.mockTrack("t" + i);
queueManager.queue(track, member);
}
events.clear();
}
@SuppressWarnings("unchecked")
private Map<String, Object> payload(MusicEvent event) {
return (Map<String, Object>) event.payload();
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> items(MusicEvent event) {
return (List<Map<String, Object>>) payload(event).get("items");
}
@Test
public void addPublishesAddedWithIndex() {
queueManager.queue(QueueManagerTest.mockTrack("t1"), member);
assertEquals(1, events.size());
MusicEvent event = events.get(0);
assertEquals(MusicEvent.Type.QUEUE_UPDATED, event.type());
assertEquals("guild1", event.guildId());
assertEquals(1, payload(event).get("size"));
assertEquals("added", payload(event).get("change"));
assertEquals(0, items(event).get(0).get("index"));
assertEquals("t1", items(event).get(0).get("identifier"));
assertEquals("u1", items(event).get(0).get("requesterId"));
}
@Test
public void removePublishesRemovedWithOriginalIndex() {
fill(3);
queueManager.removeFromQueue(3);
assertEquals(1, events.size());
assertEquals("removed", payload(events.get(0)).get("change"));
assertEquals(2, payload(events.get(0)).get("size"));
assertEquals(2, items(events.get(0)).get(0).get("index"));
assertEquals("t3", items(events.get(0)).get(0).get("identifier"));
}
@Test
public void movePublishesFromAndTo() {
fill(4);
queueManager.reorder(4, 2);
assertEquals(1, events.size());
assertEquals("moved", payload(events.get(0)).get("change"));
Map<String, Object> item = items(events.get(0)).get(0);
assertEquals(3, item.get("fromIndex"));
assertEquals(1, item.get("toIndex"));
assertEquals("t4", item.get("identifier"));
}
@Test
public void clearPublishesCleared() {
fill(2);
queueManager.clearQueue();
assertEquals(1, events.size());
assertEquals("cleared", payload(events.get(0)).get("change"));
assertEquals(0, payload(events.get(0)).get("size"));
assertTrue(items(events.get(0)).isEmpty());
}
@Test
public void shufflePublishesShuffledAndQueueShuffled() {
fill(4);
queueManager.shuffle();
assertEquals(2, events.size());
assertEquals(MusicEvent.Type.QUEUE_UPDATED, events.get(0).type());
assertEquals("shuffled", payload(events.get(0)).get("change"));
assertEquals(4, payload(events.get(0)).get("size"));
assertEquals(MusicEvent.Type.QUEUE_SHUFFLED, events.get(1).type());
assertEquals("guild1", events.get(1).guildId());
}
@Test
public void removeHeadPublishesRemovedAtIndexZero() {
fill(2);
queueManager.removeHeadOfQueue();
assertEquals("removed", payload(events.get(0)).get("change"));
assertEquals(0, items(events.get(0)).get(0).get("index"));
assertEquals(1, payload(events.get(0)).get("size"));
}
}
@@ -11,6 +11,7 @@ import net.dv8tion.jda.api.entities.Guild;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.*;
@@ -76,6 +77,75 @@ public class TrackSchedulerTest {
assertNull(scheduler.getCurrentTrack());
}
@Test
@SuppressWarnings("unchecked")
public void onTrackExceptionPublishesTrackError() {
java.util.List<me.bobert.netneutrality.events.MusicEvent> events = new java.util.ArrayList<>();
when(musicManager.getEvents()).thenReturn(events::add);
dev.arbjerg.lavalink.client.player.TrackException exception = mock(dev.arbjerg.lavalink.client.player.TrackException.class);
when(exception.getMessage()).thenReturn("boom");
scheduler.attachContext(mock(me.bobert.netneutrality.context.MusicChannelContext.class));
scheduler.onTrackException(QueueManagerTest.mockTrack("t1"), exception);
assertEquals(1, events.size());
assertEquals(me.bobert.netneutrality.events.MusicEvent.Type.TRACK_ERROR, events.get(0).type());
assertEquals("guild1", events.get(0).guildId());
java.util.Map<String, Object> payload = (java.util.Map<String, Object>) events.get(0).payload();
assertEquals("boom", payload.get("message"));
assertEquals("t1", ((java.util.Map<String, Object>) payload.get("track")).get("identifier"));
}
@Test
@SuppressWarnings("unchecked")
public void onTrackStuckPublishesTrackStuck() {
java.util.List<me.bobert.netneutrality.events.MusicEvent> events = new java.util.ArrayList<>();
when(musicManager.getEvents()).thenReturn(events::add);
scheduler.attachContext(mock(me.bobert.netneutrality.context.MusicChannelContext.class));
scheduler.onTrackStuck(QueueManagerTest.mockTrack("t1"), 10000L);
me.bobert.netneutrality.events.MusicEvent event = events.stream()
.filter(e -> e.type() == me.bobert.netneutrality.events.MusicEvent.Type.TRACK_STUCK)
.findFirst().orElseThrow();
assertEquals("guild1", event.guildId());
java.util.Map<String, Object> payload = (java.util.Map<String, Object>) event.payload();
assertEquals(10000L, payload.get("thresholdMs"));
assertEquals("t1", ((java.util.Map<String, Object>) payload.get("track")).get("identifier"));
}
@Test
@SuppressWarnings("unchecked")
public void onPlayerUpdateIsThrottledPerGuild() {
java.util.List<me.bobert.netneutrality.events.MusicEvent> events = new java.util.ArrayList<>();
when(musicManager.getEvents()).thenReturn(events::add);
scheduler.onTrackStart(QueueManagerTest.mockTrack("t1"));
events.clear();
scheduler.onPlayerUpdate(100L, 1500L, true, 20L, 1000L);
scheduler.onPlayerUpdate(101L, 1600L, true, 20L, 3000L);
scheduler.onPlayerUpdate(102L, 6500L, true, 20L, 6000L);
assertEquals(2, events.size());
assertEquals(me.bobert.netneutrality.events.MusicEvent.Type.PLAYER_POSITION, events.get(0).type());
java.util.Map<String, Object> payload = (java.util.Map<String, Object>) events.get(0).payload();
assertEquals(1500L, payload.get("position"));
assertEquals(100L, payload.get("timestamp"));
assertEquals(true, payload.get("connected"));
assertEquals(20L, payload.get("ping"));
}
@Test
public void onPlayerUpdateSkippedWhenNothingPlaying() {
java.util.List<me.bobert.netneutrality.events.MusicEvent> events = new java.util.ArrayList<>();
when(musicManager.getEvents()).thenReturn(events::add);
scheduler.onPlayerUpdate(100L, 1500L, true, 20L, 1000L);
assertEquals(0, events.size());
}
@Test
public void getDjReturnsNullWhenNoContext() {
assertNull(scheduler.getDj());
@@ -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 "));
}
}