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 InstanceConfig _icfg;
|
||||||
private readonly DiscordSocketClient _client;
|
private readonly DiscordSocketClient _client;
|
||||||
private IReadOnlyCollection<Service> _services; // all services in an iterable format
|
private IReadOnlyCollection<Service> _services;
|
||||||
|
private IReadOnlyCollection<ModuleBase> _modules;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets application instance configuration.
|
/// Gets application instance configuration.
|
||||||
|
@ -27,6 +28,14 @@ namespace Kerobot
|
||||||
/// Gets the Discord client instance.
|
/// Gets the Discord client instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DiscordSocketClient DiscordClient => _client;
|
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)
|
internal Kerobot(InstanceConfig conf, DiscordSocketClient client)
|
||||||
{
|
{
|
||||||
|
|
|
@ -13,6 +13,14 @@
|
||||||
<LangVersion>7.2</LangVersion>
|
<LangVersion>7.2</LangVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Remove="DefaultGuildConfig.json" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="DefaultGuildConfig.json" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CommandLineParser" Version="2.2.1" />
|
<PackageReference Include="CommandLineParser" Version="2.2.1" />
|
||||||
<PackageReference Include="Discord.Net" Version="2.0.0-beta" />
|
<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)
|
public override async Task CreateDatabaseTablesAsync(NpgsqlConnection db)
|
||||||
{
|
{
|
||||||
using (var c = db.CreateCommand())
|
using (var c = db.CreateCommand())
|
||||||
|
|
|
@ -21,13 +21,6 @@ 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>
|
||||||
|
|
Loading…
Reference in a new issue