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.
This commit is contained in:
anthorob committed 2026-09-29 10:41:09 -04:00
1 parent a8b353ea73
commit 60d7a66b42
13 files changed
+373 -23

No files matched your search

@@ -28,6 +28,8 @@ import me.bobert.netneutrality.core.thread.ExecutorProvider;
import me.bobert.netneutrality.utils.SettingsProvider; import me.bobert.netneutrality.utils.SettingsProvider;
import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.utils.ChannelMessagePurger; 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.BlacklistRepository;
import me.bobert.netneutrality.db.repository.GuildRepository; import me.bobert.netneutrality.db.repository.GuildRepository;
import me.bobert.netneutrality.db.repository.GuildSettingRepository; import me.bobert.netneutrality.db.repository.GuildSettingRepository;
@@ -141,6 +143,8 @@ public class App {
GuildServiceProvider guildServices = context.register( GuildServiceProvider guildServices = context.register(
new GuildServiceProviderImpl(guildRepository, blacklistRepository, guildSettingRepository)); new GuildServiceProviderImpl(guildRepository, blacklistRepository, guildSettingRepository));
LogHandler.bind(guildServices); LogHandler.bind(guildServices);
UserFunction.bind(guildServices);
DisabledCommandPrecondition.bind(guildServices);
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown. // Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors)); music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors));
@@ -25,7 +25,11 @@ import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
public class ConfigureCommand extends AdminCommand { public class ConfigureCommand extends AdminCommand {
private static final Logger logger = LogManager.getLogger(ConfigureCommand.class); 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") new SubcommandData("music-role", "Role needed to use music commands")
.addOption(OptionType.ROLE, "role", "Name of role", true), .addOption(OptionType.ROLE, "role", "Name of role", true),
new SubcommandData("music-channel", "Only allow music commands on this channel") 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,7 +126,81 @@ public class ConfigureCommand extends AdminCommand {
GuildSettingService settingService = guildService.getGuildSettingService(); GuildSettingService settingService = guildService.getGuildSettingService();
context.ephemeral(); context.ephemeral();
if ("music-role".equals(sub)) { 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);
};
}
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(successFormat, value);
}
private CommandExecutionResult configureStringSetting(CommandContext context, GuildSettingService settingService, GSettings setting, String optionName, String clearedMessage, String successFormat) {
String value = context.getOption(optionName, "", CommandOption::getAsString);
if (value.isEmpty()) {
settingService.set(setting, null);
return CommandExecutionResult.success(clearedMessage);
}
settingService.set(setting, value);
return CommandExecutionResult.success(successFormat, value);
}
private CommandExecutionResult toggleDisabledCommand(CommandContext context, GuildSettingService settingService, boolean disable) {
String name = context.getOption("name", "", CommandOption::getAsString).trim();
Set<String> 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); Role role = context.getOption("role", CommandOption::getAsRole);
assert role != null; assert role != null;
@@ -109,9 +215,11 @@ public class ConfigureCommand extends AdminCommand {
return CommandExecutionResult.success(String.format("Successfully set %s as the role needed for music command", role.getAsMention())); return CommandExecutionResult.success(String.format("Successfully set %s as the role needed for music command", role.getAsMention()));
} }
return CommandExecutionResult.error("Unknown subcommand: music-role");
} }
if ("music-channel".equals(sub)) { private CommandExecutionResult configureMusicChannel(CommandContext context, GuildSettingService settingService) {
GuildChannelUnion channel = context.getOption("channel", CommandOption::getAsChannel); GuildChannelUnion channel = context.getOption("channel", CommandOption::getAsChannel);
if (channel == null) { if (channel == null) {
@@ -120,17 +228,16 @@ public class ConfigureCommand extends AdminCommand {
return CommandExecutionResult.success("No channel restrictions for music commands"); return CommandExecutionResult.success("No channel restrictions for music commands");
} }
if (channel instanceof TextChannel) { if (channel instanceof TextChannel && channel.getGuild().getId().equals(context.getGuild().getId())) {
if (channel.getGuild().getId().equals(context.getGuild().getId())) {
settingService.set(GSettings.MUSIC_CHANNEL, channel.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.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) {
if ("music-volume".equals(sub)) {
int volume = context.getOption("volume", 30, CommandOption::getAsInt); int volume = context.getOption("volume", 30, CommandOption::getAsInt);
settingService.set(GSettings.MUSIC_VOLUME, String.valueOf(volume)); settingService.set(GSettings.MUSIC_VOLUME, String.valueOf(volume));
musicService.get(context.getGuild()).setVolume(volume); musicService.get(context.getGuild()).setVolume(volume);
@@ -138,14 +245,14 @@ public class ConfigureCommand extends AdminCommand {
return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume)); return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume));
} }
if ("error-logging".equals(sub)) { private CommandExecutionResult configureErrorLogging(CommandContext context, GuildService guildService) {
boolean debug = context.getOption("debug", true, CommandOption::getAsBoolean); boolean debug = context.getOption("debug", true, CommandOption::getAsBoolean);
guildService.updateDebugMode(debug); guildService.updateDebugMode(debug);
return CommandExecutionResult.success("Error logging is now %s for this guild", (debug ? "enabled" : "disabled")); return CommandExecutionResult.success("Error logging is now %s for this guild", (debug ? "enabled" : "disabled"));
} }
if ("show-all".equals(sub)) { private CommandExecutionResult showAll(CommandContext context, GuildSettingService settingService) {
Map<String, String> settings = settingService.snapshot(); Map<String, String> settings = settingService.snapshot();
MessageCreateBuilder builder = new MessageCreateBuilder(); MessageCreateBuilder builder = new MessageCreateBuilder();
@@ -156,13 +263,12 @@ public class ConfigureCommand extends AdminCommand {
value = settings.get(o.getName()); value = settings.get(o.getName());
} }
builder.addContent(o.getName()).addContent(": ").addContent(value); builder.addContent(o.getName()).addContent(" (").addContent(o.getDescription()).addContent("): ").addContent(value);
builder.addContent("\n"); builder.addContent("\n");
} }
context.replyEphemeral(builder.build()); context.replyEphemeral(builder.build());
}
return CommandExecutionResult.error("Unknown subcommand: " + sub); return CommandExecutionResult.success("Displayed current configuration");
} }
} }
@@ -9,7 +9,9 @@ import io.sentry.Sentry;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.Context; import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.core.context.TaskContext; 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.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildSettingService;
import me.bobert.netneutrality.exceptions.ContextException; import me.bobert.netneutrality.exceptions.ContextException;
import me.bobert.netneutrality.utils.MessageFunction; import me.bobert.netneutrality.utils.MessageFunction;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
@@ -93,12 +95,20 @@ public class LogHandler {
if (context.getGuild() != null) { if (context.getGuild() != null) {
GuildServiceProvider provider = guildServices; GuildServiceProvider provider = guildServices;
if (provider != null && provider.get(context.getGuild().getId()).isDebugEnabled()) { 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() context.getGuild()
.getTextChannels() .getTextChannels()
.stream() .stream()
.filter(textChannel -> textChannel.getName().equals("debug") || textChannel.getName().equals("log")) .filter(textChannel -> textChannel.getName().equals("debug") || textChannel.getName().equals("log"))
.filter(TextChannel::canTalk) .filter(TextChannel::canTalk)
.forEach(textChannel -> textChannel.sendMessageEmbeds(MessageFunction.buildDebugMessage(exception)).queue()); .forEach(textChannel -> textChannel.sendMessageEmbeds(MessageFunction.buildDebugMessage(exception, timezone)).queue());
} }
} }
} }
@@ -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} &lt; {@code WARN} &lt; {@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();
}
}
@@ -8,6 +8,7 @@ package me.bobert.netneutrality.core.command;
import io.sentry.Sentry; import io.sentry.Sentry;
import me.bobert.netneutrality.core.LogHandler; import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.precondition.CommandPrecondition; 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.GuildOnlyPrecondition;
import me.bobert.netneutrality.core.command.precondition.PermissionPrecondition; import me.bobert.netneutrality.core.command.precondition.PermissionPrecondition;
import me.bobert.netneutrality.core.command.precondition.SlashCommandOnlyPrecondition; import me.bobert.netneutrality.core.command.precondition.SlashCommandOnlyPrecondition;
@@ -52,6 +53,7 @@ public class CommandProcessor {
private static List<CommandPrecondition> defaultPreconditions() { private static List<CommandPrecondition> defaultPreconditions() {
return List.of( return List.of(
new GuildOnlyPrecondition(), new GuildOnlyPrecondition(),
new DisabledCommandPrecondition(),
new SlashCommandOnlyPrecondition(), new SlashCommandOnlyPrecondition(),
new PermissionPrecondition() new PermissionPrecondition()
); );
@@ -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;
}
}
@@ -221,7 +221,16 @@ public class MusicManager implements MusicManagerCallback {
} }
public synchronized CommandExecutionResult voteSkip(CommandContext context) { 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) { public void setVolume(int volume) {
@@ -18,6 +18,7 @@ public class VoteSkipService {
static final double VOTE_THRESHOLD_DIVISOR = 2.1; static final double VOTE_THRESHOLD_DIVISOR = 2.1;
static final int MIN_MEMBERS_FOR_VOTE = 3; 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. * 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.</p> * recorded" message before the skip fires on the next invocation.</p>
*/ */
public CommandExecutionResult voteSkip(CommandContext context, QueueManager queueManager, AudioChannel audioChannel, boolean isCurrentlyPlaying) { 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) { if (audioChannel == null) {
return CommandExecutionResult.warn("Bot is not connected to a voice channel"); 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."); 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()); info.addSkip(context.getGuildUser());
logger.debug("VoteSkip: guild={}, connected={}, needed={}, skips={}, user={}", context.getGuild().getId(), connected, needed, info.getSkipCount(), context.getGuildUser().getId()); logger.debug("VoteSkip: guild={}, connected={}, needed={}, skips={}, user={}", context.getGuild().getId(), connected, needed, info.getSkipCount(), context.getGuildUser().getId());
@@ -7,6 +7,7 @@ package me.bobert.netneutrality.scheduled.music;
*/ */
import me.bobert.netneutrality.core.scheduled.ScheduledTask; import me.bobert.netneutrality.core.scheduled.ScheduledTask;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.music.MusicService;
import net.dv8tion.jda.api.entities.Member; 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.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import java.time.Duration;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public class MusicPlayerAutoDisconnect implements ScheduledTask { public class MusicPlayerAutoDisconnect implements ScheduledTask {
private final MusicService musicService; private final MusicService musicService;
private final ConcurrentMap<String, Instant> idleSince = new ConcurrentHashMap<>();
public MusicPlayerAutoDisconnect(MusicService musicService) { public MusicPlayerAutoDisconnect(MusicService musicService) {
Objects.requireNonNull(musicService, "MusicService must not be null"); Objects.requireNonNull(musicService, "MusicService must not be null");
@@ -51,19 +57,45 @@ public class MusicPlayerAutoDisconnect implements ScheduledTask {
private void processManager(MusicManager manager) throws InterruptedException { private void processManager(MusicManager manager) throws InterruptedException {
AudioChannel channel = manager.getConnectedChannel(); AudioChannel channel = manager.getConnectedChannel();
String guildId = manager.getGuild().getId();
if (channel != null) { if (channel == null) {
logger.debug("Processing MusicPlayerAutoDisconnectThread for guild {}", manager.getGuild().getId()); idleSince.remove(guildId);
if (conditionsMetToDisconnect(manager)) { return;
logger.debug("Guild {}: Sleeping 30 seconds and checking again", manager.getGuild().getId()); }
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) // Sleep 30 seconds then check again (prevents disconnecting right after a track ends)
Thread.sleep(30000); Thread.sleep(30000);
if (conditionsMetToDisconnect(manager)) { if (conditionsMetToDisconnect(manager)) {
logger.debug("Guild {}: auto-disconnect player from voice channel: {}", manager.getGuild().getId(), channel.getId()); logger.debug("Guild {}: auto-disconnect player from voice channel: {}", guildId, channel.getId());
manager.disconnect(); 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());
} }
} }
@@ -28,6 +28,7 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import java.awt.Color; import java.awt.Color;
import java.time.Instant;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -263,12 +264,13 @@ public class MessageFunction {
return message.build(); return message.build();
} }
public static MessageEmbed buildDebugMessage(ContextException exception) { public static MessageEmbed buildDebugMessage(ContextException exception, String timezone) {
Context context = exception.getContext(); Context context = exception.getContext();
EmbedBuilder message = new EmbedBuilder(); EmbedBuilder message = new EmbedBuilder();
message.setColor(Color.RED); message.setColor(Color.RED);
message.setTitle(":warning: Exception in context"); message.setTitle(":warning: Exception in context");
message.addField("Time:", TimeUtils.format(Instant.now(), timezone), false);
if (context instanceof CommandContext commandContext) { if (context instanceof CommandContext commandContext) {
message.addField("Caused by command:", commandContext.getCommand().getCommandName(), false); message.addField("Caused by command:", commandContext.getCommand().getCommandName(), false);
@@ -10,6 +10,7 @@ import me.bobert.netneutrality.context.MusicChannelContext;
import me.bobert.netneutrality.context.command.SlashCommandContext; import me.bobert.netneutrality.context.command.SlashCommandContext;
import me.bobert.netneutrality.core.context.Context; import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.exceptions.ContextException; import me.bobert.netneutrality.exceptions.ContextException;
import net.dv8tion.jda.api.entities.GuildVoiceState; import net.dv8tion.jda.api.entities.GuildVoiceState;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
@@ -96,6 +97,11 @@ public class MusicUtils {
} }
public static boolean isUserDj(Member member, MusicManager manager) { 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(); Member dj = manager.getScheduler().getDj();
if (manager.isCurrentlyPlaying() && dj != null) { if (manager.isCurrentlyPlaying() && dj != null) {
@@ -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");
}
}
}
@@ -8,12 +8,26 @@ package me.bobert.netneutrality.utils;
import me.bobert.netneutrality.context.command.SlashCommandContext; import me.bobert.netneutrality.context.command.SlashCommandContext;
import me.bobert.netneutrality.core.context.Context; 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.Permission;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.Role;
public class UserFunction { 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) { public static boolean isAdmin(Context context) {
if (context instanceof SlashCommandContext) { if (context instanceof SlashCommandContext) {
return true; 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));
} }
} }