diff --git a/.vscode/launch.json b/.vscode/launch.json index f29b948..eb527f9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,7 @@ "preLaunchTask": "build", // If you have changed target frameworks, make sure to update the program path. "program": "${workspaceFolder}/bin/Debug/net6.0/RegexBot.dll", - "args": [], + "args": [ "-c", "${workspaceFolder}/bin/Debug/net6.0/config.json" ], "cwd": "${workspaceFolder}", // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console "console": "internalConsole", diff --git a/InstanceConfig.cs b/Configuration.cs similarity index 66% rename from InstanceConfig.cs rename to Configuration.cs index 413633a..eef0dbe 100644 --- a/InstanceConfig.cs +++ b/Configuration.cs @@ -1,14 +1,9 @@ using CommandLine; using Newtonsoft.Json; using System.Diagnostics.CodeAnalysis; -using System.Reflection; namespace RegexBot; -/// -/// Contains essential instance configuration for this bot including Discord connection settings, service configuration, -/// and command-line options. -/// -class InstanceConfig { +class Configuration { /// /// Token used for Discord authentication. /// @@ -19,18 +14,20 @@ class InstanceConfig { /// internal IReadOnlyList Assemblies { get; } - public string? SqlHost { get; } - public string? SqlDatabase { get; } - public string SqlUsername { get; } - public string SqlPassword { get; } + public JObject ServerConfigs { get; } + + // SQL properties: + public string? Host { get; } + public string? Database { get; } + public string Username { get; } + public string Password { get; } /// /// Sets up instance configuration object from file and command line parameters. /// - internal InstanceConfig() { + internal Configuration() { var args = CommandLineParameters.Parse(Environment.GetCommandLineArgs()); - var path = args?.ConfigFile ?? Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location) - + Path.DirectorySeparatorChar + "." + Path.DirectorySeparatorChar + "instance.json"; + var path = args?.ConfigFile!; JObject conf; try { @@ -54,10 +51,16 @@ class InstanceConfig { throw new Exception($"'{nameof(Assemblies)}' is not properly specified in configuration."); } - SqlHost = ReadConfKey(conf, nameof(SqlHost), false); - SqlDatabase = ReadConfKey(conf, nameof(SqlDatabase), false); - SqlUsername = ReadConfKey(conf, nameof(SqlUsername), true); - SqlPassword = ReadConfKey(conf, nameof(SqlPassword), true); + var dbconf = conf["DatabaseOptions"]?.Value(); + if (dbconf == null) throw new Exception("Database settings were not specified in configuration."); + // TODO more detailed database configuration? password file, other advanced authentication settings... look into this. + Host = ReadConfKey(dbconf, nameof(Host), false); + Database = ReadConfKey(dbconf, nameof(Database), false); + Username = ReadConfKey(dbconf, nameof(Username), true); + Password = ReadConfKey(dbconf, nameof(Password), true); + + ServerConfigs = conf["Servers"]?.Value(); + if (ServerConfigs == null) throw new Exception("No server configurations were specified."); } private static T? ReadConfKey(JObject jc, string key, [DoesNotReturnIf(true)] bool failOnEmpty) { @@ -67,8 +70,8 @@ class InstanceConfig { } class CommandLineParameters { - [Option('c', "config", Default = null)] - public string? ConfigFile { get; set; } = null!; + [Option('c', "config", Default = "config.json")] + public string? ConfigFile { get; set; } = null; public static CommandLineParameters? Parse(string[] args) { CommandLineParameters? result = null; diff --git a/ConfigurationSchema.json b/ConfigurationSchema.json new file mode 100644 index 0000000..6c126e3 --- /dev/null +++ b/ConfigurationSchema.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "BotToken": { + "type": "string", + "description": "The token used by the bot to connect to Discord." + }, + "Assemblies": { + "type": "array", + "description": "A list of additional files to be loaded to extend the bot's functionality.", + "default": [ "RegexBot.dll" ] + }, + "DatabaseOptions": { + "type": "object", + "description": "A set of options for the SQL database connection.", + "properties": { + "Host": { + "type": "string", + "description": "The SQL host, whether a hostname, IP address, or path to a socket." + }, + "Database": { + "type": "string", + "description": "The target SQL database name to connect to, if different from the default." + }, + "Username": { + "type": "string", + "description": "The username used for SQL server authentication." + }, + "Password": { + "type": "string", + "description": "The password used for SQL server authentication." + } + }, + "required": [ "Username", "Password" ] + }, + "Servers": { + "type": "object", + "description": "A collection of server configurations with keys representing server IDs and values containing the respective server's configuration." + /* TODO unfinished */ + } + }, + "required": [ "BotToken", "DatabaseOptions", "Servers" ] +} \ No newline at end of file diff --git a/Data/BotDatabaseContext.cs b/Data/BotDatabaseContext.cs index 319594d..5e2a775 100644 --- a/Data/BotDatabaseContext.cs +++ b/Data/BotDatabaseContext.cs @@ -10,15 +10,16 @@ public class BotDatabaseContext : DbContext { static BotDatabaseContext() { // 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() { #if DEBUG IncludeErrorDetail = true, #endif - Host = conf.SqlHost ?? "localhost", // default to localhost - Database = conf.SqlDatabase, - Username = conf.SqlUsername, - Password = conf.SqlPassword + Host = conf.Host ?? "localhost", // default to localhost + Database = conf.Database, + Username = conf.Username, + Password = conf.Password }.ToString(); } diff --git a/DefaultGuildConfig.json b/DefaultGuildConfig.json deleted file mode 100644 index 07ddb10..0000000 --- a/DefaultGuildConfig.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/NoiTheCat/RegexBot/main/ServerConfigSchema.json", - "Name": "SERVER NAME", // Server name is optional, but useful as a reference - "Moderators": [ - // Users and roles are accepted here. - "MODERATOR" - ], - - /* - The following configuration is provided as a sample for commonly-used features. - For a detailed reference which includes all possible configuration settings, see: - (TODO put documentation link here) - */ - "RegexModerator": [ - { - "Label": "No cheese", - "Regex": "cheese", - "Response": [ - "say #_ You can't say that, that's illegal", - "delete" - ] - }, - { - "Label": "Secret club initiation", - "Regex": "my name is .* and I dislike cheese", - "Response": [ - "say @_ We welcome you.", - "addrole &00000::Secret Club member" - ] - } - ], - - "AutoResponder": [ - { - "Label": "Infinite no u", - "Regex": "no u", - "Reply": "no u" - }, - { - "Label": "Acknowledge praise", - "Regex": "yes u", - "Reply": ":blush:" - } - ] -} \ No newline at end of file diff --git a/ModuleLoader.cs b/ModuleLoader.cs index e7a46d7..9bc9e34 100644 --- a/ModuleLoader.cs +++ b/ModuleLoader.cs @@ -7,7 +7,7 @@ static class ModuleLoader { /// /// Given the instance configuration, loads all appropriate types from file specified in it. /// - internal static ReadOnlyCollection Load(InstanceConfig conf, RegexbotClient rb) { + internal static ReadOnlyCollection Load(Configuration conf, RegexbotClient rb) { var path = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location) + Path.DirectorySeparatorChar; var modules = new List(); diff --git a/Program.cs b/Program.cs index 7fee3e8..f680771 100644 --- a/Program.cs +++ b/Program.cs @@ -15,9 +15,9 @@ class Program { StartTime = DateTimeOffset.UtcNow; Console.WriteLine("Bot start time: " + StartTime.ToString("u")); - InstanceConfig cfg; + Configuration cfg; try { - cfg = new InstanceConfig(); // Program may exit within here. + cfg = new Configuration(); // Program may exit within here. } catch (Exception ex) { Console.WriteLine(ex.Message); Environment.ExitCode = 1; diff --git a/RegexBot.csproj b/RegexBot.csproj index 44d59b5..2bddcf8 100644 --- a/RegexBot.csproj +++ b/RegexBot.csproj @@ -11,14 +11,6 @@ True - - - - - - - - diff --git a/RegexbotClient.cs b/RegexbotClient.cs index bb5d161..bef14d4 100644 --- a/RegexbotClient.cs +++ b/RegexbotClient.cs @@ -8,7 +8,7 @@ public partial class RegexbotClient { /// /// Gets application instance configuration. /// - internal InstanceConfig Config { get; } + internal Configuration Config { get; } /// /// Gets the Discord client instance. @@ -20,14 +20,14 @@ public partial class RegexbotClient { /// internal IReadOnlyCollection Modules { get; } - internal RegexbotClient(InstanceConfig conf, DiscordSocketClient client) { + internal RegexbotClient(Configuration conf, DiscordSocketClient client) { Config = conf; DiscordClient = client; // Get all services started up _svcLogging = new Services.Logging.LoggingService(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); _svcEntityCache = new Services.EntityCache.EntityCacheService(this); diff --git a/SampleConfiguration.json b/SampleConfiguration.json new file mode 100644 index 0000000..48c5564 --- /dev/null +++ b/SampleConfiguration.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://raw.githubusercontent.com/NoiTheCat/RegexBot/main/ConfigurationSchema.json", + + "BotToken": "12345678901234567890qwertyuiop.1234567890", + //"Assemblies": [ "RegexBot.dll" ], + "DatabaseOptions": { + "Username": "regexbot", + "Password": "regexbot" + }, + + "Servers": { + "00000000": { // Place server ID here + "Name": "SERVER NAME", // Server name is unused by the bot, but is useful as a reference. + "Moderators": [ + // Users and roles are accepted here. + "MODERATOR" + ], + + /* + The following configuration is provided as a sample for commonly-used features. + For a detailed reference which includes all possible configuration settings, see + this project's documentation. + */ + "RegexModerator": [ + { + "Label": "No cheese", + "Regex": "cheese", + "Response": [ + "say #_ You can't say that, that's illegal", + "delete" + ] + }, + { + "Label": "Secret club initiation", + "Regex": "my name is .* and I (hate|dislike) cheese", + "Response": [ + "say @_ We welcome you.", + "addrole &00000::Secret Club member" + ] + } + ], + + "AutoResponder": [ + { + "Label": "Infinite no u", + "Regex": "no u", + "Reply": "no u" + }, + { + "Label": "Acknowledge praise", + "Regex": "yes u", + "Reply": ":blush:" + } + ] + } + } +} \ No newline at end of file diff --git a/ServerConfigSchema.json b/ServerConfigSchema.json deleted file mode 100644 index ec2f8ad..0000000 --- a/ServerConfigSchema.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "type": "object", - "properties": { - "Name": { - "type": "string", - "description": "The server's name. The value is unused by the application and is only for user reference." - }, - "Moderators": { - "type": "array", - "description": "A list of entities which the bot should recognize as moderators.", - "items": { - "type": "string" - } - } - /* schema still a work in progress */ - }, - "required": [ - "Moderators" - ] -} \ No newline at end of file diff --git a/Services/ModuleState/ModuleStateService.cs b/Services/ModuleState/ModuleStateService.cs index ecaf395..93623e7 100644 --- a/Services/ModuleState/ModuleStateService.cs +++ b/Services/ModuleState/ModuleStateService.cs @@ -1,20 +1,18 @@ -using Newtonsoft.Json; -using RegexBot.Common; -using System.Reflection; +using RegexBot.Common; namespace RegexBot.Services.ModuleState; /// /// 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. /// class ModuleStateService : Service { - private readonly object _storageLock = new(); private readonly Dictionary _moderators; private readonly Dictionary> _stateData; + private readonly JObject _serverConfs; - public ModuleStateService(RegexbotClient bot) : base(bot) { + public ModuleStateService(RegexbotClient bot, JObject servers) : base(bot) { _moderators = new(); _stateData = new(); + _serverConfs = servers; bot.DiscordClient.GuildAvailable += RefreshGuildState; bot.DiscordClient.JoinedGuild += RefreshGuildState; @@ -27,45 +25,31 @@ class ModuleStateService : Service { } private Task RemoveGuildData(SocketGuild arg) { - lock (_storageLock) { - _stateData.Remove(arg.Id); - _moderators.Remove(arg.Id); - } + _stateData.Remove(arg.Id); + _moderators.Remove(arg.Id); return Task.CompletedTask; } // Hooked public T? DoGetStateObj(ulong guildId, Type t) { - lock (_storageLock) { - if (_stateData.ContainsKey(guildId) && _stateData[guildId].ContainsKey(t)) { - // Leave handling of potential InvalidCastException to caller. - return (T?)_stateData[guildId][t]; - } - return default; + if (_stateData.ContainsKey(guildId) && _stateData[guildId].ContainsKey(t)) { + // Leave handling of potential InvalidCastException to caller. + return (T?)_stateData[guildId][t]; } + return default; } // Hooked public EntityList DoGetModlist(ulong guildId) { - lock (_storageLock) { - if (_moderators.TryGetValue(guildId, out var mods)) return mods; - else return new EntityList(); - } + if (_moderators.TryGetValue(guildId, out var mods)) return mods; + else return new EntityList(); } private async Task ProcessConfiguration(SocketGuild guild) { - var jstr = await LoadConfigFile(guild); - JObject guildConf; - try { - var tok = JToken.Parse(jstr); - 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; + var guildConf = _serverConfs[guild.Id.ToString()]?.Value(); + if (guildConf == null) { + Log($"{guild.Name} ({guild.Id}) has no configuration. Add config or consider removing bot from server."); + return true; } // Load moderator list @@ -86,38 +70,8 @@ class ModuleStateService : Service { return false; } } - lock (_storageLock) { - _moderators[guild.Id] = mods; - _stateData[guild.Id] = newStates; - } + _moderators[guild.Id] = mods; + _stateData[guild.Id] = newStates; return true; } - - private async Task 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); - } - } }