Merve instance and guild configs into single file

Previous method only really made sense when plans for this bot were far
more ambitious than they are now.
This commit is contained in:
Noi 2022-12-04 16:30:50 -08:00
parent 8b5f0e83c4
commit 681e702406
8 changed files with 53 additions and 103 deletions

2
.vscode/launch.json vendored
View file

@ -11,7 +11,7 @@
"preLaunchTask": "build", "preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path. // If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/bin/Debug/net6.0/RegexBot.dll", "program": "${workspaceFolder}/bin/Debug/net6.0/RegexBot.dll",
"args": [], "args": [ "-c", "${workspaceFolder}/bin/Debug/net6.0/config.json" ],
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
"console": "internalConsole", "console": "internalConsole",

View file

@ -1,14 +1,9 @@
using CommandLine; using CommandLine;
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Reflection;
namespace RegexBot; namespace RegexBot;
/// <summary> class Configuration {
/// Contains essential instance configuration for this bot including Discord connection settings, service configuration,
/// and command-line options.
/// </summary>
class InstanceConfig {
/// <summary> /// <summary>
/// Token used for Discord authentication. /// Token used for Discord authentication.
/// </summary> /// </summary>
@ -19,18 +14,20 @@ class InstanceConfig {
/// </summary> /// </summary>
internal IReadOnlyList<string> Assemblies { get; } internal IReadOnlyList<string> Assemblies { get; }
public string? SqlHost { get; } public JObject ServerConfigs { get; }
public string? SqlDatabase { get; }
public string SqlUsername { get; } // SQL properties:
public string SqlPassword { get; } public string? Host { get; }
public string? Database { get; }
public string Username { get; }
public string Password { get; }
/// <summary> /// <summary>
/// Sets up instance configuration object from file and command line parameters. /// Sets up instance configuration object from file and command line parameters.
/// </summary> /// </summary>
internal InstanceConfig() { internal Configuration() {
var args = CommandLineParameters.Parse(Environment.GetCommandLineArgs()); var args = CommandLineParameters.Parse(Environment.GetCommandLineArgs());
var path = args?.ConfigFile ?? Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location) var path = args?.ConfigFile!;
+ Path.DirectorySeparatorChar + "." + Path.DirectorySeparatorChar + "instance.json";
JObject conf; JObject conf;
try { try {
@ -54,10 +51,16 @@ class InstanceConfig {
throw new Exception($"'{nameof(Assemblies)}' is not properly specified in configuration."); throw new Exception($"'{nameof(Assemblies)}' is not properly specified in configuration.");
} }
SqlHost = ReadConfKey<string>(conf, nameof(SqlHost), false); var dbconf = conf["DatabaseOptions"]?.Value<JObject>();
SqlDatabase = ReadConfKey<string?>(conf, nameof(SqlDatabase), false); if (dbconf == null) throw new Exception("Database settings were not specified in configuration.");
SqlUsername = ReadConfKey<string>(conf, nameof(SqlUsername), true); // TODO more detailed database configuration? password file, other advanced authentication settings... look into this.
SqlPassword = ReadConfKey<string>(conf, nameof(SqlPassword), true); Host = ReadConfKey<string>(dbconf, nameof(Host), false);
Database = ReadConfKey<string?>(dbconf, nameof(Database), false);
Username = ReadConfKey<string>(dbconf, nameof(Username), true);
Password = ReadConfKey<string>(dbconf, nameof(Password), true);
ServerConfigs = conf["Servers"]?.Value<JObject>();
if (ServerConfigs == null) throw new Exception("No server configurations were specified.");
} }
private static T? ReadConfKey<T>(JObject jc, string key, [DoesNotReturnIf(true)] bool failOnEmpty) { private static T? ReadConfKey<T>(JObject jc, string key, [DoesNotReturnIf(true)] bool failOnEmpty) {
@ -67,8 +70,8 @@ class InstanceConfig {
} }
class CommandLineParameters { class CommandLineParameters {
[Option('c', "config", Default = null)] [Option('c', "config", Default = "config.json")]
public string? ConfigFile { get; set; } = null!; public string? ConfigFile { get; set; } = null;
public static CommandLineParameters? Parse(string[] args) { public static CommandLineParameters? Parse(string[] args) {
CommandLineParameters? result = null; CommandLineParameters? result = null;

View file

@ -10,15 +10,16 @@ public class BotDatabaseContext : DbContext {
static BotDatabaseContext() { static BotDatabaseContext() {
// Get our own config loaded just for the SQL stuff // Get our own config loaded just for the SQL stuff
var conf = new InstanceConfig(); // TODO this should probably be cached, or otherwise loaded in a better way
var conf = new Configuration();
_connectionString = new NpgsqlConnectionStringBuilder() { _connectionString = new NpgsqlConnectionStringBuilder() {
#if DEBUG #if DEBUG
IncludeErrorDetail = true, IncludeErrorDetail = true,
#endif #endif
Host = conf.SqlHost ?? "localhost", // default to localhost Host = conf.Host ?? "localhost", // default to localhost
Database = conf.SqlDatabase, Database = conf.Database,
Username = conf.SqlUsername, Username = conf.Username,
Password = conf.SqlPassword Password = conf.Password
}.ToString(); }.ToString();
} }

View file

@ -7,7 +7,7 @@ static class ModuleLoader {
/// <summary> /// <summary>
/// Given the instance configuration, loads all appropriate types from file specified in it. /// Given the instance configuration, loads all appropriate types from file specified in it.
/// </summary> /// </summary>
internal static ReadOnlyCollection<RegexbotModule> Load(InstanceConfig conf, RegexbotClient rb) { internal static ReadOnlyCollection<RegexbotModule> Load(Configuration conf, RegexbotClient rb) {
var path = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location) + Path.DirectorySeparatorChar; var path = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location) + Path.DirectorySeparatorChar;
var modules = new List<RegexbotModule>(); var modules = new List<RegexbotModule>();

View file

@ -15,9 +15,9 @@ class Program {
StartTime = DateTimeOffset.UtcNow; StartTime = DateTimeOffset.UtcNow;
Console.WriteLine("Bot start time: " + StartTime.ToString("u")); Console.WriteLine("Bot start time: " + StartTime.ToString("u"));
InstanceConfig cfg; Configuration cfg;
try { try {
cfg = new InstanceConfig(); // Program may exit within here. cfg = new Configuration(); // Program may exit within here.
} catch (Exception ex) { } catch (Exception ex) {
Console.WriteLine(ex.Message); Console.WriteLine(ex.Message);
Environment.ExitCode = 1; Environment.ExitCode = 1;

View file

@ -11,14 +11,6 @@
<GenerateDocumentationFile>True</GenerateDocumentationFile> <GenerateDocumentationFile>True</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<None Remove="DefaultGuildConfig.json" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="DefaultGuildConfig.json" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.9.1" /> <PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Discord.Net" Version="3.8.1" /> <PackageReference Include="Discord.Net" Version="3.8.1" />

View file

@ -8,7 +8,7 @@ public partial class RegexbotClient {
/// <summary> /// <summary>
/// Gets application instance configuration. /// Gets application instance configuration.
/// </summary> /// </summary>
internal InstanceConfig Config { get; } internal Configuration Config { get; }
/// <summary> /// <summary>
/// Gets the Discord client instance. /// Gets the Discord client instance.
@ -20,14 +20,14 @@ public partial class RegexbotClient {
/// </summary> /// </summary>
internal IReadOnlyCollection<RegexbotModule> Modules { get; } internal IReadOnlyCollection<RegexbotModule> Modules { get; }
internal RegexbotClient(InstanceConfig conf, DiscordSocketClient client) { internal RegexbotClient(Configuration conf, DiscordSocketClient client) {
Config = conf; Config = conf;
DiscordClient = client; DiscordClient = client;
// Get all services started up // Get all services started up
_svcLogging = new Services.Logging.LoggingService(this); _svcLogging = new Services.Logging.LoggingService(this);
_svcSharedEvents = new Services.SharedEventService.SharedEventService(this); _svcSharedEvents = new Services.SharedEventService.SharedEventService(this);
_svcGuildState = new Services.ModuleState.ModuleStateService(this); _svcGuildState = new Services.ModuleState.ModuleStateService(this, conf.ServerConfigs);
_svcCommonFunctions = new Services.CommonFunctions.CommonFunctionsService(this); _svcCommonFunctions = new Services.CommonFunctions.CommonFunctionsService(this);
_svcEntityCache = new Services.EntityCache.EntityCacheService(this); _svcEntityCache = new Services.EntityCache.EntityCacheService(this);

View file

@ -1,20 +1,18 @@
using Newtonsoft.Json; using RegexBot.Common;
using RegexBot.Common;
using System.Reflection;
namespace RegexBot.Services.ModuleState; namespace RegexBot.Services.ModuleState;
/// <summary> /// <summary>
/// Implements per-module storage and retrieval of guild-specific state data, most typically but not limited to configuration data. /// Implements per-module storage and retrieval of guild-specific state data, most typically but not limited to configuration data.
/// To that end, this service handles loading and validation of per-guild configuration files.
/// </summary> /// </summary>
class ModuleStateService : Service { class ModuleStateService : Service {
private readonly object _storageLock = new();
private readonly Dictionary<ulong, EntityList> _moderators; private readonly Dictionary<ulong, EntityList> _moderators;
private readonly Dictionary<ulong, Dictionary<Type, object?>> _stateData; private readonly Dictionary<ulong, Dictionary<Type, object?>> _stateData;
private readonly JObject _serverConfs;
public ModuleStateService(RegexbotClient bot) : base(bot) { public ModuleStateService(RegexbotClient bot, JObject servers) : base(bot) {
_moderators = new(); _moderators = new();
_stateData = new(); _stateData = new();
_serverConfs = servers;
bot.DiscordClient.GuildAvailable += RefreshGuildState; bot.DiscordClient.GuildAvailable += RefreshGuildState;
bot.DiscordClient.JoinedGuild += RefreshGuildState; bot.DiscordClient.JoinedGuild += RefreshGuildState;
@ -27,45 +25,31 @@ class ModuleStateService : Service {
} }
private Task RemoveGuildData(SocketGuild arg) { private Task RemoveGuildData(SocketGuild arg) {
lock (_storageLock) {
_stateData.Remove(arg.Id); _stateData.Remove(arg.Id);
_moderators.Remove(arg.Id); _moderators.Remove(arg.Id);
}
return Task.CompletedTask; return Task.CompletedTask;
} }
// Hooked // Hooked
public T? DoGetStateObj<T>(ulong guildId, Type t) { public T? DoGetStateObj<T>(ulong guildId, Type t) {
lock (_storageLock) {
if (_stateData.ContainsKey(guildId) && _stateData[guildId].ContainsKey(t)) { if (_stateData.ContainsKey(guildId) && _stateData[guildId].ContainsKey(t)) {
// Leave handling of potential InvalidCastException to caller. // Leave handling of potential InvalidCastException to caller.
return (T?)_stateData[guildId][t]; return (T?)_stateData[guildId][t];
} }
return default; return default;
} }
}
// Hooked // Hooked
public EntityList DoGetModlist(ulong guildId) { public EntityList DoGetModlist(ulong guildId) {
lock (_storageLock) {
if (_moderators.TryGetValue(guildId, out var mods)) return mods; if (_moderators.TryGetValue(guildId, out var mods)) return mods;
else return new EntityList(); else return new EntityList();
} }
}
private async Task<bool> ProcessConfiguration(SocketGuild guild) { private async Task<bool> ProcessConfiguration(SocketGuild guild) {
var jstr = await LoadConfigFile(guild); var guildConf = _serverConfs[guild.Id.ToString()]?.Value<JObject>();
JObject guildConf; if (guildConf == null) {
try { Log($"{guild.Name} ({guild.Id}) has no configuration. Add config or consider removing bot from server.");
var tok = JToken.Parse(jstr); return true;
if (tok.Type == JTokenType.Object) {
guildConf = (JObject)tok;
} else {
throw new InvalidCastException("Configuration is not valid JSON.");
}
} catch (Exception ex) when (ex is JsonReaderException or InvalidCastException) {
Log($"Error loading configuration for server ID {guild.Id}: {ex.Message}");
return false;
} }
// Load moderator list // Load moderator list
@ -86,38 +70,8 @@ class ModuleStateService : Service {
return false; return false;
} }
} }
lock (_storageLock) {
_moderators[guild.Id] = mods; _moderators[guild.Id] = mods;
_stateData[guild.Id] = newStates; _stateData[guild.Id] = newStates;
}
return true; return true;
} }
private async Task<string> LoadConfigFile(SocketGuild guild) {
// Per-guild configuration exists under `config/(guild ID).json`
var basePath = Path.GetDirectoryName(Assembly.GetExecutingAssembly()!.Location) +
Path.DirectorySeparatorChar + "config" + Path.DirectorySeparatorChar;
if (!Directory.Exists(basePath)) Directory.CreateDirectory(basePath);
var path = basePath + guild.Id + ".json";
if (File.Exists(path)) {
return await File.ReadAllTextAsync(path);
} else { // Write default configuration to new file
string fileContents;
using (var resStream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream($"{nameof(RegexBot)}.DefaultGuildConfig.json")!) {
using var readin = new StreamReader(resStream, encoding: System.Text.Encoding.UTF8);
fileContents = readin.ReadToEnd();
}
var userex = BotClient.DiscordClient.CurrentUser;
fileContents = fileContents.Replace("SERVER NAME", guild.Name).Replace("MODERATOR", $"@{userex.Id}::{userex.Username}");
using (var newFile = File.OpenWrite(path)) {
var w = new StreamWriter(newFile);
w.Write(fileContents);
w.Flush();
w.Close();
}
Log($"Created initial configuration file in config{Path.DirectorySeparatorChar}{guild.Id}.json");
return await LoadConfigFile(guild);
}
}
} }