#pragma warning disable CA1822 // "Mark members as static" - members should only be callable by code with access to this instance using Microsoft.EntityFrameworkCore; using RegexBot.Common; using RegexBot.Data; namespace RegexBot.Services.EntityCache; /// /// Provides and maintains a database-backed cache of users. /// It is meant to work as a supplement 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 UserCachingSubservice { private readonly Action _log; internal UserCachingSubservice(RegexbotClient bot, Action logMethod) { _log = logMethod; bot.DiscordClient.GuildMembersDownloaded += DiscordClient_GuildMembersDownloaded; bot.DiscordClient.GuildMemberUpdated += DiscordClient_GuildMemberUpdated; bot.DiscordClient.UserUpdated += DiscordClient_UserUpdated; } private Task DiscordClient_GuildMembersDownloaded(SocketGuild arg) { var userlist = arg.Users.ToList(); _ = Task.Run(async () => { try { using var db = new BotDatabaseContext(); foreach (var user in userlist) { UpdateUser(user, db); UpdateGuildUser(user, db); } var changes = await db.SaveChangesAsync(); _log($"{arg.Name}: Member caches updated ({changes} database writes)."); } catch (Exception ex) { _log($"{arg.Name}: {ex}"); } }); return Task.CompletedTask; } private async Task DiscordClient_GuildMemberUpdated(Discord.Cacheable old, SocketGuildUser current) { using var db = new BotDatabaseContext(); UpdateUser(current, db); // Update user data first (avoid potential foreign key constraint violation) UpdateGuildUser(current, db); await db.SaveChangesAsync(); } private async Task DiscordClient_UserUpdated(SocketUser old, SocketUser current) { using var db = new BotDatabaseContext(); UpdateUser(current, db); await db.SaveChangesAsync(); } // IMPORTANT: Do NOT forget to save changes in database after calling this! private static void UpdateUser(SocketUser user, BotDatabaseContext db) { var uinfo = db.UserCache.Where(c => c.UserId == user.Id).SingleOrDefault(); if (uinfo == null) { uinfo = new() { UserId = user.Id }; db.UserCache.Add(uinfo); } uinfo.Username = user.Username; uinfo.Discriminator = user.Discriminator; uinfo.AvatarUrl = user.GetAvatarUrl(size: 512); uinfo.ULastUpdateTime = DateTimeOffset.UtcNow; } private static void UpdateGuildUser(SocketGuildUser user, BotDatabaseContext db) { var guinfo = db.GuildUserCache.Where(c => c.GuildId == user.Guild.Id && c.UserId == user.Id).SingleOrDefault(); if (guinfo == null) { guinfo = new() { GuildId = user.Guild.Id, UserId = user.Id }; db.GuildUserCache.Add(guinfo); } guinfo.GULastUpdateTime = DateTimeOffset.UtcNow; guinfo.Nickname = user.Nickname; // TODO guild-specific avatar, other details? } // Hooked internal CachedUser? DoUserQuery(string search) { static CachedUser? innerQuery(ulong? sID, (string name, string? disc)? nameSearch) { var db = new BotDatabaseContext(); var query = db.UserCache.AsQueryable(); if (sID.HasValue) query = query.Where(c => c.UserId == sID.Value); if (nameSearch != null) { query = query.Where(c => c.Username.ToLower() == nameSearch.Value.name.ToLower()); if (nameSearch.Value.disc != null) query = query.Where(c => c.Discriminator == nameSearch.Value.disc); } query = query.OrderByDescending(e => e.ULastUpdateTime); return query.SingleOrDefault(); } // Is search actually a ping? Extract ID. var m = Utilities.UserMention.Match(search); if (m.Success) search = m.Groups["snowflake"].Value; // Is search a number? Assume ID, proceed to query. if (ulong.TryParse(search, out var searchid)) { var idres = innerQuery(searchid, null); if (idres != null) return idres; } // All of the above failed. Assume the number may be a string to search. var namesplit = SplitNameAndDiscriminator(search); return innerQuery(null, namesplit); } // Hooked internal CachedGuildUser? DoGuildUserQuery(ulong guildId, string search) { static CachedGuildUser? innerQuery(ulong guildId, ulong? sID, (string name, string? disc)? nameSearch) { var db = new BotDatabaseContext(); var query = db.GuildUserCache.Include(gu => gu.User).Where(c => c.GuildId == guildId); if (sID.HasValue) query = query.Where(c => c.UserId == sID.Value); if (nameSearch != null) { query = query.Where(c => (c.Nickname != null && c.Nickname.ToLower() == nameSearch.Value.name.ToLower()) || c.User.Username.ToLower() == nameSearch.Value.name.ToLower()); if (nameSearch.Value.disc != null) query = query.Where(c => c.User.Discriminator == nameSearch.Value.disc); } query = query.OrderByDescending(e => e.GULastUpdateTime); return query.SingleOrDefault(); } // Is search actually a ping? Extract ID. var m = Utilities.UserMention.Match(search); if (m.Success) search = m.Groups["snowflake"].Value; // Is search a number? Assume ID, proceed to query. if (ulong.TryParse(search, out var searchid)) { var idres = innerQuery(guildId, searchid, null); if (idres != null) return idres; } // All of the above failed. Assume the number may be a string to search. var namesplit = SplitNameAndDiscriminator(search); return innerQuery(guildId, null, namesplit); } private static (string, string?) SplitNameAndDiscriminator(string input) { string name; string? disc = null; var split = Utilities.DiscriminatorSearch.Match(input); if (split.Success) { name = split.Groups[1].Value; disc = split.Groups[2].Value; } else { name = input; } // Also strip leading '@' from search if (name.Length > 0 && name[0] == '@') name = name[1..]; return (name, disc); } }