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:
1 parent
a8b353ea73
commit
60d7a66b42
13 files changed
+424
-74
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,75 +126,149 @@ 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) {
|
||||||
Role role = context.getOption("role", CommandOption::getAsRole);
|
case "music-role" -> configureMusicRole(context, settingService);
|
||||||
assert role != null;
|
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) {
|
private CommandExecutionResult configureIntSetting(CommandContext context, GuildSettingService settingService, GSettings setting, String optionName, String successFormat) {
|
||||||
settingService.set(GSettings.MUSIC_ROLE, null);
|
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())) {
|
private CommandExecutionResult configureStringSetting(CommandContext context, GuildSettingService settingService, GSettings setting, String optionName, String clearedMessage, String successFormat) {
|
||||||
settingService.set(GSettings.MUSIC_ROLE, role.getId());
|
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)) {
|
settingService.set(setting, value);
|
||||||
GuildChannelUnion channel = context.getOption("channel", CommandOption::getAsChannel);
|
return CommandExecutionResult.success(successFormat, value);
|
||||||
|
}
|
||||||
|
|
||||||
if (channel == null) {
|
private CommandExecutionResult toggleDisabledCommand(CommandContext context, GuildSettingService settingService, boolean disable) {
|
||||||
settingService.set(GSettings.MUSIC_CHANNEL, null);
|
String name = context.getOption("name", "", CommandOption::getAsString).trim();
|
||||||
|
|
||||||
return CommandExecutionResult.success("No channel restrictions for music commands");
|
Set<String> disabledCommands = Arrays.stream(settingService.getOrDefault(GSettings.DISABLED_COMMANDS).split(","))
|
||||||
}
|
.map(String::trim)
|
||||||
|
.filter(value -> !value.isEmpty())
|
||||||
if (channel instanceof TextChannel) {
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
if (channel.getGuild().getId().equals(context.getGuild().getId())) {
|
|
||||||
settingService.set(GSettings.MUSIC_CHANNEL, channel.getId());
|
if (disable) {
|
||||||
|
if (disabledCommands.stream().noneMatch(value -> value.equalsIgnoreCase(name))) {
|
||||||
return CommandExecutionResult.success(String.format("Successfully set %s as the channel required to execute music command", channel.getAsMention()));
|
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<String, String> 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)) {
|
context.replyEphemeral(builder.build());
|
||||||
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));
|
return CommandExecutionResult.success("Displayed current configuration");
|
||||||
}
|
|
||||||
|
|
||||||
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<String, String> 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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} < {@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
);
|
);
|
||||||
|
|||||||
+52
@@ -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());
|
||||||
|
|||||||
+43
-11
@@ -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());
|
}
|
||||||
// Sleep 30 seconds then check again (prevents disconnecting right after a track ends)
|
|
||||||
Thread.sleep(30000);
|
|
||||||
|
|
||||||
if (conditionsMetToDisconnect(manager)) {
|
logger.debug("Processing MusicPlayerAutoDisconnectThread for guild {}", guildId);
|
||||||
logger.debug("Guild {}: auto-disconnect player from voice channel: {}", manager.getGuild().getId(), channel.getId());
|
if (!conditionsMetToDisconnect(manager)) {
|
||||||
manager.disconnect();
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user