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) {