From 5671b7b48cf2a110f03e5b00b006a6e729a14d54 Mon Sep 17 00:00:00 2001 From: Noi Date: Wed, 11 May 2022 20:26:28 -0700 Subject: [PATCH] Add message caching subservice Partially implements legacy's ModLogs module on the bot side, with the remainder to be implemented as a proper module. --- RegexBot/Data/BotDatabaseContext.cs | 5 +- RegexBot/Data/CachedGuildMessage.cs | 36 ++++++++++ RegexBot/Data/CachedUser.cs | 3 + RegexBot/InstanceConfig.cs | 8 +-- RegexBot/Program.cs | 7 +- RegexBot/RegexBot.csproj | 8 +-- .../Services/CommonFunctions/BanKickResult.cs | 7 +- .../CommonFunctions/CommonFunctionsService.cs | 3 +- RegexBot/Services/CommonFunctions/Hooks.cs | 23 ++++--- .../EntityCache/EntityCacheService.cs | 17 +++-- RegexBot/Services/EntityCache/Hooks.cs | 25 +++++-- .../EntityCache/MessageCachingSubservice.cs | 69 +++++++++++++++++++ .../EntityCache/UserCachingSubservice.cs | 6 +- 13 files changed, 175 insertions(+), 42 deletions(-) create mode 100644 RegexBot/Data/CachedGuildMessage.cs create mode 100644 RegexBot/Services/EntityCache/MessageCachingSubservice.cs diff --git a/RegexBot/Data/BotDatabaseContext.cs b/RegexBot/Data/BotDatabaseContext.cs index bb4e7ec..c118da2 100644 --- a/RegexBot/Data/BotDatabaseContext.cs +++ b/RegexBot/Data/BotDatabaseContext.cs @@ -20,6 +20,7 @@ public class BotDatabaseContext : DbContext { public DbSet GuildLog { get; set; } = null!; public DbSet UserCache { get; set; } = null!; public DbSet GuildUserCache { get; set; } = null!; + public DbSet GuildMessageCache { get; set; } = null!; protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder @@ -27,9 +28,9 @@ public class BotDatabaseContext : DbContext { .UseSnakeCaseNamingConvention(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => - entity.Property(e => e.Timestamp).HasDefaultValueSql("now()")); + modelBuilder.Entity(entity => entity.Property(e => e.Timestamp).HasDefaultValueSql("now()")); modelBuilder.Entity(entity => entity.Property(e => e.Discriminator).HasMaxLength(4).IsFixedLength()); modelBuilder.Entity(entity => entity.Navigation(e => e.User).AutoInclude()); + modelBuilder.Entity(entity => entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()")); } } diff --git a/RegexBot/Data/CachedGuildMessage.cs b/RegexBot/Data/CachedGuildMessage.cs new file mode 100644 index 0000000..da76387 --- /dev/null +++ b/RegexBot/Data/CachedGuildMessage.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace RegexBot.Data; +[Table("cache_messages")] +public class CachedGuildMessage { + [Key] + public long MessageId { get; set; } + + public long AuthorId { get; set; } + + public long GuildId { get; set; } + + public long ChannelId { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? EditedAt { get; set; } + + public List AttachmentNames { get; set; } = null!; + + public string Content { get; set; } = null!; + + /// Gets the timestamp when the message was last updated. + /// + /// This is equivalent to coalescing the value of and . + /// + [NotMapped] + public DateTimeOffset LastUpdatedAt => EditedAt ?? CreatedAt; + + [ForeignKey(nameof(CachedUser.UserId))] + [InverseProperty(nameof(CachedUser.GuildMessages))] + public CachedUser Author { get; set; } = null!; + + internal new CachedGuildMessage MemberwiseClone() => (CachedGuildMessage)base.MemberwiseClone(); +} diff --git a/RegexBot/Data/CachedUser.cs b/RegexBot/Data/CachedUser.cs index 8f5620b..9658f87 100644 --- a/RegexBot/Data/CachedUser.cs +++ b/RegexBot/Data/CachedUser.cs @@ -14,4 +14,7 @@ public class CachedUser { [InverseProperty(nameof(CachedGuildUser.User))] public ICollection Guilds { get; set; } = null!; + + [InverseProperty(nameof(CachedGuildMessage.Author))] + public ICollection GuildMessages { get; set; } = null!; } diff --git a/RegexBot/InstanceConfig.cs b/RegexBot/InstanceConfig.cs index c55f5e9..051f904 100644 --- a/RegexBot/InstanceConfig.cs +++ b/RegexBot/InstanceConfig.cs @@ -55,20 +55,18 @@ class InstanceConfig { throw new Exception(pfx + ex.Message, ex); } -#pragma warning disable CS8601 // Possible null reference assignment. // Input validation - throw exception on errors. Exception messages printed as-is. - BotToken = conf[nameof(BotToken)]?.Value(); + BotToken = conf[nameof(BotToken)]?.Value()!; if (string.IsNullOrEmpty(BotToken)) throw new Exception($"'{nameof(BotToken)}' is not properly specified in configuration."); - PostgresConnString = conf[nameof(PostgresConnString)]?.Value(); + PostgresConnString = conf[nameof(PostgresConnString)]?.Value()!; if (string.IsNullOrEmpty(PostgresConnString)) throw new Exception($"'{nameof(PostgresConnString)}' is not properly specified in configuration."); - InstanceLogTarget = conf[nameof(InstanceLogTarget)]?.Value(); + InstanceLogTarget = conf[nameof(InstanceLogTarget)]?.Value()!; if (string.IsNullOrEmpty(InstanceLogTarget)) throw new Exception($"'{nameof(InstanceLogTarget)}' is not properly specified in configuration."); -#pragma warning restore CS8601 var asmList = conf[nameof(Assemblies)]; if (asmList == null || asmList.Type != JTokenType.Array) { diff --git a/RegexBot/Program.cs b/RegexBot/Program.cs index 0422434..4ae93c7 100644 --- a/RegexBot/Program.cs +++ b/RegexBot/Program.cs @@ -2,7 +2,6 @@ using Discord.WebSocket; namespace RegexBot; - class Program { /// /// Timestamp specifying the date and time that the program began running. @@ -51,7 +50,7 @@ class Program { private static void Console_CancelKeyPress(object? sender, ConsoleCancelEventArgs e) { e.Cancel = true; - _main.InstanceLogAsync(true, nameof(RegexBot), "Shutting down. Reason: Interrupt signal."); + _main._svcLogging.DoInstanceLog(true, nameof(RegexBot), "Shutting down. Reason: Interrupt signal."); // 5 seconds of leeway - any currently running tasks will need time to finish executing var closeWait = Task.Delay(5000); @@ -62,8 +61,8 @@ class Program { closeWait.Wait(); bool success = _main.DiscordClient.StopAsync().Wait(1000); - if (!success) _main.InstanceLogAsync(false, nameof(RegexBot), - "Failed to disconnect cleanly from Discord. Will force shut down.").Wait(); + if (!success) _main._svcLogging.DoInstanceLog(false, nameof(RegexBot), + "Failed to disconnect cleanly from Discord. Will force shut down."); Environment.Exit(0); } } diff --git a/RegexBot/RegexBot.csproj b/RegexBot/RegexBot.csproj index 359ea73..1da133a 100644 --- a/RegexBot/RegexBot.csproj +++ b/RegexBot/RegexBot.csproj @@ -25,15 +25,15 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/RegexBot/Services/CommonFunctions/BanKickResult.cs b/RegexBot/Services/CommonFunctions/BanKickResult.cs index 60545e6..265582d 100644 --- a/RegexBot/Services/CommonFunctions/BanKickResult.cs +++ b/RegexBot/Services/CommonFunctions/BanKickResult.cs @@ -4,7 +4,6 @@ using static RegexBot.RegexbotClient; // Instances of this class are created by CommonFunctionService and are meant to be sent to modules, // therefore we put this in the root RegexBot namespace despite being specific to this service. namespace RegexBot; - /// /// Contains information on various success/failure outcomes for a ban or kick operation. /// @@ -75,7 +74,7 @@ public class BanKickResult { /// Returns a message representative of the ban/kick result that may be posted as-is /// within the a Discord channel. /// - public string GetResultString(RegexbotClient bot, ulong guildId) { + public string GetResultString(RegexbotClient bot) { string msg; if (OperationSuccess) msg = ":white_check_mark: "; @@ -92,10 +91,12 @@ public class BanKickResult { } if (_rptTargetId != 0) { - var user = bot.EcQueryUser(guildId, _rptTargetId.ToString()).GetAwaiter().GetResult(); + var user = bot.EcQueryUser(_rptTargetId.ToString()); if (user != null) { // TODO sanitize possible formatting characters in display name msg += $" user **{user.Username}#{user.Discriminator}**"; + } else { + msg += $" user with ID **{_rptTargetId}**"; } } diff --git a/RegexBot/Services/CommonFunctions/CommonFunctionsService.cs b/RegexBot/Services/CommonFunctions/CommonFunctionsService.cs index c245a09..1f5c5a5 100644 --- a/RegexBot/Services/CommonFunctions/CommonFunctionsService.cs +++ b/RegexBot/Services/CommonFunctions/CommonFunctionsService.cs @@ -20,6 +20,7 @@ internal class CommonFunctionsService : Service { /// Common processing for kicks and bans. Called by DoKickAsync and DoBanAsync. /// /// The reason to insert into the Audit Log. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static")] internal async Task BanOrKickAsync( RemovalType t, SocketGuild guild, string source, ulong target, int banPurgeDays, string logReason, bool sendDmToTarget) { @@ -31,7 +32,7 @@ internal class CommonFunctionsService : Service { // Can't kick without obtaining user object. Quit here. if (t == RemovalType.Kick && utarget == null) return new BanKickResult(null, false, true, RemovalType.Kick, 0); - // TODO notify services here as soon as we get some who will want to listen to this + // TODO notify services here as soon as we get some who will want to listen to this (use source parameter) // Send DM notification if (sendDmToTarget) { diff --git a/RegexBot/Services/CommonFunctions/Hooks.cs b/RegexBot/Services/CommonFunctions/Hooks.cs index aa1c32b..b531c58 100644 --- a/RegexBot/Services/CommonFunctions/Hooks.cs +++ b/RegexBot/Services/CommonFunctions/Hooks.cs @@ -2,9 +2,8 @@ using RegexBot.Services.CommonFunctions; namespace RegexBot; - partial class RegexbotClient { - private CommonFunctionsService _svcCommonFunctions; + private readonly CommonFunctionsService _svcCommonFunctions; public enum RemovalType { None, Ban, Kick } @@ -31,9 +30,9 @@ partial class RegexbotClient { /// The EntityCache search string. public async Task BanAsync(SocketGuild guild, string source, string targetSearch, int purgeDays, string reason, bool sendDMToTarget) { - var result = await EcQueryUser(guild.Id, targetSearch); + var result = EcQueryGuildUser(guild.Id, targetSearch); if (result == null) return new BanKickResult(null, false, true, RemovalType.Ban, 0); - return await BanAsync(guild, source, result.UserID, purgeDays, reason, sendDMToTarget); + return await BanAsync(guild, source, (ulong)result.UserId, purgeDays, reason, sendDMToTarget); } /// @@ -44,9 +43,15 @@ partial class RegexbotClient { /// A structure containing results of the ban operation. /// The guild in which to attempt the kick. /// The user, module, or service which is requesting this action to be taken. - /// The user which to perform the action to. - /// Reason for the action. Sent to the Audit Log and user (if specified). - /// Specify whether to send a direct message to the target user informing them of the action being taken. + /// The user which to perform the action towards. + /// + /// Reason for the action. Sent to the guild's audit log and, if + /// is , the target. + /// + /// + /// Specify whether to send a direct message to the target user informing them of the action + /// (that is, a ban/kick message). + /// public Task KickAsync(SocketGuild guild, string source, ulong targetUser, string reason, bool sendDMToTarget) => _svcCommonFunctions.BanOrKickAsync(RemovalType.Ban, guild, source, targetUser, 0, reason, sendDMToTarget); @@ -56,8 +61,8 @@ partial class RegexbotClient { /// /// The EntityCache search string. public async Task KickAsync(SocketGuild guild, string source, string targetSearch, string reason, bool sendDMToTarget) { - var result = await EcQueryUser(guild.Id, targetSearch); + var result = EcQueryGuildUser(guild.Id, targetSearch); if (result == null) return new BanKickResult(null, false, true, RemovalType.Kick, 0); - return await KickAsync(guild, source, result.UserID, reason, sendDMToTarget); + return await KickAsync(guild, source, (ulong)result.UserId, reason, sendDMToTarget); } } diff --git a/RegexBot/Services/EntityCache/EntityCacheService.cs b/RegexBot/Services/EntityCache/EntityCacheService.cs index cb5430e..2ceca21 100644 --- a/RegexBot/Services/EntityCache/EntityCacheService.cs +++ b/RegexBot/Services/EntityCache/EntityCacheService.cs @@ -1,7 +1,6 @@ using RegexBot.Data; namespace RegexBot.Services.EntityCache; - /// /// 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 @@ -9,17 +8,25 @@ namespace RegexBot.Services.EntityCache; /// class EntityCacheService : Service { private readonly UserCachingSubservice _uc; + private readonly MessageCachingSubservice _mc; internal EntityCacheService(RegexbotClient bot) : base(bot) { // Currently we only have UserCache. May add Channel and Server caches later. _uc = new UserCachingSubservice(bot); + _mc = new MessageCachingSubservice(bot, Log); } // Hooked - internal static CachedUser? QueryUserCache(string search) - => UserCachingSubservice.DoUserQuery(search); + internal CachedUser? QueryUserCache(string search) + => _uc.DoUserQuery(search); // Hooked - internal static CachedGuildUser? QueryGuildUserCache(ulong guildId, string search) - => UserCachingSubservice.DoGuildUserQuery(guildId, search); + internal CachedGuildUser? QueryGuildUserCache(ulong guildId, string search) + => _uc.DoGuildUserQuery(guildId, search); + + // Hooked + internal event RegexbotClient.CachePreUpdateHandler? OnCachePreUpdate { + add { lock (_mc) _mc.OnCachePreUpdate += value; } + remove { lock (_mc) _mc.OnCachePreUpdate -= value; } + } } diff --git a/RegexBot/Services/EntityCache/Hooks.cs b/RegexBot/Services/EntityCache/Hooks.cs index 4c5b2a4..bbe9df5 100644 --- a/RegexBot/Services/EntityCache/Hooks.cs +++ b/RegexBot/Services/EntityCache/Hooks.cs @@ -1,11 +1,9 @@ -#pragma warning disable CA1822 -using RegexBot.Data; +using RegexBot.Data; using RegexBot.Services.EntityCache; namespace RegexBot; - partial class RegexbotClient { - private EntityCacheService _svcEntityCache; + private readonly EntityCacheService _svcEntityCache; /// /// Queries the entity cache for user information. The given search string may contain a user ID @@ -14,7 +12,7 @@ partial class RegexbotClient { /// /// Search string. May be a name with discriminator, a name, or an ID. /// A instance containing cached information, or null if no result. - public CachedUser? EcQueryUser(string search) => EntityCacheService.QueryUserCache(search); + public CachedUser? EcQueryUser(string search) => _svcEntityCache.QueryUserCache(search); /// /// Queries the entity cache for guild-specific user information. The given search string may contain a user ID, @@ -24,5 +22,20 @@ partial class RegexbotClient { /// ID of the corresponding guild in which to search. /// Search string. May be a name with discriminator, a name, or an ID. /// A instance containing cached information, or null if no result. - public CachedGuildUser? EcQueryGuildUser(ulong guildId, string search) => EntityCacheService.QueryGuildUserCache(guildId, search); + public CachedGuildUser? EcQueryGuildUser(ulong guildId, string search) => _svcEntityCache.QueryGuildUserCache(guildId, search); + + /// + /// Fired after a message edit, when the message cache is about to be updated with the edited message. + /// + /// + /// An alternative to .
+ /// This event is fired in response to a guild message being edited and provides handlers with existing + /// cached contents before it is updated and the previous contents permanently lost. + ///
+ public event CachePreUpdateHandler? OnCachePreUpdate { + add { _svcEntityCache.OnCachePreUpdate += value; } + remove { _svcEntityCache.OnCachePreUpdate -= value; } + } + + public delegate Task CachePreUpdateHandler(CachedGuildMessage cachedMsg); } diff --git a/RegexBot/Services/EntityCache/MessageCachingSubservice.cs b/RegexBot/Services/EntityCache/MessageCachingSubservice.cs new file mode 100644 index 0000000..aceeb00 --- /dev/null +++ b/RegexBot/Services/EntityCache/MessageCachingSubservice.cs @@ -0,0 +1,69 @@ +using Discord; +using Discord.WebSocket; +using RegexBot.Data; +using static RegexBot.RegexbotClient; + +namespace RegexBot.Services.EntityCache; +class MessageCachingSubservice { + // Hooked + public event CachePreUpdateHandler? OnCachePreUpdate; + + private readonly Action _log; + + internal MessageCachingSubservice(RegexbotClient bot, Action logMethod) { + _log = logMethod; + bot.DiscordClient.MessageReceived += DiscordClient_MessageReceived; + bot.DiscordClient.MessageUpdated += DiscordClient_MessageUpdated; + } + + private Task DiscordClient_MessageReceived(SocketMessage arg) { + if (arg.Channel is IDMChannel || arg is not SocketSystemMessage) return Task.CompletedTask; + return AddOrUpdateCacheItemAsync(arg); + } + private Task DiscordClient_MessageUpdated(Cacheable arg1, SocketMessage arg2, ISocketMessageChannel arg3) { + if (arg2.Channel is IDMChannel || arg2 is not SocketSystemMessage) return Task.CompletedTask; + return AddOrUpdateCacheItemAsync(arg2); + } + + private async Task AddOrUpdateCacheItemAsync(SocketMessage arg) { + using var db = new BotDatabaseContext(); + + CachedGuildMessage? msg = db.GuildMessageCache.Where(m => m.MessageId == (long)arg.Id).SingleOrDefault(); + if (msg == null) { + msg = new() { + MessageId = (long)arg.Id, + AuthorId = (long)arg.Author.Id, + GuildId = (long)((SocketGuildUser)arg.Author).Guild.Id, + ChannelId = (long)arg.Channel.Id, + AttachmentNames = arg.Attachments.Select(a => a.Filename).ToList(), + Content = arg.Content + }; + db.GuildMessageCache.Add(msg); + } else { + // Notify any listeners of cache update before it happens + var oldMsg = msg.MemberwiseClone(); + await Task.Factory.StartNew(async () => await RunPreUpdateHandlersAsync(oldMsg)); + + msg.EditedAt = DateTimeOffset.UtcNow; + msg.Content = arg.Content; + msg.AttachmentNames = arg.Attachments.Select(a => a.Filename).ToList(); + db.GuildMessageCache.Update(msg); + } + await db.SaveChangesAsync(); + } + + private async Task RunPreUpdateHandlersAsync(CachedGuildMessage msg) { + CachePreUpdateHandler? eventList; + lock (this) eventList = OnCachePreUpdate; + if (eventList == null) return; + + foreach (var handler in eventList.GetInvocationList()) { + try { + await (Task)handler.DynamicInvoke(msg)!; + } catch (Exception ex) { + _log($"Unhandled exception in {nameof(RegexbotClient.OnCachePreUpdate)} handler '{handler.Method.Name}':", false); + _log(ex.ToString(), false); + } + } + } +} diff --git a/RegexBot/Services/EntityCache/UserCachingSubservice.cs b/RegexBot/Services/EntityCache/UserCachingSubservice.cs index 5fd23e4..f1fb673 100644 --- a/RegexBot/Services/EntityCache/UserCachingSubservice.cs +++ b/RegexBot/Services/EntityCache/UserCachingSubservice.cs @@ -3,12 +3,12 @@ using RegexBot.Data; using System.Text.RegularExpressions; 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. /// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static")] class UserCachingSubservice { private static Regex DiscriminatorSearch { get; } = new(@"(.+)#(\d{4}(?!\d))", RegexOptions.Compiled); @@ -58,7 +58,7 @@ class UserCachingSubservice { } // Hooked - internal static CachedUser? DoUserQuery(string search) { + internal CachedUser? DoUserQuery(string search) { static CachedUser? innerQuery(ulong? sID, (string name, string? disc)? nameSearch) { var db = new BotDatabaseContext(); @@ -87,7 +87,7 @@ class UserCachingSubservice { } // Hooked - internal static CachedGuildUser? DoGuildUserQuery(ulong guildId, string search) { + internal CachedGuildUser? DoGuildUserQuery(ulong guildId, string search) { static CachedGuildUser? innerQuery(ulong guildId, ulong? sID, (string name, string? disc)? nameSearch) { var db = new BotDatabaseContext();