Implemented module loading; fixed GuildStateService issues

This commit is contained in:
Noikoio 2018-06-04 17:15:18 -07:00
parent eae7410dbc
commit f20a17e914
7 changed files with 160 additions and 36 deletions

View file

@ -2,6 +2,7 @@
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Reflection; using System.Reflection;
namespace Kerobot namespace Kerobot
@ -19,6 +20,13 @@ namespace Kerobot
/// </summary> /// </summary>
internal string BotToken => _botToken; internal string BotToken => _botToken;
const string JAssemblies = "Assemblies";
readonly string[] _enabledAssemblies;
/// <summary>
/// List of assemblies to load, by file. Paths are always relative to the bot directory.
/// </summary>
internal string[] EnabledAssemblies => _enabledAssemblies;
const string JPgSqlConnectionString = "SqlConnectionString"; const string JPgSqlConnectionString = "SqlConnectionString";
readonly string _pgSqlConnectionString; readonly string _pgSqlConnectionString;
/// <summary> /// <summary>
@ -76,6 +84,13 @@ namespace Kerobot
if (string.IsNullOrEmpty(_pgSqlConnectionString)) if (string.IsNullOrEmpty(_pgSqlConnectionString))
throw new Exception($"'{JPgSqlConnectionString}' is not properly specified in configuration."); throw new Exception($"'{JPgSqlConnectionString}' is not properly specified in configuration.");
var asmList = conf[JAssemblies];
if (asmList == null || asmList.Type != JTokenType.Array)
{
throw new Exception($"'{JAssemblies}' is not properly specified in configuration.");
}
_enabledAssemblies = asmList.Values<string>().ToArray();
var ilInput = conf[JInstanceLogReportTarget]?.Value<string>(); var ilInput = conf[JInstanceLogReportTarget]?.Value<string>();
if (!string.IsNullOrWhiteSpace(ilInput)) if (!string.IsNullOrWhiteSpace(ilInput))
{ {

View file

@ -48,25 +48,29 @@ namespace Kerobot
await InstanceLogAsync(true, "Kerobot", "Connected and ready."); await InstanceLogAsync(true, "Kerobot", "Connected and ready.");
}; };
InitializeServices(); // Get all services started up
_services = InitializeServices();
// TODO prepare modules here // Load externally defined functionality
_modules = ModuleLoader.Load(_icfg, this);
// Everything's ready to go by now. Print the welcome message here. // Everything's ready to go. Print the welcome message here.
var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
InstanceLogAsync(false, "Kerobot", InstanceLogAsync(false, "Kerobot",
$"This is Kerobot v{ver.ToString(3)}. https://github.com/Noikoio/Kerobot").Wait(); $"This is Kerobot v{ver.ToString(3)}. https://github.com/Noikoio/Kerobot").Wait();
} }
private void InitializeServices() private IReadOnlyCollection<Service> InitializeServices()
{ {
var svcList = new List<Service>(); var svcList = new List<Service>();
// Put services here as they become usable. // Put services here as they become usable.
_svcLogging = new Services.Logging.LoggingService(this); _svcLogging = new Services.Logging.LoggingService(this);
svcList.Add(_svcLogging); svcList.Add(_svcLogging);
_svcGuildState = new Services.GuildState.GuildStateService(this);
svcList.Add(_svcGuildState);
_services = svcList.AsReadOnly(); return svcList.AsReadOnly();
} }
/// <summary> /// <summary>

View file

@ -0,0 +1,12 @@
using System;
namespace Kerobot
{
/// <summary>
/// Specifies to the Kerobot module loader that the target class should be treated as a module instance.
/// When the program scans an assembly which has been specified in its instance configuration to be loaded,
/// the program searches for classes implementing <see cref="ModuleBase"/> that also contain this attribute.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class KerobotModuleAttribute : Attribute { }
}

View file

