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)); } }