From 60d7a66b428802a42ec1b1feaa14a69d2fa06d39 Mon Sep 17 00:00:00 2001
From: anthorob
Date: Tue, 29 Sep 2026 10:41:09 -0400
Subject: [PATCH] Introduce timezone support and configurable settings for
music bot
Added support for timezone-specific formatting, leveraging the new `TimeUtils` utility. Enhanced music bot functionality with configurable settings such as idle timeout, vote-skip threshold, DJ role, and more. Refactored configuration commands for modularity and added safe fallbacks for invalid configurations.
---
.../java/me/bobert/netneutrality/App.java | 4 +
.../command/bot/ConfigureCommand.java | 222 +++++++++++++-----
.../bobert/netneutrality/core/LogHandler.java | 12 +-
.../netneutrality/core/LogSeverity.java | 43 ++++
.../core/command/CommandProcessor.java | 2 +
.../DisabledCommandPrecondition.java | 52 ++++
.../netneutrality/music/MusicManager.java | 11 +-
.../netneutrality/music/VoteSkipService.java | 11 +-
.../music/MusicPlayerAutoDisconnect.java | 54 ++++-
.../netneutrality/utils/MessageFunction.java | 4 +-
.../netneutrality/utils/MusicUtils.java | 6 +
.../bobert/netneutrality/utils/TimeUtils.java | 43 ++++
.../netneutrality/utils/UserFunction.java | 34 ++-
13 files changed, 424 insertions(+), 74 deletions(-)
create mode 100644 src/main/java/me/bobert/netneutrality/core/LogSeverity.java
create mode 100644 src/main/java/me/bobert/netneutrality/core/command/precondition/DisabledCommandPrecondition.java
create mode 100644 src/main/java/me/bobert/netneutrality/utils/TimeUtils.java
diff --git a/src/main/java/me/bobert/netneutrality/App.java b/src/main/java/me/bobert/netneutrality/App.java
index 08a5ac4..a8cc55f 100644
--- a/src/main/java/me/bobert/netneutrality/App.java
+++ b/src/main/java/me/bobert/netneutrality/App.java
@@ -28,6 +28,8 @@ import me.bobert.netneutrality.core.thread.ExecutorProvider;
import me.bobert.netneutrality.utils.SettingsProvider;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.utils.ChannelMessagePurger;
+import me.bobert.netneutrality.utils.UserFunction;
+import me.bobert.netneutrality.core.command.precondition.DisabledCommandPrecondition;
import me.bobert.netneutrality.db.repository.BlacklistRepository;
import me.bobert.netneutrality.db.repository.GuildRepository;
import me.bobert.netneutrality.db.repository.GuildSettingRepository;
@@ -141,6 +143,8 @@ public class App {
GuildServiceProvider guildServices = context.register(
new GuildServiceProviderImpl(guildRepository, blacklistRepository, guildSettingRepository));
LogHandler.bind(guildServices);
+ UserFunction.bind(guildServices);
+ DisabledCommandPrecondition.bind(guildServices);
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors));
diff --git a/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java b/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java
index c31cecd..c71fed2 100644
--- a/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java
+++ b/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java
@@ -25,7 +25,11 @@ import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
public class ConfigureCommand extends AdminCommand {
private static final Logger logger = LogManager.getLogger(ConfigureCommand.class);
@@ -74,7 +78,35 @@ public class ConfigureCommand extends AdminCommand {
new SubcommandData("music-role", "Role needed to use music commands")
.addOption(OptionType.ROLE, "role", "Name of role", true),
new SubcommandData("music-channel", "Only allow music commands on this channel")
- .addOption(OptionType.CHANNEL, "channel", "Leave empty to allow everywhere")
+ .addOption(OptionType.CHANNEL, "channel", "Leave empty to allow everywhere"),
+ new SubcommandData("music-max-playlist-size", "Playlist size above which confirmation is asked before queueing")
+ .addOptions(new OptionData(OptionType.INTEGER, "size", "Number of tracks", true)
+ .setRequiredRange(1, 1000)),
+ new SubcommandData("music-dj-role", "Role that may control music playback without being the current DJ")
+ .addOption(OptionType.ROLE, "role", "Leave empty to clear"),
+ new SubcommandData("music-vote-skip-threshold", "Percentage of listeners required to approve a vote-skip")
+ .addOptions(new OptionData(OptionType.INTEGER, "percent", "Percentage (1-100)", true)
+ .setRequiredRange(1, 100)),
+ new SubcommandData("music-idle-timeout", "Minutes of inactivity before the bot auto-disconnects")
+ .addOptions(new OptionData(OptionType.INTEGER, "minutes", "Number of minutes", true)
+ .setRequiredRange(1, 1440)),
+ new SubcommandData("command-prefix", "Override the bot's message command prefix on this server")
+ .addOption(OptionType.STRING, "prefix", "Leave empty to use the bot-wide default"),
+ new SubcommandData("admin-role", "Role granted admin permissions, in addition to Discord permissions")
+ .addOption(OptionType.ROLE, "role", "Leave empty to clear"),
+ new SubcommandData("disabled-commands", "Comma-separated list of command names disabled on this server")
+ .addOption(OptionType.STRING, "commands", "Leave empty to re-enable everything"),
+ new SubcommandData("disable-command", "Disable a single command on this server")
+ .addOption(OptionType.STRING, "name", "Name of the command to disable", true),
+ new SubcommandData("enable-command", "Re-enable a single command on this server")
+ .addOption(OptionType.STRING, "name", "Name of the command to re-enable", true),
+ new SubcommandData("log-level", "Minimum severity of events sent to the log channel")
+ .addOptions(new OptionData(OptionType.STRING, "level", "Severity level", true)
+ .addChoice("ERROR", "ERROR").addChoice("WARN", "WARN").addChoice("INFO", "INFO")),
+ new SubcommandData("locale", "Preferred locale for the bot's replies on this server")
+ .addOption(OptionType.STRING, "locale", "Locale code, e.g. en, fr", true),
+ new SubcommandData("timezone", "Timezone used to display timestamps on this server")
+ .addOption(OptionType.STRING, "timezone", "Timezone id, e.g. UTC, Europe/Paris", true)
);
}
@@ -94,75 +126,149 @@ public class ConfigureCommand extends AdminCommand {
GuildSettingService settingService = guildService.getGuildSettingService();
context.ephemeral();
- if ("music-role".equals(sub)) {
- Role role = context.getOption("role", CommandOption::getAsRole);
- assert role != null;
+ return switch (sub) {
+ case "music-role" -> configureMusicRole(context, settingService);
+ case "music-channel" -> configureMusicChannel(context, settingService);
+ case "music-volume" -> configureMusicVolume(context, settingService);
+ case "error-logging" -> configureErrorLogging(context, guildService);
+ case "music-max-playlist-size" -> configureIntSetting(context, settingService, GSettings.MUSIC_MAXPLAYLISTSIZE, "size", "Maximum playlist size before confirmation is now %d");
+ case "music-dj-role" -> configureRoleSetting(context, settingService, GSettings.MUSIC_DJ_ROLE, "No dedicated DJ role is set anymore", "Successfully set %s as the DJ role");
+ case "music-vote-skip-threshold" -> configureIntSetting(context, settingService, GSettings.MUSIC_VOTE_SKIP_THRESHOLD, "percent", "Vote-skip threshold is now %d%%");
+ case "music-idle-timeout" -> configureIntSetting(context, settingService, GSettings.MUSIC_IDLE_TIMEOUT, "minutes", "Idle disconnect timeout is now %d minutes");
+ case "command-prefix" -> configureStringSetting(context, settingService, GSettings.COMMAND_PREFIX, "prefix", "Command prefix is now reset to the bot-wide default", "Command prefix is now set to %s");
+ case "admin-role" -> configureRoleSetting(context, settingService, GSettings.ADMIN_ROLE, "No dedicated admin role is set anymore", "Successfully set %s as the admin role");
+ case "disabled-commands" -> configureStringSetting(context, settingService, GSettings.DISABLED_COMMANDS, "commands", "All commands are now re-enabled", "Disabled commands are now: %s");
+ case "disable-command" -> toggleDisabledCommand(context, settingService, true);
+ case "enable-command" -> toggleDisabledCommand(context, settingService, false);
+ case "log-level" -> configureStringSetting(context, settingService, GSettings.LOG_LEVEL, "level", "Log level reset to default", "Log level is now %s");
+ case "locale" -> configureStringSetting(context, settingService, GSettings.LOCALE, "locale", "Locale reset to default", "Locale is now %s");
+ case "timezone" -> configureStringSetting(context, settingService, GSettings.TIMEZONE, "timezone", "Timezone reset to default", "Timezone is now %s");
+ case "show-all" -> showAll(context, settingService);
+ default -> CommandExecutionResult.error("Unknown subcommand: " + sub);
+ };
+ }
- if (role.getPosition() == -1) {
- settingService.set(GSettings.MUSIC_ROLE, null);
+ private CommandExecutionResult configureIntSetting(CommandContext context, GuildSettingService settingService, GSettings setting, String optionName, String successFormat) {
+ int value = context.getOption(optionName, Integer.parseInt(setting.getDefaultValue()), CommandOption::getAsInt);
+ settingService.set(setting, String.valueOf(value));
- return CommandExecutionResult.success("Everyone can now use music commands");
- }
+ return CommandExecutionResult.success(successFormat, value);
+ }
- if (role.getGuild().getId().equals(context.getGuild().getId())) {
- settingService.set(GSettings.MUSIC_ROLE, role.getId());
+ private CommandExecutionResult configureStringSetting(CommandContext context, GuildSettingService settingService, GSettings setting, String optionName, String clearedMessage, String successFormat) {
+ String value = context.getOption(optionName, "", CommandOption::getAsString);
- return CommandExecutionResult.success(String.format("Successfully set %s as the role needed for music command", role.getAsMention()));
- }
+ if (value.isEmpty()) {
+ settingService.set(setting, null);
+ return CommandExecutionResult.success(clearedMessage);
}
- if ("music-channel".equals(sub)) {
- GuildChannelUnion channel = context.getOption("channel", CommandOption::getAsChannel);
+ settingService.set(setting, value);
+ return CommandExecutionResult.success(successFormat, value);
+ }
- if (channel == null) {
- settingService.set(GSettings.MUSIC_CHANNEL, null);
+ private CommandExecutionResult toggleDisabledCommand(CommandContext context, GuildSettingService settingService, boolean disable) {
+ String name = context.getOption("name", "", CommandOption::getAsString).trim();
- return CommandExecutionResult.success("No channel restrictions for music commands");
- }
-
- if (channel instanceof TextChannel) {
- if (channel.getGuild().getId().equals(context.getGuild().getId())) {
- settingService.set(GSettings.MUSIC_CHANNEL, channel.getId());
-
- return CommandExecutionResult.success(String.format("Successfully set %s as the channel required to execute music command", channel.getAsMention()));
- }
+ Set disabledCommands = Arrays.stream(settingService.getOrDefault(GSettings.DISABLED_COMMANDS).split(","))
+ .map(String::trim)
+ .filter(value -> !value.isEmpty())
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+
+ if (disable) {
+ if (disabledCommands.stream().noneMatch(value -> value.equalsIgnoreCase(name))) {
+ disabledCommands.add(name);
+ }
+ } else {
+ disabledCommands.removeIf(value -> value.equalsIgnoreCase(name));
+ }
+
+ settingService.set(GSettings.DISABLED_COMMANDS, disabledCommands.isEmpty() ? null : String.join(",", disabledCommands));
+
+ return CommandExecutionResult.success(disable ? "Command %s is now disabled on this server" : "Command %s is now re-enabled on this server", name);
+ }
+
+ private CommandExecutionResult configureRoleSetting(CommandContext context, GuildSettingService settingService, GSettings setting, String clearedMessage, String successFormat) {
+ Role role = context.getOption("role", CommandOption::getAsRole);
+
+ if (role == null) {
+ settingService.set(setting, null);
+ return CommandExecutionResult.success(clearedMessage);
+ }
+
+ settingService.set(setting, role.getId());
+ return CommandExecutionResult.success(successFormat, role.getAsMention());
+ }
+
+ private CommandExecutionResult configureMusicRole(CommandContext context, GuildSettingService settingService) {
+ Role role = context.getOption("role", CommandOption::getAsRole);
+ assert role != null;
+
+ if (role.getPosition() == -1) {
+ settingService.set(GSettings.MUSIC_ROLE, null);
+
+ return CommandExecutionResult.success("Everyone can now use music commands");
+ }
+
+ if (role.getGuild().getId().equals(context.getGuild().getId())) {
+ settingService.set(GSettings.MUSIC_ROLE, role.getId());
+
+ return CommandExecutionResult.success(String.format("Successfully set %s as the role needed for music command", role.getAsMention()));
+ }
+
+ return CommandExecutionResult.error("Unknown subcommand: music-role");
+ }
+
+ private CommandExecutionResult configureMusicChannel(CommandContext context, GuildSettingService settingService) {
+ GuildChannelUnion channel = context.getOption("channel", CommandOption::getAsChannel);
+
+ if (channel == null) {
+ settingService.set(GSettings.MUSIC_CHANNEL, null);
+
+ return CommandExecutionResult.success("No channel restrictions for music commands");
+ }
+
+ if (channel instanceof TextChannel && channel.getGuild().getId().equals(context.getGuild().getId())) {
+ settingService.set(GSettings.MUSIC_CHANNEL, channel.getId());
+
+ return CommandExecutionResult.success(String.format("Successfully set %s as the channel required to execute music command", channel.getAsMention()));
+ }
+
+ return CommandExecutionResult.error("Unknown subcommand: music-channel");
+ }
+
+ private CommandExecutionResult configureMusicVolume(CommandContext context, GuildSettingService settingService) {
+ int volume = context.getOption("volume", 30, CommandOption::getAsInt);
+ settingService.set(GSettings.MUSIC_VOLUME, String.valueOf(volume));
+ musicService.get(context.getGuild()).setVolume(volume);
+
+ return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume));
+ }
+
+ private CommandExecutionResult configureErrorLogging(CommandContext context, GuildService guildService) {
+ boolean debug = context.getOption("debug", true, CommandOption::getAsBoolean);
+ guildService.updateDebugMode(debug);
+
+ return CommandExecutionResult.success("Error logging is now %s for this guild", (debug ? "enabled" : "disabled"));
+ }
+
+ private CommandExecutionResult showAll(CommandContext context, GuildSettingService settingService) {
+ Map settings = settingService.snapshot();
+ MessageCreateBuilder builder = new MessageCreateBuilder();
+
+ for (GSettings o : GSettings.values()) {
+ String value = o.getDefaultValue();
+
+ if (settings.containsKey(o.getName())) {
+ value = settings.get(o.getName());
}
+ builder.addContent(o.getName()).addContent(" (").addContent(o.getDescription()).addContent("): ").addContent(value);
+ builder.addContent("\n");
}
- if ("music-volume".equals(sub)) {
- int volume = context.getOption("volume", 30, CommandOption::getAsInt);
- settingService.set(GSettings.MUSIC_VOLUME, String.valueOf(volume));
- musicService.get(context.getGuild()).setVolume(volume);
+ context.replyEphemeral(builder.build());
- return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume));
- }
-
- if ("error-logging".equals(sub)) {
- boolean debug = context.getOption("debug", true, CommandOption::getAsBoolean);
- guildService.updateDebugMode(debug);
-
- return CommandExecutionResult.success("Error logging is now %s for this guild", (debug ? "enabled" : "disabled"));
- }
-
- if ("show-all".equals(sub)) {
- Map settings = settingService.snapshot();
- MessageCreateBuilder builder = new MessageCreateBuilder();
-
- for (GSettings o : GSettings.values()) {
- String value = o.getDefaultValue();
-
- if (settings.containsKey(o.getName())) {
- value = settings.get(o.getName());
- }
-
- builder.addContent(o.getName()).addContent(": ").addContent(value);
- builder.addContent("\n");
- }
-
- context.replyEphemeral(builder.build());
- }
-
- return CommandExecutionResult.error("Unknown subcommand: " + sub);
+ return CommandExecutionResult.success("Displayed current configuration");
}
}
diff --git a/src/main/java/me/bobert/netneutrality/core/LogHandler.java b/src/main/java/me/bobert/netneutrality/core/LogHandler.java
index fe40856..9c33c1e 100644
--- a/src/main/java/me/bobert/netneutrality/core/LogHandler.java
+++ b/src/main/java/me/bobert/netneutrality/core/LogHandler.java
@@ -9,7 +9,9 @@ import io.sentry.Sentry;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.core.context.TaskContext;
+import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
+import me.bobert.netneutrality.db.service.GuildSettingService;
import me.bobert.netneutrality.exceptions.ContextException;
import me.bobert.netneutrality.utils.MessageFunction;
import net.dv8tion.jda.api.entities.Guild;
@@ -93,12 +95,20 @@ public class LogHandler {
if (context.getGuild() != null) {
GuildServiceProvider provider = guildServices;
if (provider != null && provider.get(context.getGuild().getId()).isDebugEnabled()) {
+ GuildSettingService settingService = provider.get(context.getGuild().getId()).getGuildSettingService();
+ LogSeverity configured = LogSeverity.parse(settingService.getOrDefault(GSettings.LOG_LEVEL));
+
+ if (!LogSeverity.ERROR.meetsThreshold(configured)) {
+ return;
+ }
+
+ String timezone = settingService.getOrDefault(GSettings.TIMEZONE);
context.getGuild()
.getTextChannels()
.stream()
.filter(textChannel -> textChannel.getName().equals("debug") || textChannel.getName().equals("log"))
.filter(TextChannel::canTalk)
- .forEach(textChannel -> textChannel.sendMessageEmbeds(MessageFunction.buildDebugMessage(exception)).queue());
+ .forEach(textChannel -> textChannel.sendMessageEmbeds(MessageFunction.buildDebugMessage(exception, timezone)).queue());
}
}
}
diff --git a/src/main/java/me/bobert/netneutrality/core/LogSeverity.java b/src/main/java/me/bobert/netneutrality/core/LogSeverity.java
new file mode 100644
index 0000000..29d7d73
--- /dev/null
+++ b/src/main/java/me/bobert/netneutrality/core/LogSeverity.java
@@ -0,0 +1,43 @@
+package me.bobert.netneutrality.core;
+/*
+ * Copyright (c) anthorob
+ *
+ * https://discord.gg/qUa6zNFZsd
+ */
+
+/**
+ * Severity ordering used to gate what gets sent to a guild's log channel, from least to most verbose:
+ * {@code ERROR} < {@code WARN} < {@code INFO}.
+ */
+public enum LogSeverity {
+ ERROR,
+ WARN,
+ INFO;
+
+ /**
+ * Parses a severity name, falling back to {@link #ERROR} (the most restrictive level) when the
+ * value is missing or unrecognized.
+ *
+ * @param value the raw severity name (case-insensitive)
+ * @return the matching severity, or {@link #ERROR} as a safe default
+ */
+ public static LogSeverity parse(String value) {
+ if (value == null || value.isEmpty()) {
+ return ERROR;
+ }
+
+ try {
+ return LogSeverity.valueOf(value.toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return ERROR;
+ }
+ }
+
+ /**
+ * @param configured the guild's configured minimum severity
+ * @return {@code true} when this severity should be sent given the configured minimum
+ */
+ public boolean meetsThreshold(LogSeverity configured) {
+ return this.ordinal() <= configured.ordinal();
+ }
+}
diff --git a/src/main/java/me/bobert/netneutrality/core/command/CommandProcessor.java b/src/main/java/me/bobert/netneutrality/core/command/CommandProcessor.java
index 9c95fe1..3a68e7a 100644
--- a/src/main/java/me/bobert/netneutrality/core/command/CommandProcessor.java
+++ b/src/main/java/me/bobert/netneutrality/core/command/CommandProcessor.java
@@ -8,6 +8,7 @@ package me.bobert.netneutrality.core.command;
import io.sentry.Sentry;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.precondition.CommandPrecondition;
+import me.bobert.netneutrality.core.command.precondition.DisabledCommandPrecondition;
import me.bobert.netneutrality.core.command.precondition.GuildOnlyPrecondition;
import me.bobert.netneutrality.core.command.precondition.PermissionPrecondition;
import me.bobert.netneutrality.core.command.precondition.SlashCommandOnlyPrecondition;
@@ -52,6 +53,7 @@ public class CommandProcessor {
private static List defaultPreconditions() {
return List.of(
new GuildOnlyPrecondition(),
+ new DisabledCommandPrecondition(),
new SlashCommandOnlyPrecondition(),
new PermissionPrecondition()
);
diff --git a/src/main/java/me/bobert/netneutrality/core/command/precondition/DisabledCommandPrecondition.java b/src/main/java/me/bobert/netneutrality/core/command/precondition/DisabledCommandPrecondition.java
new file mode 100644
index 0000000..6fb5c91
--- /dev/null
+++ b/src/main/java/me/bobert/netneutrality/core/command/precondition/DisabledCommandPrecondition.java
@@ -0,0 +1,52 @@
+package me.bobert.netneutrality.core.command.precondition;
+/*
+ * Copyright (c) anthorob
+ *
+ * https://discord.gg/qUa6zNFZsd
+ */
+
+import me.bobert.netneutrality.core.command.Command;
+import me.bobert.netneutrality.core.context.CommandContext;
+import me.bobert.netneutrality.db.GSettings;
+import me.bobert.netneutrality.db.service.GuildServiceProvider;
+
+import java.util.Arrays;
+
+public class DisabledCommandPrecondition implements CommandPrecondition {
+
+ private static volatile GuildServiceProvider guildServices;
+
+ /**
+ * Binds the guild-service provider used to resolve the guild's configured {@code DISABLED_COMMANDS}.
+ * Called once during bootstrap.
+ *
+ * @param provider the live guild service provider
+ */
+ public static void bind(GuildServiceProvider provider) {
+ guildServices = provider;
+ }
+
+ @Override
+ public boolean check(CommandContext context, Command command) {
+ GuildServiceProvider provider = guildServices;
+ if (provider == null || context.getGuild() == null) {
+ return true;
+ }
+
+ String disabledCommands = provider.get(context.getGuild().getId()).getGuildSettingService().getOrDefault(GSettings.DISABLED_COMMANDS);
+ if (disabledCommands.isEmpty()) {
+ return true;
+ }
+
+ boolean disabled = Arrays.stream(disabledCommands.split(","))
+ .map(String::trim)
+ .anyMatch(name -> name.equalsIgnoreCase(command.getCommandName()));
+
+ if (disabled) {
+ context.replyEphemeral("This command is disabled on this server");
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/main/java/me/bobert/netneutrality/music/MusicManager.java b/src/main/java/me/bobert/netneutrality/music/MusicManager.java
index 18eceb0..05a90c1 100644
--- a/src/main/java/me/bobert/netneutrality/music/MusicManager.java
+++ b/src/main/java/me/bobert/netneutrality/music/MusicManager.java
@@ -221,7 +221,16 @@ public class MusicManager implements MusicManagerCallback {
}
public synchronized CommandExecutionResult voteSkip(CommandContext context) {
- return voteSkipService.voteSkip(context, getQueueManager(), getConnectedChannel(), isCurrentlyPlaying());
+ int threshold = parseVoteSkipThreshold(guildService);
+ return voteSkipService.voteSkip(context, getQueueManager(), getConnectedChannel(), isCurrentlyPlaying(), threshold);
+ }
+
+ private static int parseVoteSkipThreshold(GuildService guildService) {
+ try {
+ return Integer.parseInt(guildService.getGuildSettingService().getOrDefault(GSettings.MUSIC_VOTE_SKIP_THRESHOLD));
+ } catch (NumberFormatException e) {
+ return Integer.parseInt(GSettings.MUSIC_VOTE_SKIP_THRESHOLD.getDefaultValue());
+ }
}
public void setVolume(int volume) {
diff --git a/src/main/java/me/bobert/netneutrality/music/VoteSkipService.java b/src/main/java/me/bobert/netneutrality/music/VoteSkipService.java
index 2e84c36..f363883 100644
--- a/src/main/java/me/bobert/netneutrality/music/VoteSkipService.java
+++ b/src/main/java/me/bobert/netneutrality/music/VoteSkipService.java
@@ -18,6 +18,7 @@ public class VoteSkipService {
static final double VOTE_THRESHOLD_DIVISOR = 2.1;
static final int MIN_MEMBERS_FOR_VOTE = 3;
+ static final int DEFAULT_VOTE_THRESHOLD_PERCENT = 48;
/**
* Records a vote-skip from the invoking user and, if the threshold is met, skips the track.
@@ -28,6 +29,14 @@ public class VoteSkipService {
* recorded" message before the skip fires on the next invocation.
*/
public CommandExecutionResult voteSkip(CommandContext context, QueueManager queueManager, AudioChannel audioChannel, boolean isCurrentlyPlaying) {
+ return voteSkip(context, queueManager, audioChannel, isCurrentlyPlaying, DEFAULT_VOTE_THRESHOLD_PERCENT);
+ }
+
+ /**
+ * Same as {@link #voteSkip(CommandContext, QueueManager, AudioChannel, boolean)} but with a
+ * configurable vote threshold percentage of connected listeners.
+ */
+ public CommandExecutionResult voteSkip(CommandContext context, QueueManager queueManager, AudioChannel audioChannel, boolean isCurrentlyPlaying, int thresholdPercent) {
if (audioChannel == null) {
return CommandExecutionResult.warn("Bot is not connected to a voice channel");
}
@@ -49,7 +58,7 @@ public class VoteSkipService {
return CommandExecutionResult.info("Skipping current track since not enough people are connected to the voice channel.");
}
- double needed = connected / VOTE_THRESHOLD_DIVISOR;
+ double needed = connected * (thresholdPercent / 100.0);
info.addSkip(context.getGuildUser());
logger.debug("VoteSkip: guild={}, connected={}, needed={}, skips={}, user={}", context.getGuild().getId(), connected, needed, info.getSkipCount(), context.getGuildUser().getId());
diff --git a/src/main/java/me/bobert/netneutrality/scheduled/music/MusicPlayerAutoDisconnect.java b/src/main/java/me/bobert/netneutrality/scheduled/music/MusicPlayerAutoDisconnect.java
index 42616ba..f60907c 100644
--- a/src/main/java/me/bobert/netneutrality/scheduled/music/MusicPlayerAutoDisconnect.java
+++ b/src/main/java/me/bobert/netneutrality/scheduled/music/MusicPlayerAutoDisconnect.java
@@ -7,6 +7,7 @@ package me.bobert.netneutrality.scheduled.music;
*/
import me.bobert.netneutrality.core.scheduled.ScheduledTask;
+import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.MusicService;
import net.dv8tion.jda.api.entities.Member;
@@ -14,12 +15,17 @@ import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import java.time.Duration;
+import java.time.Instant;
import java.util.List;
import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
public class MusicPlayerAutoDisconnect implements ScheduledTask {
private final MusicService musicService;
+ private final ConcurrentMap idleSince = new ConcurrentHashMap<>();
public MusicPlayerAutoDisconnect(MusicService musicService) {
Objects.requireNonNull(musicService, "MusicService must not be null");
@@ -51,19 +57,45 @@ public class MusicPlayerAutoDisconnect implements ScheduledTask {
private void processManager(MusicManager manager) throws InterruptedException {
AudioChannel channel = manager.getConnectedChannel();
+ String guildId = manager.getGuild().getId();
- if (channel != null) {
- logger.debug("Processing MusicPlayerAutoDisconnectThread for guild {}", manager.getGuild().getId());
- if (conditionsMetToDisconnect(manager)) {
- logger.debug("Guild {}: Sleeping 30 seconds and checking again", manager.getGuild().getId());
- // Sleep 30 seconds then check again (prevents disconnecting right after a track ends)
- Thread.sleep(30000);
+ if (channel == null) {
+ idleSince.remove(guildId);
+ return;
+ }
- if (conditionsMetToDisconnect(manager)) {
- logger.debug("Guild {}: auto-disconnect player from voice channel: {}", manager.getGuild().getId(), channel.getId());
- manager.disconnect();
- }
- }
+ logger.debug("Processing MusicPlayerAutoDisconnectThread for guild {}", guildId);
+ if (!conditionsMetToDisconnect(manager)) {
+ idleSince.remove(guildId);
+ return;
+ }
+
+ Instant idleStart = idleSince.computeIfAbsent(guildId, id -> Instant.now());
+ long idleMinutes = Duration.between(idleStart, Instant.now()).toMinutes();
+ long timeoutMinutes = getIdleTimeoutMinutes(manager);
+
+ if (idleMinutes < timeoutMinutes) {
+ return;
+ }
+
+ logger.debug("Guild {}: Sleeping 30 seconds and checking again", guildId);
+ // Sleep 30 seconds then check again (prevents disconnecting right after a track ends)
+ Thread.sleep(30000);
+
+ if (conditionsMetToDisconnect(manager)) {
+ logger.debug("Guild {}: auto-disconnect player from voice channel: {}", guildId, channel.getId());
+ manager.disconnect();
+ idleSince.remove(guildId);
+ } else {
+ idleSince.remove(guildId);
+ }
+ }
+
+ private long getIdleTimeoutMinutes(MusicManager manager) {
+ try {
+ return Long.parseLong(manager.getGuildService().getGuildSettingService().getOrDefault(GSettings.MUSIC_IDLE_TIMEOUT));
+ } catch (NumberFormatException e) {
+ return Long.parseLong(GSettings.MUSIC_IDLE_TIMEOUT.getDefaultValue());
}
}
diff --git a/src/main/java/me/bobert/netneutrality/utils/MessageFunction.java b/src/main/java/me/bobert/netneutrality/utils/MessageFunction.java
index b4709fc..ad540c0 100644
--- a/src/main/java/me/bobert/netneutrality/utils/MessageFunction.java
+++ b/src/main/java/me/bobert/netneutrality/utils/MessageFunction.java
@@ -28,6 +28,7 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.awt.Color;
+import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.List;
@@ -263,12 +264,13 @@ public class MessageFunction {
return message.build();
}
- public static MessageEmbed buildDebugMessage(ContextException exception) {
+ public static MessageEmbed buildDebugMessage(ContextException exception, String timezone) {
Context context = exception.getContext();
EmbedBuilder message = new EmbedBuilder();
message.setColor(Color.RED);
message.setTitle(":warning: Exception in context");
+ message.addField("Time:", TimeUtils.format(Instant.now(), timezone), false);
if (context instanceof CommandContext commandContext) {
message.addField("Caused by command:", commandContext.getCommand().getCommandName(), false);
diff --git a/src/main/java/me/bobert/netneutrality/utils/MusicUtils.java b/src/main/java/me/bobert/netneutrality/utils/MusicUtils.java
index 589ec11..604f998 100644
--- a/src/main/java/me/bobert/netneutrality/utils/MusicUtils.java
+++ b/src/main/java/me/bobert/netneutrality/utils/MusicUtils.java
@@ -10,6 +10,7 @@ import me.bobert.netneutrality.context.MusicChannelContext;
import me.bobert.netneutrality.context.command.SlashCommandContext;
import me.bobert.netneutrality.core.context.Context;
+import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.exceptions.ContextException;
import net.dv8tion.jda.api.entities.GuildVoiceState;
import net.dv8tion.jda.api.entities.Member;
@@ -96,6 +97,11 @@ public class MusicUtils {
}
public static boolean isUserDj(Member member, MusicManager manager) {
+ String djRoleId = manager.getGuildService().getGuildSettingService().getOrDefault(GSettings.MUSIC_DJ_ROLE);
+ if (djRoleId != null && !djRoleId.isEmpty() && member.getRoles().stream().anyMatch(role -> role.getId().equals(djRoleId))) {
+ return true;
+ }
+
Member dj = manager.getScheduler().getDj();
if (manager.isCurrentlyPlaying() && dj != null) {
diff --git a/src/main/java/me/bobert/netneutrality/utils/TimeUtils.java b/src/main/java/me/bobert/netneutrality/utils/TimeUtils.java
new file mode 100644
index 0000000..e86a007
--- /dev/null
+++ b/src/main/java/me/bobert/netneutrality/utils/TimeUtils.java
@@ -0,0 +1,43 @@
+package me.bobert.netneutrality.utils;
+/*
+ * Copyright (c) anthorob
+ *
+ * https://discord.gg/qUa6zNFZsd
+ */
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Formats timestamps for guild-facing output (e.g. log embeds) using a guild's configured
+ * {@code TIMEZONE} setting, falling back to UTC when the setting is missing or invalid.
+ */
+public class TimeUtils {
+
+ private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z");
+
+ private TimeUtils() {
+ }
+
+ /**
+ * @param instant the instant to format
+ * @param timezone the guild's configured timezone id (e.g. {@code UTC}, {@code Europe/Paris})
+ * @return the formatted timestamp in the given timezone, or UTC if the timezone is missing/invalid
+ */
+ public static String format(Instant instant, String timezone) {
+ return FORMATTER.format(instant.atZone(resolveZone(timezone)));
+ }
+
+ private static ZoneId resolveZone(String timezone) {
+ if (timezone == null || timezone.isEmpty()) {
+ return ZoneId.of("UTC");
+ }
+
+ try {
+ return ZoneId.of(timezone);
+ } catch (Exception e) {
+ return ZoneId.of("UTC");
+ }
+ }
+}
diff --git a/src/main/java/me/bobert/netneutrality/utils/UserFunction.java b/src/main/java/me/bobert/netneutrality/utils/UserFunction.java
index 530112f..fec5411 100644
--- a/src/main/java/me/bobert/netneutrality/utils/UserFunction.java
+++ b/src/main/java/me/bobert/netneutrality/utils/UserFunction.java
@@ -8,12 +8,26 @@ package me.bobert.netneutrality.utils;
import me.bobert.netneutrality.context.command.SlashCommandContext;
import me.bobert.netneutrality.core.context.Context;
+import me.bobert.netneutrality.db.GSettings;
+import me.bobert.netneutrality.db.service.GuildServiceProvider;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role;
public class UserFunction {
+ private static volatile GuildServiceProvider guildServices;
+
+ /**
+ * Binds the guild-service provider used to resolve the guild's configured {@code ADMIN_ROLE}.
+ * Called once during bootstrap.
+ *
+ * @param provider the live guild service provider
+ */
+ public static void bind(GuildServiceProvider provider) {
+ guildServices = provider;
+ }
+
public static boolean isAdmin(Context context) {
if (context instanceof SlashCommandContext) {
return true;
@@ -30,7 +44,25 @@ public class UserFunction {
}
}
- return isAdmin;
+ if (isAdmin) {
+ return true;
+ }
+
+ return hasAdminRole(user);
+ }
+
+ private static boolean hasAdminRole(Member user) {
+ GuildServiceProvider provider = guildServices;
+ if (provider == null || user.getGuild() == null) {
+ return false;
+ }
+
+ String adminRoleId = provider.get(user.getGuild().getId()).getGuildSettingService().getOrDefault(GSettings.ADMIN_ROLE);
+ if (adminRoleId == null || adminRoleId.isEmpty()) {
+ return false;
+ }
+
+ return user.getRoles().stream().anyMatch(role -> role.getId().equals(adminRoleId));
}
}