RegexBot/Kerobot/Services/GuildState/GuildStateService.cs

262 lines
10 KiB
C#

using Discord.WebSocket;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Npgsql;
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.JoinedGuild += DiscordClient_JoinedGuild;
kb.DiscordClient.LeftGuild += DiscordClient_LeftGuild;
// TODO periodic task for refreshing stale configuration
}
private async Task DiscordClient_GuildAvailable(SocketGuild arg) => await InitializeGuild(arg);
private async Task DiscordClient_JoinedGuild(SocketGuild arg) => await InitializeGuild(arg);
/// <summary>
/// Unloads in-memory guild information.
/// </summary>
private Task DiscordClient_LeftGuild(SocketGuild arg)
{
// TODO what is GuildUnavailable? Should we listen for that too?
lock (_storageLock) _storage.Remove(arg.Id);
return Task.CompletedTask;
}
/// <summary>
/// Initializes guild in-memory and database structures, then attempts to load configuration.
/// </summary>
private async Task InitializeGuild(SocketGuild arg)
{
// Get this done before any other thing.
await CreateSchema(arg.Id);
// Attempt initialization on the guild. All services will set up their tables here.
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
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).");
}
else
{
await Kerobot.InstanceLogAsync(false, GuildLogSource,
$"Configuration successfully refreshed for guild ID {arg.Id}.");
}
}
/// <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
{
object state;
try
{
state = await mod.CreateGuildStateAsync(guildConf[tn]); // can be null
}
catch (Exception ex)
{
Log("Encountered unhandled exception during guild state initialization:\n" +
$"Module: {tn}\n" +
$"Guild: {guildId} ({Kerobot.DiscordClient.GetGuild(guildId)?.Name ?? "unknown name"})\n" +
$"```\n{ex.ToString()}\n```", true).Wait();
Kerobot.GuildLogAsync(guildId, GuildLogSource,
"An internal error occurred when attempting to load new configuration. " +
"The bot owner has been notified.").Wait();
return false;
}
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>
public override async Task CreateDatabaseTablesAsync(NpgsqlConnection db)
{
using (var c = db.CreateCommand())
{
c.CommandText = $"create table if not exists {DBTableName} ("
+ $"rev_id SERIAL primary key, "
+ "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.DefaultGuildConfig.json";
var a = System.Reflection.Assembly.GetExecutingAssembly();
using (var s = a.GetManifestResourceStream(ResourceName))
using (var r = new System.IO.StreamReader(s))
return r.ReadToEnd();
}
}
}