diff --git a/Module/EntityCache/UserCacheItem.cs b/EntityCache/CacheUser.cs similarity index 55% rename from Module/EntityCache/UserCacheItem.cs rename to EntityCache/CacheUser.cs index 1e94e95..56c58f6 100644 --- a/Module/EntityCache/UserCacheItem.cs +++ b/EntityCache/CacheUser.cs @@ -1,15 +1,18 @@ -using System; +using Discord.WebSocket; +using Npgsql; +using System; using System.Collections.Generic; using System.Data.Common; +using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; -namespace Noikoio.RegexBot.Module.EntityCache +namespace Noikoio.RegexBot.EntityCache { /// - /// Represents a cached user. + /// Representation of a cached user. /// - class UserCacheItem + class CacheUser { readonly ulong _userId; readonly ulong _guildId; @@ -57,8 +60,20 @@ namespace Noikoio.RegexBot.Module.EntityCache /// public string AvatarUrl => _avatarUrl; + private CacheUser(SocketGuildUser u) + { + _userId = u.Id; + _guildId = u.Guild.Id; + _cacheDate = DateTime.UtcNow; + _username = u.Username; + _discriminator = u.Discriminator; + _nickname = u.Nickname; + _avatarUrl = u.GetAvatarUrl(); + } - private UserCacheItem(DbDataReader r) + // Double-check SqlHelper if making changes to this constant + const string QueryColumns = "user_id, guild_id, cache_date, username, discriminator, nickname, avatar_url"; + private CacheUser(DbDataReader r) { // Double-check ordinals if making changes to QueryColumns unchecked @@ -77,20 +92,32 @@ namespace Noikoio.RegexBot.Module.EntityCache public override string ToString() => DisplayName; #region Queries - // Double-check constructor if making changes to this constant - const string QueryColumns = "user_id, guild_id, cache_date, username, discriminator, nickname, avatar_url"; - - /// - /// Attempts to query for an exact result with the given parameters. - /// - /// Null on no result. - public static async Task QueryAsync(ulong guild, ulong user) + // Accessible by EntityCache. Documentation is there. + internal static async Task QueryAsync(DiscordSocketClient c, ulong guild, ulong user) { - using (var db = await RegexBot.Config.Database.GetOpenConnectionAsync()) + // Local cache search + var lresult = LocalQueryAsync(c, guild, user); + if (lresult != null) return lresult; + + // Database cache search + var db = await RegexBot.Config.Database.GetOpenConnectionAsync(); + if (db == null) return null; // Database not available for query. + using (db) return await DbQueryAsync(db, guild, user); + } + + private static CacheUser LocalQueryAsync(DiscordSocketClient c, ulong guild, ulong user) + { + var u = c.GetGuild(guild)?.GetUser(user); + if (u == null) return null; + return new CacheUser(u); + } + private static async Task DbQueryAsync(NpgsqlConnection db, ulong guild, ulong user) + { + using (db) { using (var c = db.CreateCommand()) { - c.CommandText = $"SELECT {QueryColumns} FROM {Sql.TableUser} WHERE " + c.CommandText = $"SELECT {QueryColumns} FROM {SqlHelper.TableUser} WHERE " + "user_id = @Uid AND guild_id = @Gid"; c.Parameters.Add("@Uid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = user; c.Parameters.Add("@Gid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = guild; @@ -99,7 +126,7 @@ namespace Noikoio.RegexBot.Module.EntityCache { if (await r.ReadAsync()) { - return new UserCacheItem(r); + return new CacheUser(r); } else { @@ -110,23 +137,21 @@ namespace Noikoio.RegexBot.Module.EntityCache } } - private static Regex DiscriminatorSearch = new Regex(@"(.+)#(\d{4}(?!\d))", RegexOptions.Compiled); + // ----- - /// - /// Attempts to look up the user given a search string. - /// This string looks up case-insensitive, exact matches of nicknames and usernames. - /// - /// An containing zero or more query results, sorted by cache date. - public static async Task> QueryAsync(ulong guild, string search) + private static Regex DiscriminatorSearch = new Regex(@"(.+)#(\d{4}(?!\d))", RegexOptions.Compiled); + // Accessible by EntityCache. Documentation is there. + internal static async Task> QueryAsync(DiscordSocketClient c, ulong guild, string search) { - // Is search just a number? It's an ID. + // Is search just a number? Assume ID, pass it on to the correct place. if (ulong.TryParse(search, out var presult)) { - var r = await QueryAsync(guild, presult); - if (r == null) return new UserCacheItem[0]; - else return new UserCacheItem[] { r }; + var r = await QueryAsync(c, guild, presult); + if (r == null) return new CacheUser[0]; + else return new CacheUser[] { r }; } + // Split name/discriminator string name; string disc; var split = DiscriminatorSearch.Match(search); @@ -141,14 +166,52 @@ namespace Noikoio.RegexBot.Module.EntityCache disc = null; } - // Storing in HashSet to enforce uniqueness - HashSet result = new HashSet(_uc); + // Local cache search + var lresult = LocalQueryAsync(c, guild, name, disc); + if (lresult.Count() != 0) return lresult; - using (var db = await RegexBot.Config.Database.GetOpenConnectionAsync()) + // Database cache search + var db = await RegexBot.Config.Database.GetOpenConnectionAsync(); + if (db == null) return null; // Database not available for query. + using (db) return await DbQueryAsync(db, guild, name, disc); + } + + private static IEnumerable LocalQueryAsync(DiscordSocketClient c, ulong guild, string name, string disc) + { + var g = c.GetGuild(guild); + if (g == null) return new CacheUser[] { }; + + bool Filter(string iun, string inn, string idc) + { + // Same logic as in the SQL query in the method below this one + bool match = + string.Equals(iun, name, StringComparison.InvariantCultureIgnoreCase) + || string.Equals(inn, name, StringComparison.InvariantCultureIgnoreCase); + + if (match && disc != null) + match = idc.Equals(disc); + + return match; + } + + var qresult = g.Users.Where(i => Filter(i.Username, i.Nickname, i.Discriminator)); + var result = new List(); + foreach (var item in qresult) + { + result.Add(new CacheUser(item)); + } + return result; + } + + private static async Task> DbQueryAsync(NpgsqlConnection db, ulong guild, string name, string disc) + { + var result = new List(); + + using (db = await RegexBot.Config.Database.GetOpenConnectionAsync()) { using (var c = db.CreateCommand()) { - c.CommandText = $"SELECT {QueryColumns} FROM {Sql.TableUser} WHERE " + c.CommandText = $"SELECT {QueryColumns} FROM {SqlHelper.TableUser} WHERE " + "( lower(username) = lower(@NameSearch) OR lower(nickname) = lower(@NameSearch) )"; c.Parameters.Add("@NameSearch", NpgsqlTypes.NpgsqlDbType.Text).Value = name; if (disc != null) @@ -162,21 +225,13 @@ namespace Noikoio.RegexBot.Module.EntityCache { while (await r.ReadAsync()) { - result.Add(new UserCacheItem(r)); + result.Add(new CacheUser(r)); } } } } return result; } - - private static UniqueChecker _uc = new UniqueChecker(); - class UniqueChecker : IEqualityComparer - { - public bool Equals(UserCacheItem x, UserCacheItem y) => x.UserId == y.UserId && x.GuildId == y.GuildId; - - public int GetHashCode(UserCacheItem obj) => unchecked((int)(obj.UserId ^ obj.GuildId)); - } #endregion } } diff --git a/EntityCache/EntityCache.cs b/EntityCache/EntityCache.cs new file mode 100644 index 0000000..be280b1 --- /dev/null +++ b/EntityCache/EntityCache.cs @@ -0,0 +1,38 @@ +using Discord.WebSocket; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Noikoio.RegexBot.EntityCache +{ + /// + /// Static class for accessing the entity cache. + /// + static class EntityCache + { + /* + * The entity cache works by combining data known/cached by Discord.Net in addition to + * what has been stored in the database. If data does not exist in the former, it is + * retrieved from the latter. + * In either case, the resulting data is placed within a cache item object. + */ + + static DiscordSocketClient _client; + internal static void SetClient(DiscordSocketClient c) => _client = _client ?? c; + + /// + /// Attempts to query for an exact result with the given parameters. + /// Does not handle exceptions that may occur. + /// + /// Null on no result. + internal static Task QueryAsync(ulong guild, ulong user) + => CacheUser.QueryAsync(_client, guild, user); + + /// + /// Attempts to look up the user given a search string. + /// This string looks up case-insensitive, exact matches of nicknames and usernames. + /// + /// An containing zero or more query results, sorted by cache date. + internal static Task> QueryAsync(DiscordSocketClient c, ulong guild, string search) + => CacheUser.QueryAsync(_client, guild, search); + } +} diff --git a/EntityCache/SqlHelper.cs b/EntityCache/SqlHelper.cs index c68a8e7..3c8b6ce 100644 --- a/EntityCache/SqlHelper.cs +++ b/EntityCache/SqlHelper.cs @@ -1,5 +1,4 @@ using Discord.WebSocket; -using Npgsql; using NpgsqlTypes; using System; using System.Collections.Generic; @@ -17,15 +16,10 @@ namespace Noikoio.RegexBot.EntityCache public const string TableTextChannel = "cache_textchannel"; public const string TableUser = "cache_users"; - private static async Task OpenDB() - { - if (!RegexBot.Config.Database.Available) return null; - return await RegexBot.Config.Database.GetOpenConnectionAsync(); - } - + // Reminder: Check Cache query methods if making changes to tables internal static async Task CreateCacheTablesAsync() { - var db = await OpenDB(); + var db = await RegexBot.Config.Database.GetOpenConnectionAsync(); if (db == null) return; using (db) { @@ -88,13 +82,13 @@ namespace Noikoio.RegexBot.EntityCache #region Insertions and updates internal static async Task UpdateGuildAsync(SocketGuild g) { - var db = await OpenDB(); + var db = await RegexBot.Config.Database.GetOpenConnectionAsync(); if (db == null) return; using (db) { using (var c = db.CreateCommand()) { - c.CommandText = "INSERT INTO " + Sql.TableGuild + " (guild_id, current_name) " + c.CommandText = "INSERT INTO " + TableGuild + " (guild_id, current_name) " + "VALUES (@GuildId, @CurrentName) " + "ON CONFLICT (guild_id) DO UPDATE SET " + "current_name = EXCLUDED.current_name"; @@ -113,13 +107,13 @@ namespace Noikoio.RegexBot.EntityCache } internal static async Task UpdateGuildMemberAsync(IEnumerable users) { - var db = await OpenDB(); + var db = await RegexBot.Config.Database.GetOpenConnectionAsync(); if (db == null) return; using (db) { using (var c = db.CreateCommand()) { - c.CommandText = "INSERT INTO " + Sql.TableUser + c.CommandText = "INSERT INTO " + TableUser + " (user_id, guild_id, cache_date, username, discriminator, nickname, avatar_url)" + " VALUES (@Uid, @Gid, @Date, @Uname, @Disc, @Nname, @Url) " + "ON CONFLICT (user_id, guild_id) DO UPDATE SET " diff --git a/RegexBot.cs b/RegexBot.cs index 019cead..b69db23 100644 --- a/RegexBot.cs +++ b/RegexBot.cs @@ -20,7 +20,7 @@ namespace Noikoio.RegexBot internal RegexBot() { - // Load configuration + // Load initial configuration _config = new Configuration(this); if (!_config.LoadInitialConfig()) { @@ -31,6 +31,7 @@ namespace Noikoio.RegexBot Environment.Exit(1); } + // Set Discord client settings _client = new DiscordSocketClient(new DiscordSocketConfig() { LogLevel = LogSeverity.Info, @@ -39,8 +40,9 @@ namespace Noikoio.RegexBot MessageCacheSize = 0 }); - // Hook up handlers for basic functions + // Hook up basic handlers and other references _client.Connected += _client_Connected; + EntityCache.EntityCache.SetClient(_client); // Initialize modules _modules = new BotModule[] @@ -50,13 +52,15 @@ namespace Noikoio.RegexBot new Module.AutoRespond.AutoRespond(_client), new EntityCache.Module(_client) // EntityCache goes before anything else that uses its data }; + + // Set up logging var dlog = Logger.GetLogger("Discord.Net"); _client.Log += async (arg) => await dlog( String.Format("{0}: {1}{2}", arg.Source, ((int)arg.Severity < 3 ? arg.Severity + ": " : ""), arg.Message)); - // With modules initialized, finish loading configuration + // Finish loading configuration var conf = _config.ReloadServerConfig().Result; if (conf == false) {