2018-12-19 00:21:35 +00:00
|
|
|
|
using System;
|
2018-06-03 00:33:31 +00:00
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Threading.Tasks;
|
2018-12-19 00:21:35 +00:00
|
|
|
|
using Discord.WebSocket;
|
2019-04-24 20:41:10 +00:00
|
|
|
|
using Kerobot.Common;
|
2018-12-19 00:21:35 +00:00
|
|
|
|
using Newtonsoft.Json;
|
|
|
|
|
using Newtonsoft.Json.Linq;
|
2018-06-03 00:33:31 +00:00
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
{
|
|
|
|
|
private readonly object _storageLock = new object();
|
2019-04-24 20:41:10 +00:00
|
|
|
|
private readonly Dictionary<ulong, EntityList> _moderators;
|
|
|
|
|
private readonly Dictionary<ulong, Dictionary<Type, StateInfo>> _states;
|
2018-06-03 00:33:31 +00:00
|
|
|
|
|
|
|
|
|
const string GuildLogSource = "Configuration loader";
|
|
|
|
|
|
|
|
|
|
public GuildStateService(Kerobot kb) : base(kb)
|
|
|
|
|
{
|
2019-04-24 20:41:10 +00:00
|
|
|
|
_states = new Dictionary<ulong, Dictionary<Type, StateInfo>>();
|
2018-12-19 00:21:35 +00:00
|
|
|
|
CreateDatabaseTablesAsync().Wait();
|
2018-06-03 00:33:31 +00:00
|
|
|
|
|
|
|
|
|
kb.DiscordClient.GuildAvailable += DiscordClient_GuildAvailable;
|
2018-09-21 02:43:05 +00:00
|
|
|
|
kb.DiscordClient.JoinedGuild += DiscordClient_JoinedGuild;
|
2018-06-03 00:33:31 +00:00
|
|
|
|
kb.DiscordClient.LeftGuild += DiscordClient_LeftGuild;
|
|
|
|
|
|
|
|
|
|
// TODO periodic task for refreshing stale configuration
|
|
|
|
|
}
|
2018-06-05 00:15:18 +00:00
|
|
|
|
|
2018-09-21 02:43:05 +00:00
|
|
|
|
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?
|
2019-04-24 20:41:10 +00:00
|
|
|
|
lock (_storageLock) _states.Remove(arg.Id);
|
2018-09-21 02:43:05 +00:00
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2018-12-19 00:21:35 +00:00
|
|
|
|
/// Initializes guild in-memory structures and attempts to load configuration.
|
2018-09-21 02:43:05 +00:00
|
|
|
|
/// </summary>
|
|
|
|
|
private async Task InitializeGuild(SocketGuild arg)
|
2018-06-03 00:33:31 +00:00
|
|
|
|
{
|
2018-12-19 00:21:35 +00:00
|
|
|
|
// We're only loading config here now.
|
2018-06-03 00:33:31 +00:00
|
|
|
|
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).");
|
|
|
|
|
}
|
2018-06-05 00:15:18 +00:00
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
await Kerobot.InstanceLogAsync(false, GuildLogSource,
|
|
|
|
|
$"Configuration successfully refreshed for guild ID {arg.Id}.");
|
|
|
|
|
}
|
2018-06-03 00:33:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
2019-04-24 20:41:10 +00:00
|
|
|
|
#region Data output
|
2018-06-03 00:33:31 +00:00
|
|
|
|
/// <summary>
|
|
|
|
|
/// See <see cref="ModuleBase.GetGuildState{T}(ulong)"/>.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public T RetrieveGuildStateObject<T>(ulong guildId, Type t)
|
|
|
|
|
{
|
|
|
|
|
lock (_storageLock)
|
|
|
|
|
{
|
2019-04-24 20:41:10 +00:00
|
|
|
|
if (_states.TryGetValue(guildId, out var tl))
|
2018-06-03 00:33:31 +00:00
|
|
|
|
{
|
|
|
|
|
if (tl.TryGetValue(t, out var val))
|
|
|
|
|
{
|
|
|
|
|
// Leave handling of potential InvalidCastException to caller.
|
|
|
|
|
return (T)val.Data;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return default;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-04-24 20:41:10 +00:00
|
|
|
|
/// <summary>
|
|
|
|
|
/// See <see cref="ModuleBase.GetModerators(ulong)"/>.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public EntityList RetrieveGuildModerators(ulong guildId)
|
|
|
|
|
{
|
|
|
|
|
lock (_storageLock)
|
|
|
|
|
{
|
|
|
|
|
if (_moderators.TryGetValue(guildId, out var mods)) return mods;
|
|
|
|
|
else return new EntityList();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endregion
|
|
|
|
|
|
2018-06-03 00:33:31 +00:00
|
|
|
|
/// <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);
|
2018-12-19 00:21:35 +00:00
|
|
|
|
if (jstr == null) jstr = await RetrieveDefaultConfiguration();
|
2018-06-03 00:33:31 +00:00
|
|
|
|
int jstrHash = jstr.GetHashCode();
|
|
|
|
|
JObject guildConf;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var tok = JToken.Parse(jstr);
|
2018-06-05 00:15:18 +00:00
|
|
|
|
if (tok.Type == JTokenType.Object)
|
2018-06-03 00:33:31 +00:00
|
|
|
|
{
|
|
|
|
|
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.
|
|
|
|
|
|
2019-04-24 20:41:10 +00:00
|
|
|
|
// Load moderator list
|
|
|
|
|
var mods = new EntityList(guildConf["Moderators"], true);
|
|
|
|
|
|
|
|
|
|
// Create guild state objects for all existing modules
|
2018-06-03 00:33:31 +00:00
|
|
|
|
var newStates = new Dictionary<Type, StateInfo>();
|
|
|
|
|
foreach (var mod in Kerobot.Modules)
|
|
|
|
|
{
|
|
|
|
|
var t = mod.GetType();
|
|
|
|
|
var tn = t.Name;
|
|
|
|
|
try
|
|
|
|
|
{
|
2018-09-21 02:43:05 +00:00
|
|
|
|
object state;
|
|
|
|
|
try
|
|
|
|
|
{
|
2019-03-16 23:41:55 +00:00
|
|
|
|
state = await mod.CreateGuildStateAsync(guildId, guildConf[tn]); // can be null
|
2018-09-21 02:43:05 +00:00
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|
2018-06-03 00:33:31 +00:00
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2019-04-24 20:41:10 +00:00
|
|
|
|
lock (_storageLock)
|
|
|
|
|
{
|
|
|
|
|
_moderators[guildId] = mods;
|
|
|
|
|
_states[guildId] = newStates;
|
|
|
|
|
}
|
2018-06-03 00:33:31 +00:00
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#region Database
|
2018-12-19 00:21:35 +00:00
|
|
|
|
const string DBTableName = "guild_configuration";
|
2018-06-03 00:33:31 +00:00
|
|
|
|
/// <summary>
|
2018-12-19 00:21:35 +00:00
|
|
|
|
/// Creates the table structures for holding guild configuration.
|
2018-06-03 00:33:31 +00:00
|
|
|
|
/// </summary>
|
2018-12-19 00:21:35 +00:00
|
|
|
|
private async Task CreateDatabaseTablesAsync()
|
2018-06-03 00:33:31 +00:00
|
|
|
|
{
|
2018-12-19 00:21:35 +00:00
|
|
|
|
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync())
|
2018-06-03 00:33:31 +00:00
|
|
|
|
{
|
|
|
|
|
using (var c = db.CreateCommand())
|
|
|
|
|
{
|
2018-12-19 00:21:35 +00:00
|
|
|
|
c.CommandText = $"create table if not exists {DBTableName} ("
|
|
|
|
|
+ $"rev_id SERIAL primary key, "
|
|
|
|
|
+ "guild_id bigint not null, "
|
|
|
|
|
+ "author bigint not null, "
|
|
|
|
|
+ "rev_date timestamptz not null default NOW(), "
|
|
|
|
|
+ "config_json text not null"
|
|
|
|
|
+ ")";
|
2018-06-03 00:33:31 +00:00
|
|
|
|
await c.ExecuteNonQueryAsync();
|
|
|
|
|
}
|
|
|
|
|
|
2018-12-19 00:21:35 +00:00
|
|
|
|
// Creating default configuration with revision ID 0.
|
|
|
|
|
// Config ID 0 is used when no other configurations can be loaded for a guild.
|
|
|
|
|
using (var c = db.CreateCommand())
|
|
|
|
|
{
|
|
|
|
|
c.CommandText = $"insert into {DBTableName} (rev_id, guild_id, author, config_json) "
|
|
|
|
|
+ "values (0, 0, 0, @Json) "
|
|
|
|
|
+ "on conflict (rev_id) do nothing";
|
|
|
|
|
c.Parameters.Add("@Json", NpgsqlTypes.NpgsqlDbType.Text).Value = PreloadDefaultGuildJson();
|
|
|
|
|
c.Prepare();
|
|
|
|
|
await c.ExecuteNonQueryAsync();
|
|
|
|
|
}
|
2018-06-03 00:33:31 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<string> RetrieveConfiguration(ulong guildId)
|
|
|
|
|
{
|
2018-12-19 00:21:35 +00:00
|
|
|
|
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync())
|
2018-06-03 00:33:31 +00:00
|
|
|
|
{
|
|
|
|
|
using (var c = db.CreateCommand())
|
|
|
|
|
{
|
2018-12-19 00:21:35 +00:00
|
|
|
|
c.CommandText = $"select config_json from {DBTableName} where guild_id = {guildId} "
|
2018-06-03 00:33:31 +00:00
|
|
|
|
+ "order by rev_id desc limit 1";
|
|
|
|
|
using (var r = await c.ExecuteReaderAsync())
|
|
|
|
|
{
|
|
|
|
|
if (await r.ReadAsync())
|
|
|
|
|
{
|
|
|
|
|
return r.GetString(0);
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2018-12-19 00:21:35 +00:00
|
|
|
|
|
|
|
|
|
private async Task<string> RetrieveDefaultConfiguration()
|
|
|
|
|
{
|
|
|
|
|
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync())
|
|
|
|
|
{
|
|
|
|
|
using (var c = db.CreateCommand())
|
|
|
|
|
{
|
|
|
|
|
c.CommandText = $"select config_json from {DBTableName} where rev_id = 0";
|
|
|
|
|
using (var r = await c.ExecuteReaderAsync())
|
|
|
|
|
{
|
|
|
|
|
if (await r.ReadAsync()) return r.GetString(0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
throw new Exception("Unable to retrieve fallback configuration.");
|
|
|
|
|
}
|
2018-06-03 00:33:31 +00:00
|
|
|
|
#endregion
|
|
|
|
|
|
2018-12-19 00:21:35 +00:00
|
|
|
|
// Default guild configuration JSON is embedded in assembly.
|
2018-06-03 00:33:31 +00:00
|
|
|
|
private string PreloadDefaultGuildJson()
|
|
|
|
|
{
|
2018-06-05 00:15:18 +00:00
|
|
|
|
const string ResourceName = "Kerobot.DefaultGuildConfig.json";
|
2018-06-03 00:33:31 +00:00
|
|
|
|
|
|
|
|
|
var a = System.Reflection.Assembly.GetExecutingAssembly();
|
|
|
|
|
using (var s = a.GetManifestResourceStream(ResourceName))
|
|
|
|
|
using (var r = new System.IO.StreamReader(s))
|
|
|
|
|
return r.ReadToEnd();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|