@ -21,7 +21,11 @@ namespace Kerobot
/// </summary> /// </summary>
public Kerobot Kerobot => _kb; public Kerobot Kerobot => _kb;
private ModuleBase(Kerobot kb) => _kb = kb; /// <summary>
/// When a module is loaded, this constructor is called.
/// Services are available at this point. Do not attempt to communicate to Discord within the constructor.
/// </summary>
public ModuleBase(Kerobot kb) => _kb = kb;
/// <summary> /// <summary>
/// Gets the module name. /// Gets the module name.

65
Kerobot/ModuleLoader.cs Normal file
View file

@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Reflection;
namespace Kerobot
{
static class ModuleLoader
{
private const string LogName = nameof(ModuleLoader);
/// <summary>
/// Given the instance configuration, loads all appropriate types from file specified in it.
/// </summary>
internal static ReadOnlyCollection<ModuleBase> Load(InstanceConfig conf, Kerobot k)
{
var path = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location) + Path.DirectorySeparatorChar;
var modules = new List<ModuleBase>();
try
{
foreach (var file in conf.EnabledAssemblies)
{
var a = Assembly.LoadFile(path + file);
modules.AddRange(LoadModulesFromAssembly(a, k));
}
return modules.AsReadOnly();
}
catch (Exception ex)
{
// TODO better (not lazy) exception handling
// Possible exceptions:
// - Errors loading assemblies
// - Errors finding module paths
// - Errors creating module instances
// - Unknown errors
Console.WriteLine("Module load failed.");
Console.WriteLine(ex.ToString());
return null;
}
}
static IEnumerable<ModuleBase> LoadModulesFromAssembly(Assembly asm, Kerobot k)
{
var eligibleTypes = from type in asm.GetTypes()
where !type.IsAssignableFrom(typeof(ModuleBase))
where type.GetCustomAttribute<KerobotModuleAttribute>() != null
select type;
k.InstanceLogAsync(false, LogName,
$"{asm.FullName} has {eligibleTypes.Count()} usable types:");
var newmods = new List<ModuleBase>();
foreach (var t in eligibleTypes)
{
var mod = Activator.CreateInstance(t, k);
k.InstanceLogAsync(false, LogName,
$"---> Instance created: {t.FullName}");
newmods.Add((ModuleBase)mod);
}
return newmods;
}
}
}

View file

