First commit of GuildStateService
Currently untested. Unable to properly test until the module system is developed further.
This commit is contained in:
parent
43c02d2705
commit
eae7410dbc
10 changed files with 371 additions and 19 deletions
3
Kerobot/DefaultGuildConfig.json
Normal file
3
Kerobot/DefaultGuildConfig.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
// To do: Write good example config and decent documentation to go along with it.
|
||||
}
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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
65
Kerobot/ModuleBase.cs
Normal 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) { }
|
||||
}
|
||||
}
|
218
Kerobot/Services/GuildState/GuildStateService.cs
Normal file
218
Kerobot/Services/GuildState/GuildStateService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
16
Kerobot/Services/GuildState/Kerobot_hooks.cs
Normal file
16
Kerobot/Services/GuildState/Kerobot_hooks.cs
Normal 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);
|
||||
}
|
||||
}
|
50
Kerobot/Services/GuildState/StateInfo.cs
Normal file
50
Kerobot/Services/GuildState/StateInfo.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Kerobot.Services.GuildStateManager
|
||||
{
|
||||
class Manager
|
||||
{
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue