First commit of GuildStateService

Currently untested. Unable to properly test until the module system is
developed further.
This commit is contained in:
Noikoio 2018-06-02 17:33:31 -07:00
parent 43c02d2705
commit eae7410dbc
10 changed files with 371 additions and 19 deletions

View file

@ -0,0 +1,3 @@
{
// To do: Write good example config and decent documentation to go along with it.
}

View file

@ -17,7 +17,8 @@ namespace Kerobot
private readonly InstanceConfig _icfg;
private readonly DiscordSocketClient _client;
private IReadOnlyCollection<Service> _services; // all services in an iterable format
private IReadOnlyCollection<Service> _services;
private IReadOnlyCollection<ModuleBase> _modules;
/// <summary>
/// Gets application instance configuration.
@ -27,6 +28,14 @@ namespace Kerobot
/// Gets the Discord client instance.
/// </summary>
public DiscordSocketClient DiscordClient => _client;
/// <summary>
/// All loaded services in an iterable form.
/// </summary>
internal IReadOnlyCollection<Service> Services => _services;
/// <summary>
/// All loaded modules in an iterable form.
/// </summary>
internal IReadOnlyCollection<ModuleBase> Modules => _modules;
internal Kerobot(InstanceConfig conf, DiscordSocketClient client)
{

View file

@ -13,6 +13,14 @@
<LangVersion>7.2</LangVersion>
</PropertyGroup>
<ItemGroup>
<None Remove="DefaultGuildConfig.json" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="DefaultGuildConfig.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.2.1" />
<PackageReference Include="Discord.Net" Version="2.0.0-beta" />

65
Kerobot/ModuleBase.cs Normal file
View file

@ -0,0 +1,65 @@
using Newtonsoft.Json.Linq;
using System;
using System.Threading.Tasks;
namespace Kerobot
{
/// <summary>
/// Base class for a Kerobot module. A module implements a user-facing feature and is expected to directly handle
/// user input (both by means of configuration and incoming Discord events) and process it accordingly.
/// </summary>
/// <remarks>
/// Implementing classes should not rely on local/instance variables to store data. Make use of
/// <see cref="CreateGuildStateAsync(JToken)"/> and <see cref="GetGuildState{T}(ulong)"/>.
/// </remarks>
public abstract class ModuleBase
{
private readonly Kerobot _kb;
/// <summary>
/// Retrieves the Kerobot instance.
/// </summary>
public Kerobot Kerobot => _kb;
private ModuleBase(Kerobot kb) => _kb = kb;
/// <summary>
/// Gets the module name.
/// This value is derived from the class's name. It is used in configuration and logging.
/// </summary>
public string Name => GetType().Name;
/// <summary>
/// Called when a guild becomes available. The implementing class should construct an object to hold
/// data specific to the corresponding guild for use during runtime.
/// </summary>
/// <param name="config">JSON token holding module configuration specific to this guild.</param>
/// <returns>
/// An object containing state and/or configuration information for the guild currently being processed.
/// </returns>
public abstract Task<object> CreateGuildStateAsync(JToken config);
/// <summary>
/// Retrieves the state object that corresponds with the given guild.
/// </summary>
/// <typeparam name="T">The state object's type.</typeparam>
/// <param name="guildId">The guild ID for which to retrieve the state object.</param>
/// <returns>The state object cast in the given type, or Default(T) if none exists.</returns>
/// <exception cref="InvalidCastException">
/// Thrown if the stored state object cannot be cast as specified.
/// </exception>
protected T GetGuildState<T>(ulong guildId) => Kerobot.GetGuildState<T>(guildId, GetType());
}
/// <summary>
/// Represents errors that occur when a module attempts to create a new guild state object.
/// </summary>
public class ModuleLoadException : Exception
{
/// <summary>
/// Initializes this exception class with the specified error message.
/// </summary>
/// <param name="message"></param>
public ModuleLoadException(string message) : base(message) { }
}
}

View file

@ -0,0 +1,218 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Kerobot.Services.GuildState
{
/// <summary>
/// Implements per-module storage and retrieval of guild-specific state data.
/// This typically includes module configuration data.
/// </summary>
class GuildStateService : Service
{
// SCHEMAS ARE CREATED HERE, SOMEWHERE BELOW. MAKE SURE THIS CONSTRUCTOR IS CALLED EARLY.
private readonly object _storageLock = new object();
private readonly Dictionary<ulong, Dictionary<Type, StateInfo>> _storage;
const string GuildLogSource = "Configuration loader";
public GuildStateService(Kerobot kb) : base(kb)
{
_storage = new Dictionary<ulong, Dictionary<Type, StateInfo>>();
_defaultGuildJson = PreloadDefaultGuildJson();
kb.DiscordClient.GuildAvailable += DiscordClient_GuildAvailable;
kb.DiscordClient.LeftGuild += DiscordClient_LeftGuild;
// TODO periodic task for refreshing stale configuration
}
private async Task DiscordClient_GuildAvailable(Discord.WebSocket.SocketGuild arg)
{
// Get this done before any other thing.
await CreateSchema(arg.Id);
// Attempt initialization on the guild.
await CreateGuildConfigurationTableAsync(arg.Id);
// Then start loading guild information
bool success = await LoadGuildConfiguration(arg.Id);
if (!success)
{
await Kerobot.GuildLogAsync(arg.Id, GuildLogSource,
"Configuration was not reloaded due to the previously stated error(s).");
}
}
private Task DiscordClient_LeftGuild(Discord.WebSocket.SocketGuild arg)
{
// Unload guild information.
lock (_storageLock) _storage.Remove(arg.Id);
return Task.CompletedTask;
}
/// <summary>
/// See <see cref="ModuleBase.GetGuildState{T}(ulong)"/>.
/// </summary>
public T RetrieveGuildStateObject<T>(ulong guildId, Type t)
{
lock (_storageLock)
{
if (_storage.TryGetValue(guildId, out var tl))
{
if (tl.TryGetValue(t, out var val))
{
// Leave handling of potential InvalidCastException to caller.
return (T)val.Data;
}
}
return default;
}
}
/// <summary>
/// Guild-specific configuration begins processing here.
/// Configuration is loaded from database, and appropriate sections dispatched to their
/// respective methods for further processing.
/// </summary>
/// <remarks>
/// This takes an all-or-nothing approach. Should there be a single issue in processing
/// configuration, the old state data is kept.
/// </remarks>
private async Task<bool> LoadGuildConfiguration(ulong guildId)
{
var jstr = await RetrieveConfiguration(guildId);
int jstrHash = jstr.GetHashCode();
JObject guildConf;
try
{
var tok = JToken.Parse(jstr);
if (tok.Type != JTokenType.Object)
{
guildConf = (JObject)tok;
}
else
{
throw new InvalidCastException("The given configuration is not a JSON object.");
}
}
catch (Exception ex) when (ex is JsonReaderException || ex is InvalidCastException)
{
await Kerobot.GuildLogAsync(guildId, GuildLogSource,
$"A problem exists within the guild configuration: {ex.Message}");
// Don't update currently loaded state.
return false;
}
// TODO Guild-specific service options? If implemented, this is where to load them.
var newStates = new Dictionary<Type, StateInfo>();
foreach (var mod in Kerobot.Modules)
{
var t = mod.GetType();
var tn = t.Name;
try
{
var state = await mod.CreateGuildStateAsync(guildConf[tn]); // can be null
newStates.Add(t, new StateInfo(state, jstrHash));
}
catch (ModuleLoadException ex)
{
await Kerobot.GuildLogAsync(guildId, GuildLogSource,
$"{tn} has encountered an issue with its configuration: {ex.Message}");
return false;
}
}
lock (_storageLock) _storage[guildId] = newStates;
return true;
}
#region Database
/// <summary>
/// Creates a schema for holding all guild data.
/// Ensure that this runs first before any other database call to a guild.
/// </summary>
private async Task CreateSchema(ulong guildId)
{
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync(null))
{
using (var c = db.CreateCommand())
{
c.CommandText = $"create schema if not exists guild_{guildId}";
await c.ExecuteNonQueryAsync();
}
}
}
const string DBTableName = "guild_configuration";
/// <summary>
/// Creates the table structures for holding module configuration.
/// </summary>
private async Task CreateGuildConfigurationTableAsync(ulong guildId)
{
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync(guildId))
{
using (var c = db.CreateCommand())
{
c.CommandText = $"create table if not exists {DBTableName} ("
+ $"rev_id integer not null primary key DEFAULT nextval('{DBTableName}_id_seq') "
+ "author bigint not null, "
+ "rev_date timestamptz not null default NOW(), "
+ "config_json text not null"
+ ")";
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
// 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) "
+ "on conflict (rev_id) do nothing";
c.Parameters.Add("@Json", NpgsqlTypes.NpgsqlDbType.Text).Value = _defaultGuildJson;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
}
}
private async Task<string> RetrieveConfiguration(ulong guildId)
{
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync(guildId))
{
using (var c = db.CreateCommand())
{
c.CommandText = $"select config_json from {DBTableName} "
+ "order by rev_id desc limit 1";
using (var r = await c.ExecuteReaderAsync())
{
if (await r.ReadAsync())
{
return r.GetString(0);
}
return null;
}
}
}
}
#endregion
// Default guild configuration JSON is embedded in assembly. Retrieving and caching it here.
private readonly string _defaultGuildJson;
private string PreloadDefaultGuildJson()
{
const string ResourceName = "Kerobot.DefaultGuildJson.json";
var a = System.Reflection.Assembly.GetExecutingAssembly();
using (var s = a.GetManifestResourceStream(ResourceName))
using (var r = new System.IO.StreamReader(s))
return r.ReadToEnd();
}
}
}

