Refactor settings architecture and remove singleton pattern

Reworked `Settings` class to replace the static singleton pattern with constructor injection of `SettingsProvider`, improving testability and modularity. Updated all affected classes, tests, and configurations to align with the new architecture.
This commit is contained in:
anthorob committed 2026-09-10 05:59:55 -04:00
1 parent 1d737a2a4f
commit 64f4890931
21 files changed
+170 -120

No files matched your search

+17 -21
View File
@@ -6,7 +6,6 @@ package me.bobert.netneutrality;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.GSettings;
import club.minnced.discord.jdave.interop.JDaveSessionFactory;
import io.sentry.Sentry;
import me.bobert.netneutrality.bootstrap.ApplicationContext;
@@ -31,6 +30,7 @@ import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.utils.ChannelMessagePurger;
import me.bobert.netneutrality.db.JpaUtils;
import me.bobert.netneutrality.utils.Settings;
import me.bobert.netneutrality.http.WebModule;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.audio.AudioModuleConfig;
@@ -44,22 +44,19 @@ import org.apache.logging.log4j.core.config.Configurator;
public class App {
private static final Logger logger = LogManager.getLogger(App.class);
/**
* Version string resolved from the early-bootstrap {@link Settings} singleton (properties must be
* readable before the {@link ApplicationContext} exists).
*/
public static final String VERSION = Settings.getVersion();
private static App instance;
private final Settings settings;
private JDA jda;
private final ApplicationContext context = new ApplicationContext();
static void main() {
System.out.println("Starting App v" + VERSION);
Settings settings = new Settings();
String version = settings.getMavenVersion();
WebModule.setDefaultVersion(version);
logger.info("Starting App v{}", version);
// Settings is intentionally a process-wide bootstrap singleton: Sentry/env/token must be
// readable before the ApplicationContext is constructed.
String env = Settings.getInstance().get("NETNEUTRALITY_BOT_ENV", "dev");
String sentryDsn = Settings.getInstance().get("NETNEUTRALITY_SENTRY_DSN");
String env = settings.get("NETNEUTRALITY_BOT_ENV", "dev");
String sentryDsn = settings.get("NETNEUTRALITY_SENTRY_DSN");
if (sentryDsn != null) {
logger.info("Found Sentry DSN in config, setting up the SDK..");
@@ -68,7 +65,7 @@ public class App {
options.setEnvironment(env);
options.setTracesSampleRate(1.0);
options.setDebug(false);
options.setRelease(VERSION);
options.setRelease(version);
});
}
@@ -78,15 +75,16 @@ public class App {
Configurator.setLevel("com.sedmelluq.discord", Level.DEBUG);
}
instance = new App();
instance = new App(settings);
}
public App() {
public App(Settings settings) {
this.settings = settings;
startup();
}
private void startup() {
String token = Settings.getInstance().get("NETNEUTRALITY_BOT_TOKEN");
String token = settings.get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
throw new RuntimeException("No bot token provided, exiting.");
}
@@ -109,10 +107,8 @@ public class App {
* context stops components in the exact reverse order.
*/
private void buildContext() {
SettingsProvider settings = Settings.getInstance();
// Config first so SettingsProvider is resolvable for constructor injection.
context.register(settings);
context.register((SettingsProvider) settings);
// Allow ApplicationContext itself to be injected into components that need it.
context.register(context);
@@ -187,7 +183,7 @@ public class App {
}
public void initJDA() {
String token = Settings.getInstance().get("NETNEUTRALITY_BOT_TOKEN");
String token = settings.get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
SessionManager sessions = context.get(SessionManager.class);
if (sessions != null) {
@@ -216,9 +212,9 @@ public class App {
}
public void shutdown() {
System.out.println("[Net Neutrality] Shutting down...");
logger.info("[Net Neutrality] Shutting down...");
context.stop();
System.out.println("[Net Neutrality] Shutdown complete");
logger.info("[Net Neutrality] Shutdown complete");
}
public JDA getJDA() {
@@ -33,7 +33,7 @@ public class MessageCommandContext extends CommandContext implements MessageCont
private final SettingsProvider settings;
public MessageCommandContext(MessageReceivedEvent event, SettingsProvider settings) {
super(event.getJDA(), event.isFromGuild() ? event.getGuild() : null, event.getChannel(), event.getMember(), event.getAuthor());
super(event.getJDA(), event.isFromGuild() ? event.getGuild() : null, event.getChannel(), event.getMember(), event.getAuthor(), settings);
this.settings = settings;
this.event = event;
this.message = event.getMessage();
@@ -9,6 +9,7 @@ package me.bobert.netneutrality.context.command;
import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.events.Event;
@@ -20,8 +21,8 @@ import java.util.List;
public class MessageMenuContext extends CommandContext implements InteractionContext {
private final MessageContextInteractionEvent event;
public MessageMenuContext(MessageContextInteractionEvent event) {
super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser());
public MessageMenuContext(MessageContextInteractionEvent event, SettingsProvider settings) {
super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser(), settings);
this.event = event;
}
@@ -9,6 +9,7 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.command.options.SlashCommandOption;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
@@ -19,8 +20,8 @@ import java.util.stream.Collectors;
public class SlashCommandContext extends CommandContext implements InteractionContext {
private final SlashCommandInteractionEvent event;
public SlashCommandContext(SlashCommandInteractionEvent event) {
super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser());
public SlashCommandContext(SlashCommandInteractionEvent event, SettingsProvider settings) {
super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser(), settings);
this.event = event;
}
@@ -9,6 +9,7 @@ package me.bobert.netneutrality.context.command;
import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
@@ -21,8 +22,8 @@ import java.util.List;
public class UserMenuContext extends CommandContext implements InteractionContext {
private final UserContextInteractionEvent event;
public UserMenuContext(UserContextInteractionEvent event) {
super(event.getJDA(), event.getGuild(), (MessageChannel) event.getChannel(), event.getMember(), event.getUser());
public UserMenuContext(UserContextInteractionEvent event, SettingsProvider settings) {
super(event.getJDA(), event.getGuild(), (MessageChannel) event.getChannel(), event.getMember(), event.getUser(), settings);
this.event = event;
}
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.core.context;
import me.bobert.netneutrality.core.command.Command;
import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.utils.Settings;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
@@ -33,10 +33,12 @@ public abstract class CommandContext extends AbstractContext {
private SubcommandData subcommand;
private final Map<OptionData, String> invalidOptions = new HashMap<>();
private final List<CommandOption> options;
private final SettingsProvider settings;
public CommandContext(JDA jda, Guild guild, MessageChannel channel, Member member, User user) {
public CommandContext(JDA jda, Guild guild, MessageChannel channel, Member member, User user, SettingsProvider settings) {
super(jda, guild, channel, member, user);
this.options = new ArrayList<>();
this.settings = settings;
}
public Command getCommand() {
@@ -131,7 +133,7 @@ public abstract class CommandContext extends AbstractContext {
}
public boolean isMessageFromCreator() {
String creatorId = Settings.getInstance().get("NETNEUTRALITY_BOT_OWNER");
String creatorId = settings.get("NETNEUTRALITY_BOT_OWNER");
return creatorId.equals(getDiscordUser().getId());
}
@@ -5,6 +5,9 @@ package me.bobert.netneutrality.core.thread;
* https://discord.gg/qUa6zNFZsd
*/
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
@@ -18,6 +21,7 @@ import java.util.concurrent.TimeUnit;
*/
public class ThreadManager implements ExecutorProvider {
private static final Logger logger = LogManager.getLogger(ThreadManager.class);
private final Map<String, ExecutorService> executors = new ConcurrentHashMap<>();
public ThreadManager() {
@@ -56,7 +60,7 @@ public class ThreadManager implements ExecutorProvider {
@Override
public void stop() {
System.out.println("[Executor] Shutting down...");
logger.info("[Executor] Shutting down...");
try {
for (ExecutorService ex : executors.values()) {
@@ -64,17 +68,17 @@ public class ThreadManager implements ExecutorProvider {
ex.awaitTermination(1, TimeUnit.SECONDS);
}
} catch (InterruptedException e) {
System.err.println("[Executor] Running tasks interrupted");
logger.info("[Executor] Running tasks interrupted");
} finally {
executors.forEach((name, ex) -> {
if (!ex.isTerminated()) {
System.err.println("[Executor: " + name + "] Forcing shutdown of running tasks..");
logger.warn("[Executor: {}] Forcing shutdown of running tasks..", name);
ex.shutdownNow();
}
});
}
System.out.println("[Executor] Shutdown finished!");
logger.info("[Executor] Shutdown finished!");
}
}
@@ -88,9 +88,9 @@ public class EventsHandler implements EventListener {
return new MessageTaskContext(event);
});
addListener(UserContextInteractionEvent.class, UserMenuContext::new);
addListener(MessageContextInteractionEvent.class, MessageMenuContext::new);
addListener(SlashCommandInteractionEvent.class, SlashCommandContext::new);
addListener(UserContextInteractionEvent.class, event -> new UserMenuContext(event, settings));
addListener(MessageContextInteractionEvent.class, event -> new MessageMenuContext(event, settings));
addListener(SlashCommandInteractionEvent.class, event -> new SlashCommandContext(event, settings));
addListener(ModalInteractionEvent.class, ModalTaskContext::new);
addListener(ButtonInteractionEvent.class, ButtonTaskContext::new);
addListener(MessageReactionAddEvent.class, EmojiTaskContext::new);
@@ -9,7 +9,6 @@ package me.bobert.netneutrality.http;
import com.google.gson.Gson;
import io.sentry.Breadcrumb;
import io.sentry.Sentry;
import me.bobert.netneutrality.utils.Settings;
import okhttp3.Headers;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
@@ -35,7 +34,12 @@ public class WebModule {
}
private static final String USER_AGENT = "Net Neutrality/%s (Discord bot, discord.gg/trAqhQ5Wsr)";
private static volatile String defaultVersion = "0.0.0";
private static final Logger log = LoggerFactory.getLogger(WebModule.class);
public static void setDefaultVersion(String version) {
defaultVersion = version;
}
private static final Map<String, CachedResponse> cache = Collections.synchronizedMap(
new LinkedHashMap<>(100, 0.75f, true) {
@Override
@@ -170,6 +174,7 @@ public class WebModule {
public static class Builder {
private Request request;
private final String url;
private String version;
private Method method = Method.GET;
private long timeout = 5;
private TimeUnit timeUnit = TimeUnit.MINUTES;
@@ -187,6 +192,7 @@ public class WebModule {
*/
public Builder(String url) {
this.url = url;
this.version = defaultVersion;
}
public Builder ping() {
@@ -286,7 +292,7 @@ public class WebModule {
private Request createRequest() {
Request.Builder builder = new Request.Builder().url(url);
builder.addHeader("User-Agent", String.format(USER_AGENT, Settings.getVersion()));
builder.addHeader("User-Agent", String.format(USER_AGENT, version));
headers.forEach(builder::addHeader);
if (method == Method.PING) {
@@ -9,13 +9,16 @@ import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.core.tasks.impl.AbstractBuiltInTask;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.core.tasks.trait.WithButton;
import me.bobert.netneutrality.utils.Settings;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.components.buttons.Button;
public class ReportBug extends AbstractBuiltInTask implements WithButton {
public ReportBug(TaskManager taskManager) {
private final SettingsProvider settings;
public ReportBug(TaskManager taskManager, SettingsProvider settings) {
super(taskManager);
this.settings = settings;
}
@Override
@@ -31,6 +34,6 @@ public class ReportBug extends AbstractBuiltInTask implements WithButton {
@Override
public void executeTask(TaskContext context) {
context.replyEphemeral("To report a bug with the bot, you can either use the /report-bug command " +
"or join the discord here: " + Settings.getDiscordInvite());
"or join the discord here: " + settings.get("NETNEUTRALITY_BOT_INVITE", "https://discord.gg/PyCpd7JGMp"));
}
}
@@ -20,19 +20,14 @@ import java.util.concurrent.TimeUnit;
/**
* Process-wide configuration loaded from {@code bot.properties} and environment variables.
*
* <p>{@link #getInstance()} is retained intentionally as the early-bootstrap access point: Sentry,
* env and the bot token must be readable before the {@code ApplicationContext} exists. Once the
* context is built, prefer injecting {@link SettingsProvider}.
*/
public class Settings implements SettingsProvider {
private static Settings INSTANCE;
private final File propFile = new File("bot.properties");
private final Properties prop = new Properties();
public final String DATETIMEFORMAT = "yyyy-MM-dd HH:mm:ss";
private Settings() {
public Settings() {
checkAndLoadPropertiesFile();
loadEnvironmentVariables();
}
@@ -53,36 +48,6 @@ public class Settings implements SettingsProvider {
}
}
/**
* Early-bootstrap accessor used before the application context exists.
*
* @return the process-wide settings instance
*/
public static Settings getInstance() {
if (INSTANCE == null) {
INSTANCE = new Settings();
}
return INSTANCE;
}
/**
* Bootstrap helper for the public Discord invite URL.
*
* @return the invite URL
*/
public static String getDiscordInvite() {
return Settings.getInstance().get("NETNEUTRALITY_BOT_INVITE", "https://discord.gg/PyCpd7JGMp");
}
/**
* Bootstrap helper for the bot version string.
*
* @return the version
*/
public static String getVersion() {
return Settings.getInstance().getMavenVersion();
}
@Override
public String get(String key) {
@@ -14,11 +14,10 @@ import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.*;
class ReportIssueCommandTest {
private GitLabApi mockGitLabApi;
@@ -10,8 +10,7 @@ import me.bobert.netneutrality.context.command.MessageCommandContext;
import me.bobert.netneutrality.core.command.impl.AbstractCommand;
import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.utils.Settings;
import me.bobert.netneutrality.core.tasks.TaskManagerImpl;
import me.bobert.netneutrality.utils.SettingsProvider;
import me.bobert.netneutrality.core.tasks.TaskManager;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.*;
@@ -24,14 +23,12 @@ import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import me.bobert.netneutrality.bootstrap.ApplicationContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
@@ -65,8 +62,8 @@ import static org.mockito.Mockito.*;
class CommandProcessorTest {
// ----- Test Command Implementation -----
private static class TestCommand extends AbstractCommand {
static Consumer<CommandOptionsBuilder> optionsConfigurator = builder -> {};
static Consumer<CommandContext> onExecute = ctx -> {};
static Consumer<CommandOptionsBuilder> optionsConfigurator = _ -> {};
static Consumer<CommandContext> onExecute = _ -> {};
@Override
public String getCommandName() {
@@ -229,15 +226,13 @@ class CommandProcessorTest {
private Mentions mentions;
@Mock
private Settings settings;
private SettingsProvider settings;
private CommandManager commandManager;
private TaskManager taskManager;
private MockedStatic<Settings> settingsMockedStatic;
private void registerTestCommand(Consumer<CommandOptionsBuilder> optionsConfig) {
TestCommand.optionsConfigurator = optionsConfig;
TestCommand.onExecute = ctx -> {}; // Reset executor
TestCommand.onExecute = _ -> {}; // Reset executor
Command command = new TestCommand();
CommandContainer container = new CommandContainer(command);
commandManager.getCommands().put(command.getCommandName(), container);
@@ -297,23 +292,12 @@ class CommandProcessorTest {
@BeforeEach
void setup() {
// Mock Settings to return "$" as prefix (still used by message-context prefix checks)
settingsMockedStatic = mockStatic(Settings.class);
settingsMockedStatic.when(Settings::getInstance).thenReturn(settings);
when(settings.getPrefix()).thenReturn("$");
commandManager = new CommandManager(mock(ApplicationContext.class));
taskManager = mock(TaskManager.class);
}
@AfterEach
void teardown() {
if (settingsMockedStatic != null) {
settingsMockedStatic.close();
settingsMockedStatic = null;
}
}
// ===== Subcommand / Subcommand Group Tests =====
@Test
@@ -1,4 +1,4 @@
package me.bobert.netneutrality.feature.music;
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
@@ -9,7 +9,6 @@ import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.music.*;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import org.junit.jupiter.api.BeforeEach;
@@ -1,4 +1,4 @@
package me.bobert.netneutrality.feature.music;
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
@@ -6,7 +6,6 @@ package me.bobert.netneutrality.feature.music;
*/
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import me.bobert.netneutrality.music.TrackInfo;
import net.dv8tion.jda.api.entities.Member;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -14,7 +13,8 @@ import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class TrackInfoTest {
@@ -1,4 +1,4 @@
package me.bobert.netneutrality.feature.music;
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
@@ -8,7 +8,6 @@ package me.bobert.netneutrality.feature.music;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason;
import me.bobert.netneutrality.music.*;
import net.dv8tion.jda.api.entities.Guild;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -1,4 +1,4 @@
package me.bobert.netneutrality.feature.music;
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
@@ -7,9 +7,6 @@ package me.bobert.netneutrality.feature.music;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.music.QueueManager;
import me.bobert.netneutrality.music.TrackInfo;
import me.bobert.netneutrality.music.VoteSkipService;
import me.bobert.netneutrality.core.context.CommandContext;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
@@ -19,7 +16,8 @@ import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.*;
public class VoteSkipServiceTest {
@@ -10,7 +10,8 @@ import com.sedmelluq.discord.lavaplayer.track.AudioReference;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class UdioAudioSourceManagerTest {
@@ -8,13 +8,13 @@ package me.bobert.netneutrality.utils;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertNotNull;
public class SettingsTest {
@Test
public void getMavenVersion() {
String version = Settings.getInstance().getMavenVersion();
String version = new Settings().getMavenVersion();
assertNotNull(version);
}