Added EntityCache - UserCache component

This commit is contained in:
Noikoio 2019-02-15 16:49:54 -08:00
parent 47a738ddbc
commit 9efb35a046
6 changed files with 315 additions and 19 deletions

View file

@ -47,6 +47,8 @@ namespace Kerobot
var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
InstanceLogAsync(false, "Kerobot",
$"This is Kerobot v{ver.ToString(3)}. https://github.com/Noikoio/Kerobot").Wait();
// We return to Program.cs at this point.
}
private IReadOnlyCollection<Service> InitializeServices()
@ -59,6 +61,9 @@ namespace Kerobot
_svcGuildState = new Services.GuildState.GuildStateService(this);
svcList.Add(_svcGuildState);
_svcCommonFunctions = new Services.CommonFunctions.CommonFunctionsService(this);
svcList.Add(_svcCommonFunctions);
_svcEntityCache = new Services.EntityCache.EntityCacheService(this);
svcList.Add(_svcEntityCache);
return svcList.AsReadOnly();
}

View file

@ -0,0 +1,67 @@
using System;
using System.Data.Common;
namespace Kerobot // Publicly accessible class; placing in main namespace
{
/// <summary>
/// Representation of user information retrieved from Kerobot's UserCache.
/// </summary>
public class CachedUser
{
/// <summary>
/// The user's snowflake ID.
/// </summary>
public ulong UserID { get; }
/// <summary>
/// The corresponding guild's snowflake ID.
/// </summary>
public ulong GuildID { get; }
/// <summary>
/// The date in which this user was first recorded onto the database.
/// </summary>
public DateTimeOffset FirstSeenDate { get; }
/// <summary>
/// The date in which cache information for this user was last updated.
/// </summary>
public DateTimeOffset CacheDate { get; }
/// <summary>
/// The user's corresponding username, without discriminator.
/// </summary>
public string Username { get; }
/// <summary>
/// The user's corresponding discriminator value.
/// </summary>
public string Discriminator { get; }
/// <summary>
/// The user's nickname in the corresponding guild. May be null.
/// </summary>
public string Nickname { get; }
/// <summary>
/// A link to a high resolution version of the user's current avatar. May be null.
/// </summary>
public string AvatarUrl { get; }
internal CachedUser(DbDataReader row)
{
// Highly dependent on column order in the cache view defined in UserCacheService.
unchecked
{
UserID = (ulong)row.GetInt64(0);
GuildID = (ulong)row.GetInt64(1);
}
FirstSeenDate = row.GetDateTime(2).ToUniversalTime();
CacheDate = row.GetDateTime(3).ToUniversalTime();
Username = row.GetString(4);
Discriminator = row.GetString(5);
Nickname = row.IsDBNull(6) ? null : row.GetString(6);
AvatarUrl = row.IsDBNull(7) ? null : row.GetString(7);
}
}
}

View file

@ -1,10 +1,25 @@
using System;
using System.Threading.Tasks;
namespace Kerobot.Services.EntityCache
{
public class EntityCacheService
/// <summary>
/// Provides and maintains a database-backed cache of entities. Portions of information collected by this
/// service may be used by modules, while other portions are useful only for external applications which may
/// require this information, such as an external web interface.
/// </summary>
class EntityCacheService : Service
{
public EntityCacheService()
private readonly UserCache _uc;
internal EntityCacheService(Kerobot kb) : base(kb)
{
// Currently we only have UserCache. May add Channel and Server caches later.
_uc = new UserCache(kb);
}
/// <summary>
/// See documentation in Kerobot_hooks.
/// </summary>
internal Task<CachedUser> QueryUserCache(ulong guildId, string search) => _uc.Query(guildId, search);
}
}

View file