View file

@ -0,0 +1,16 @@
using Kerobot.Services.GuildState;
using System;
namespace Kerobot
{
partial class Kerobot
{
private GuildStateService _svcGuildState;
/// <summary>
/// See <see cref="ModuleBase.GetGuildState{T}(ulong)"/>.
/// </summary>
internal T GetGuildState<T>(ulong guild, Type type)
=> _svcGuildState.RetrieveGuildStateObject<T>(guild, type);
}
}

View file

@ -0,0 +1,50 @@
using Newtonsoft.Json.Linq;
using System;
namespace Kerobot.Services.GuildState
{
/// <summary>
/// Contains the guild state object and other useful metadata in regards to it.
/// </summary>
class StateInfo : IDisposable
{
static readonly TimeSpan TimeUntilStale = new TimeSpan(0, 15, 0);
private readonly object _data;
/// <summary>
/// Module-provided data.
/// </summary>
public object Data => _data;
/// <summary>
/// Hash of the JToken used to generate the data. In certain casaes, it is used to check
/// if the configuration may be stale and needs to be reloaded.
/// </summary>
private readonly int _configHash;
private readonly DateTimeOffset _creationTs;
public StateInfo(object data, int configHash)
{
_data = data;
_configHash = configHash;
_creationTs = DateTimeOffset.UtcNow;
}
public void Dispose()
{
if (_data is IDisposable dd) { dd.Dispose(); }
}
/// <summary>
/// Checks if the current data may be stale, based on the data's age or
/// through comparison with incoming configuration.
/// </summary>
public bool IsStale(JToken comparison)
{
if (DateTimeOffset.UtcNow - _creationTs > TimeUntilStale) return true;
if (comparison.GetHashCode() != _configHash) return true;
return false;
}
}
}

View file

@ -1,10 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Kerobot.Services.GuildStateManager
{
class Manager
{
}
}

View file

@ -50,7 +50,7 @@ namespace Kerobot.Services.Logging
}
}
const string TableLog = "logging";
const string TableLog = "program_log";
public override async Task CreateDatabaseTablesAsync(NpgsqlConnection db)
{
using (var c = db.CreateCommand())

View file

@ -21,13 +21,6 @@ namespace Kerobot.Services
_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>
/// Creates a log message.
/// </summary>