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",
// 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",

View file

@ -1,14 +1,9 @@
using CommandLine;
using Newtonsoft.Json;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
namespace RegexBot;
/// <summary>
/// Contains essential instance configuration for this bot including Discord connection settings, service configuration,
/// and command-line options.
/// </summary>
class InstanceConfig {
class Configuration {
/// <summary>
/// Token used for Discord authentication.
/// </summary>
@ -19,18 +14,20 @@ class InstanceConfig {
/// </summary>
internal IReadOnlyList<string> 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; }
/// <summary>
/// Sets up instance configuration object from file and command line parameters.
/// </summary>
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<string>(conf, nameof(SqlHost), false);
SqlDatabase = ReadConfKey<string?>(conf, nameof(SqlDatabase), false);
SqlUsername = ReadConfKey<string>(conf, nameof(SqlUsername), true);
SqlPassword = ReadConfKey<string>(conf, nameof(SqlPassword), true);
var dbconf = conf["DatabaseOptions"]?.Value<JObject>();
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<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) {
@ -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;

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() {
// 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();
}

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>
/// Given the instance configuration, loads all appropriate types from file specified in it.
/// </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 modules = new List<RegexbotModule>();

View file

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

View file

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

View file

@ -8,7 +8,7 @@ public partial class RegexbotClient {
/// <summary>
/// Gets application instance configuration.
/// </summary>
internal InstanceConfig Config { get; }
internal Configuration Config { get; }
/// <summary>
/// Gets the Discord client instance.
@ -20,14 +20,14 @@ public partial class RegexbotClient {
/// </summary>
internal IReadOnlyCollection<RegexbotModule> 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);

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 System.Reflection;
using RegexBot.Common;
namespace RegexBot.Services.ModuleState;
/// <summary>
/// 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>
class ModuleStateService : Service {
private readonly object _storageLock = new();
private readonly Dictionary<ulong, EntityList> _moderators;
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();
_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<T>(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<bool> 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<JObject>();
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<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);
}
}
}