From 9efb35a046c16e90773dbad533d11562cc0c4c6c Mon Sep 17 00:00:00 2001 From: Noikoio Date: Fri, 15 Feb 2019 16:49:54 -0800 Subject: [PATCH] Added EntityCache - UserCache component --- Kerobot/Kerobot.cs | 5 + Kerobot/Services/EntityCache/CachedUser.cs | 67 ++++++ .../EntityCache/EntityCacheService.cs | 21 +- Kerobot/Services/EntityCache/Kerobot_hooks.cs | 20 ++ .../Services/EntityCache/UserCacheService.cs | 205 ++++++++++++++++++ .../Services/UserCache/UserCacheService.cs | 16 -- 6 files changed, 315 insertions(+), 19 deletions(-) create mode 100644 Kerobot/Services/EntityCache/CachedUser.cs create mode 100644 Kerobot/Services/EntityCache/Kerobot_hooks.cs create mode 100644 Kerobot/Services/EntityCache/UserCacheService.cs delete mode 100644 Kerobot/Services/UserCache/UserCacheService.cs diff --git a/Kerobot/Kerobot.cs b/Kerobot/Kerobot.cs index 9086510..419283b 100644 --- a/Kerobot/Kerobot.cs +++ b/Kerobot/Kerobot.cs @@ -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 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(); } diff --git a/Kerobot/Services/EntityCache/CachedUser.cs b/Kerobot/Services/EntityCache/CachedUser.cs new file mode 100644 index 0000000..7bf1393 --- /dev/null +++ b/Kerobot/Services/EntityCache/CachedUser.cs @@ -0,0 +1,67 @@ +using System; +using System.Data.Common; + +namespace Kerobot // Publicly accessible class; placing in main namespace +{ + /// + /// Representation of user information retrieved from Kerobot's UserCache. + /// + public class CachedUser + { + /// + /// The user's snowflake ID. + /// + public ulong UserID { get; } + + /// + /// The corresponding guild's snowflake ID. + /// + public ulong GuildID { get; } + + /// + /// The date in which this user was first recorded onto the database. + /// + public DateTimeOffset FirstSeenDate { get; } + + /// + /// The date in which cache information for this user was last updated. + /// + public DateTimeOffset CacheDate { get; } + + /// + /// The user's corresponding username, without discriminator. + /// + public string Username { get; } + + /// + /// The user's corresponding discriminator value. + /// + public string Discriminator { get; } + + /// + /// The user's nickname in the corresponding guild. May be null. + /// + public string Nickname { get; } + + /// + /// A link to a high resolution version of the user's current avatar. May be null. + /// + 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); + } + } +} diff --git a/Kerobot/Services/EntityCache/EntityCacheService.cs b/Kerobot/Services/EntityCache/EntityCacheService.cs index 24e4d3b..bc6200c 100644 --- a/Kerobot/Services/EntityCache/EntityCacheService.cs +++ b/Kerobot/Services/EntityCache/EntityCacheService.cs @@ -1,10 +1,25 @@ -using System; +using System.Threading.Tasks; + namespace Kerobot.Services.EntityCache { - public class EntityCacheService + /// + /// 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. + /// + 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); } + + /// + /// See documentation in Kerobot_hooks. + /// + internal Task QueryUserCache(ulong guildId, string search) => _uc.Query(guildId, search); } } diff --git a/Kerobot/Services/EntityCache/Kerobot_hooks.cs b/Kerobot/Services/EntityCache/Kerobot_hooks.cs new file mode 100644 index 0000000..6eadda6 --- /dev/null +++ b/Kerobot/Services/EntityCache/Kerobot_hooks.cs @@ -0,0 +1,20 @@ +using Kerobot.Services.EntityCache; +using System.Threading.Tasks; + +namespace Kerobot +{ + partial class Kerobot + { + private EntityCacheService _svcEntityCache; + + /// + /// 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. + /// + /// ID of the corresponding guild in which to search. + /// Search string. Either + /// A instance containing cached information. + public Task EcQueryUser(ulong guildId, string search) => _svcEntityCache.QueryUserCache(guildId, search); + } +} diff --git a/Kerobot/Services/EntityCache/UserCacheService.cs b/Kerobot/Services/EntityCache/UserCacheService.cs new file mode 100644 index 0000000..ff94bee --- /dev/null +++ b/Kerobot/Services/EntityCache/UserCacheService.cs @@ -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 +{ + /// + /// 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. + /// + 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 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 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 + } +} diff --git a/Kerobot/Services/UserCache/UserCacheService.cs b/Kerobot/Services/UserCache/UserCacheService.cs deleted file mode 100644 index a5e73ec..0000000 --- a/Kerobot/Services/UserCache/UserCacheService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; - -namespace Kerobot.Services.EntityCache -{ - /// - /// 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. - /// - class EntityCacheService : Service - { - public EntityCacheService(Kerobot kb) : base(kb) - { - } - } -}