Added EntityCache - UserCache component
This commit is contained in:
parent
47a738ddbc
commit
9efb35a046
6 changed files with 315 additions and 19 deletions
|
@ -47,6 +47,8 @@ namespace Kerobot
|
||||||
var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
|
var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
|
||||||
InstanceLogAsync(false, "Kerobot",
|
InstanceLogAsync(false, "Kerobot",
|
||||||
$"This is Kerobot v{ver.ToString(3)}. https://github.com/Noikoio/Kerobot").Wait();
|
$"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()
|
private IReadOnlyCollection<Service> InitializeServices()
|
||||||
|
@ -59,6 +61,9 @@ namespace Kerobot
|
||||||
_svcGuildState = new Services.GuildState.GuildStateService(this);
|
_svcGuildState = new Services.GuildState.GuildStateService(this);
|
||||||
svcList.Add(_svcGuildState);
|
svcList.Add(_svcGuildState);
|
||||||
_svcCommonFunctions = new Services.CommonFunctions.CommonFunctionsService(this);
|
_svcCommonFunctions = new Services.CommonFunctions.CommonFunctionsService(this);
|
||||||
|
svcList.Add(_svcCommonFunctions);
|
||||||
|
_svcEntityCache = new Services.EntityCache.EntityCacheService(this);
|
||||||
|
svcList.Add(_svcEntityCache);
|
||||||
|
|
||||||
return svcList.AsReadOnly();
|
return svcList.AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|
67
Kerobot/Services/EntityCache/CachedUser.cs
Normal file
67
Kerobot/Services/EntityCache/CachedUser.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,25 @@
|
||||||
using System;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace Kerobot.Services.EntityCache
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
20
Kerobot/Services/EntityCache/Kerobot_hooks.cs
Normal file
20
Kerobot/Services/EntityCache/Kerobot_hooks.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
205
Kerobot/Services/EntityCache/UserCacheService.cs
Normal file
205
Kerobot/Services/EntityCache/UserCacheService.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in a new issue