@ -1,5 +1,6 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Npgsql;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -30,15 +31,29 @@ namespace Kerobot.Services.GuildState
// TODO periodic task for refreshing stale configuration // TODO periodic task for refreshing stale configuration
} }
private async Task DiscordClient_GuildAvailable(Discord.WebSocket.SocketGuild arg) private async Task DiscordClient_GuildAvailable(Discord.WebSocket.SocketGuild arg)
{ {
// Get this done before any other thing. // Get this done before any other thing.
await CreateSchema(arg.Id); await CreateSchema(arg.Id);
// Attempt initialization on the guild. // Attempt initialization on the guild. All services will set up their tables here.
await CreateGuildConfigurationTableAsync(arg.Id); using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync(arg.Id))
{
foreach (var svc in Kerobot.Services)
{
try
{
await svc.CreateDatabaseTablesAsync(db);
}
catch (NpgsqlException ex)
{
await Log("Database error on CreateDatabaseTablesAsync:\n"
+ $"-- Service: {svc.Name}\n-- Guild: {arg.Id}\n-- Error: {ex.Message}" , true);
}
}
}
// Then start loading guild information // Then start loading guild information
bool success = await LoadGuildConfiguration(arg.Id); bool success = await LoadGuildConfiguration(arg.Id);
if (!success) if (!success)
@ -46,6 +61,11 @@ namespace Kerobot.Services.GuildState
await Kerobot.GuildLogAsync(arg.Id, GuildLogSource, await Kerobot.GuildLogAsync(arg.Id, GuildLogSource,
"Configuration was not reloaded due to the previously stated error(s)."); "Configuration was not reloaded due to the previously stated error(s).");
} }
else
{
await Kerobot.InstanceLogAsync(false, GuildLogSource,
$"Configuration successfully refreshed for guild ID {arg.Id}.");
}
} }
private Task DiscordClient_LeftGuild(Discord.WebSocket.SocketGuild arg) private Task DiscordClient_LeftGuild(Discord.WebSocket.SocketGuild arg)
{ {
@ -91,7 +111,7 @@ namespace Kerobot.Services.GuildState
try try
{ {
var tok = JToken.Parse(jstr); var tok = JToken.Parse(jstr);
if (tok.Type != JTokenType.Object) if (tok.Type == JTokenType.Object)
{ {
guildConf = (JObject)tok; guildConf = (JObject)tok;
} }
@ -153,32 +173,29 @@ namespace Kerobot.Services.GuildState
/// <summary> /// <summary>
/// Creates the table structures for holding module configuration. /// Creates the table structures for holding module configuration.
/// </summary> /// </summary>
private async Task CreateGuildConfigurationTableAsync(ulong guildId) public override async Task CreateDatabaseTablesAsync(NpgsqlConnection db)
{ {
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync(guildId)) using (var c = db.CreateCommand())
{ {
using (var c = db.CreateCommand()) c.CommandText = $"create table if not exists {DBTableName} ("
{ + $"rev_id SERIAL primary key, "
c.CommandText = $"create table if not exists {DBTableName} (" + "author bigint not null, "
+ $"rev_id integer not null primary key DEFAULT nextval('{DBTableName}_id_seq') " + "rev_date timestamptz not null default NOW(), "
+ "author bigint not null, " + "config_json text not null"
+ "rev_date timestamptz not null default NOW(), " + ")";
+ "config_json text not null" await c.ExecuteNonQueryAsync();
+ ")"; }
await c.ExecuteNonQueryAsync(); // Creating default configuration with revision ID 0.
} // This allows us to quickly define rev_id as type SERIAL and not have to configure it so that
// Creating default configuration with revision ID 0. // the serial should start at 2, but rather can easily start at 1. So lazy.
// This allows us to quickly define rev_id as type SERIAL and not have to configure it so that using (var c = db.CreateCommand())
// the serial should start at 2, but rather can easily start at 1. So lazy. {
using (var c = db.CreateCommand()) c.CommandText = $"insert into {DBTableName} (rev_id, author, config_json)"
{ + "values (0, 0, @Json) "
c.CommandText = $"insert into {DBTableName} (rev_id, author, config_json)" + "on conflict (rev_id) do nothing";
+ "values (0, 0, @Json) " c.Parameters.Add("@Json", NpgsqlTypes.NpgsqlDbType.Text).Value = _defaultGuildJson;
+ "on conflict (rev_id) do nothing"; c.Prepare();
c.Parameters.Add("@Json", NpgsqlTypes.NpgsqlDbType.Text).Value = _defaultGuildJson; await c.ExecuteNonQueryAsync();
c.Prepare();
await c.ExecuteNonQueryAsync();
}
} }
} }
@ -207,7 +224,7 @@ namespace Kerobot.Services.GuildState
private readonly string _defaultGuildJson; private readonly string _defaultGuildJson;
private string PreloadDefaultGuildJson() private string PreloadDefaultGuildJson()
{ {
const string ResourceName = "Kerobot.DefaultGuildJson.json"; const string ResourceName = "Kerobot.DefaultGuildConfig.json";
var a = System.Reflection.Assembly.GetExecutingAssembly(); var a = System.Reflection.Assembly.GetExecutingAssembly();
using (var s = a.GetManifestResourceStream(ResourceName)) using (var s = a.GetManifestResourceStream(ResourceName))

View file

@ -21,6 +21,13 @@ namespace Kerobot.Services
_kb = kb; _kb = kb;
} }
/// <summary>
/// Initializes database tables per-guild. Called when entering a guild.
/// </summary>
/// <param name="db">An opened database connection with the appropriate schema option set.</param>
/// <remarks>If overriding, calling the base method is not necessary.</remarks>
public virtual Task CreateDatabaseTablesAsync(Npgsql.NpgsqlConnection db) => Task.CompletedTask;
/// <summary> /// <summary>
/// Creates a log message. /// Creates a log message.
/// </summary> /// </summary>