Merge pull request #4 from NoiTheCat/consolidate-config

Consolidate bot and guild configuration into one file
This commit is contained in:
Noi 2022-12-16 21:58:45 -08:00 committed by GitHub
commit 495636baec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 154 additions and 169 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;

44
ConfigurationSchema.json Normal file
View file

@ -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" ]
}

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

@ -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:"
}
]
}

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

57
SampleConfiguration.json Normal file
View file

@ -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:"
}
]
}
}
}

View file

@ -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"
]
}

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);
}
}
} }