@ -0,0 +1,20 @@
using Kerobot.Services.EntityCache;
using System.Threading.Tasks;
namespace Kerobot
{
partial class Kerobot
{
private EntityCacheService _svcEntityCache;
/// <summary>
/// Queries the Entity Cache for user information. The given search string may contain a user ID
/// or a username with optional discriminator. In case there are multiple results, the most recently
/// cached user will be returned.
/// </summary>
/// <param name="guildId">ID of the corresponding guild in which to search.</param>
/// <param name="search">Search string. Either </param>
/// <returns>A <see cref="CachedUser"/> instance containing cached information.</returns>
public Task<CachedUser> EcQueryUser(ulong guildId, string search) => _svcEntityCache.QueryUserCache(guildId, search);
}
}

View file

@ -0,0 +1,205 @@
using Discord.WebSocket;
using NpgsqlTypes;
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Kerobot.Services.EntityCache
{
/// <summary>
/// Provides and maintains a database-backed cache of users.
/// It is meant to work as an addition to Discord.Net's own user caching capabilities. Its purpose is to
/// provide information on users which the library may not be aware about, such as users no longer in a guild.
/// </summary>
class UserCache
{
private Kerobot _kb;
internal UserCache(Kerobot kb)
{
_kb = kb;
CreateDatabaseTablesAsync().Wait();
kb.DiscordClient.GuildMemberUpdated += DiscordClient_GuildMemberUpdated;
kb.DiscordClient.UserUpdated += DiscordClient_UserUpdated;
}
#region Database setup
public const string GlobalUserTable = "cache_userdata";
public const string GuildUserTable = "cache_guildmemberdata";
public const string UserView = "cache_guildusers"; // <- intended way to access data
private async Task CreateDatabaseTablesAsync()
{
using (var db = await _kb.GetOpenNpgsqlConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"create table if not exists {GlobalUserTable} (" +
"user_id bigint primary key, " +
"cache_update_time timestamptz not null, " + // TODO auto update w/ trigger?
"username text not null, " +
"discriminator text not null, " +
"avatar_url text null" +
")";
await c.ExecuteNonQueryAsync();
}
using (var c = db.CreateCommand())
{
c.CommandText = $"create table if not exists {GuildUserTable} (" +
$"user_id bigint references {GlobalUserTable}, " +
"guild_id bigint, " + // TODO foreign key reference?
"first_seen timestamptz not null default NOW(), " + // TODO also make immutable w/ trigger?
"cache_update_time timestamptz not null, " + // TODO auto update w/ trigger?
"nickname text null, " +
"primary key (user_id, guild_id)" +
")";
await c.ExecuteNonQueryAsync();
}
// note to self: https://stackoverflow.com/questions/9556474/how-do-i-automatically-update-a-timestamp-in-postgresql
using (var c = db.CreateCommand())
{
// NOTE: CachedUser constructor is highly dependent of the row order specified here.
// Any changes here must be reflected there.
c.CommandText = $"create or replace view {UserView} as " +
$"select {GlobalUserTable}.user_id, {GuildUserTable}.guild_id, {GuildUserTable}.first_seen, " +
$"{GuildUserTable}.cache_update_time, " +
$"{GlobalUserTable}.username, {GlobalUserTable}.discriminator, {GlobalUserTable}.nickname, " +
$"{GlobalUserTable}.avatar_url " +
$"from {GlobalUserTable} join {GuildUserTable} on {GlobalUserTable}.user_id = {GuildUserTable}.user_id";
await c.ExecuteNonQueryAsync();
// TODO consolidate both tables' cache_update_time, show the greater value.
// Right now we will have just the guild table's data visible.
}
}
}
#endregion
#region Database updates
private async Task DiscordClient_UserUpdated(SocketUser old, SocketUser current)
{
using (var db = await _kb.GetOpenNpgsqlConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"insert into {GlobalUserTable} " +
"(user_id, cache_update_time, username, discriminator, avatar_url) values " +
"(@Uid, now(), @Uname, @Disc, @Aurl) " +
"on conflict (user_id) do update " +
"set cache_update_time = EXCLUDED.cache_update_time, username = EXCLUDED.username, " +
"discriminator = EXCLUDED.discriminator, avatar_url = EXCLUDED.avatar_url";
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = current.Id;
c.Parameters.Add("@Uname", NpgsqlDbType.Text).Value = current.Username;
c.Parameters.Add("@Disc", NpgsqlDbType.Text).Value = current.Discriminator;
var aurl = c.Parameters.Add("@Aurl", NpgsqlDbType.Text);
var aurlval = current.GetAvatarUrl(Discord.ImageFormat.Png, 1024);
if (aurlval != null) aurl.Value = aurlval;
else aurl.Value = DBNull.Value;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
}
}
private async Task DiscordClient_GuildMemberUpdated(SocketGuildUser old, SocketGuildUser current)
{
using (var db = await _kb.GetOpenNpgsqlConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"insert into {GuildUserTable} " +
"(user_id, guild_id, cache_update_time, nickname) values " +
"(@Uid, @Gid, now(), @Nname) " +
"on conflict (user_id) do update " +
"set cache_update_time = EXCLUDED.cache_update_time, username = EXCLUDED.username, " +
"discriminator = EXCLUDED.discriminator, avatar_url = EXCLUDED.avatar_url";
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = current.Id;
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = current.Guild.Id;
var nname = c.Parameters.Add("@Nname", NpgsqlDbType.Text);
if (current.Nickname != null) nname.Value = current.Nickname;
else nname.Value = DBNull.Value;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
}
}
#endregion
#region Querying
private static Regex DiscriminatorSearch = new Regex(@"(.+)#(\d{4}(?!\d))", RegexOptions.Compiled);
internal async Task<CachedUser> Query(ulong guildID, string search)
{
// Is search just a number? Assume ID, pass it on to the correct place.
// If it fails, assume the number may be a username.
if (ulong.TryParse(search, out var searchid))
{
var idres = await InternalDoQuery(guildID, searchid, null, null);
if (idres != null) return idres;
}
// Split name/discriminator
string name, disc;
var split = DiscriminatorSearch.Match(search);
if (split.Success)
{
name = split.Groups[1].Value;
disc = split.Groups[2].Value;
}
else
{
name = search;
disc = null;
}
// Strip leading @ from username, if any
if (name.Length > 0 && name[0] == '@') name = name.Substring(1);
// Ready to query
return await InternalDoQuery(guildID, null, name, disc);
// TODO exception handling
}
private async Task<CachedUser> InternalDoQuery(ulong guildId, ulong? sID, string sName, string sDisc)
{
using (var db = await _kb.GetOpenNpgsqlConnectionAsync())
{
var c = db.CreateCommand();
c.CommandText = $"select * from {UserView} " +
"where guild_id = @Gid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = guildId;
if (sID.HasValue)
{
c.CommandText += " and user_id = @Uid";
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = sID.Value;
}
if (sName != null)
{
c.CommandText += " and username = @Uname";
c.Parameters.Add("@Uname", NpgsqlDbType.Text).Value = sName;
if (sDisc != null) // only search discriminator if name has been provided
{
c.CommandText += " and discriminator = @Udisc";
c.Parameters.Add("@Udisc", NpgsqlDbType.Text).Value = sDisc;
}
}
c.CommandText += " order by cache_update_time desc limit 1";
c.Prepare();
using (var r = await c.ExecuteReaderAsync())
{
if (await r.ReadAsync()) return new CachedUser(r);
return null;
}
}
}
#endregion
}
}

View file

@ -1,16 +0,0 @@
using System;
namespace Kerobot.Services.EntityCache
{
/// <summary>
/// Provides and maintains a database-backed cache of users.
/// It is meant to work as an addition to Discord.Net's own user caching capabilities, and its main purpose
/// is to be able to provide basic information on users which the bot may not currently be aware about.
/// </summary>
class EntityCacheService : Service
{
public EntityCacheService(Kerobot kb) : base(kb)
{
}
}
}