From f8fe48766b67e528623410f6a82b1b95d0250e69 Mon Sep 17 00:00:00 2001 From: Noi Date: Fri, 8 Jul 2022 12:03:15 -0700 Subject: [PATCH] Add ModLogs module Very similar in function to legacy's ModLogs, but with far cleaner code. Additionally fixes some EC issues regarding the message update delegate. --- RegexBot-Modules/ModLogs/ModLogs.cs | 62 ++++++++ RegexBot-Modules/ModLogs/ModLogs_Messages.cs | 133 ++++++++++++++++++ RegexBot-Modules/ModLogs/ModuleConfig.cs | 21 +++ .../EntityCache/EntityCacheService.cs | 2 +- RegexBot/Services/EntityCache/Hooks.cs | 19 ++- .../EntityCache/MessageCachingSubservice.cs | 54 +++---- 6 files changed, 260 insertions(+), 31 deletions(-) create mode 100644 RegexBot-Modules/ModLogs/ModLogs.cs create mode 100644 RegexBot-Modules/ModLogs/ModLogs_Messages.cs create mode 100644 RegexBot-Modules/ModLogs/ModuleConfig.cs diff --git a/RegexBot-Modules/ModLogs/ModLogs.cs b/RegexBot-Modules/ModLogs/ModLogs.cs new file mode 100644 index 0000000..efd9bc6 --- /dev/null +++ b/RegexBot-Modules/ModLogs/ModLogs.cs @@ -0,0 +1,62 @@ +using System.Text; + +namespace RegexBot.Modules.ModLogs; +/// +/// Logs certain events of note to a database for moderators to keep track of user behavior. +/// Makes use of a helper class, . +/// +[RegexbotModule] +public partial class ModLogs : RegexbotModule { + // TODO consider resurrecting 2.x idea of logging actions to db, making it searchable? + + public ModLogs(RegexbotClient bot) : base(bot) { + // TODO missing logging features: joins, leaves, bans, kicks, user edits (nick/username/discr) + DiscordClient.MessageDeleted += HandleDelete; + bot.EcOnMessageUpdate += HandleUpdate; + } + + public override Task CreateGuildStateAsync(ulong guildID, JToken config) { + if (config == null) return Task.FromResult(null); + if (config.Type != JTokenType.Object) + throw new ModuleLoadException("Configuration for this section is invalid."); + var newconf = new ModuleConfig((JObject)config); + Log($"Writing logs to {newconf.ReportingChannel}."); + return Task.FromResult(new ModuleConfig((JObject)config)); + } + + private static string MakeTimestamp(DateTimeOffset time) { + var result = new StringBuilder(); + //result.Append(time.ToString("yyyy-MM-dd hh:mm:ss")); + result.Append($""); + + var now = DateTimeOffset.UtcNow; + var diff = now - time; + if (diff < new TimeSpan(3, 0, 0, 0)) { + // Difference less than 3 days. Generate relative time format. + result.Append(" - "); + + if (diff.TotalSeconds < 60) { + // Under a minute ago. Show only seconds. + result.Append((int)Math.Ceiling(diff.TotalSeconds) + "s ago"); + } else { + // over a minute. Show days, hours, minutes, seconds. + var ts = (int)Math.Ceiling(diff.TotalSeconds); + var m = ts % 3600 / 60; + var h = ts % 86400 / 3600; + var d = ts / 86400; + + if (d > 0) result.AppendFormat("{0}d{1}h{2}m", d, h, m); + else if (h > 0) result.AppendFormat("{0}h{1}m", h, m); + else result.AppendFormat("{0}m", m); + result.Append(" ago"); + } + } + + return result.ToString(); + } + + private static string GetDefaultAvatarUrl(string discriminator) { + var discVal = int.Parse(discriminator); + return $"https://cdn.discordapp.com/embed/avatars/{discVal % 5}.png"; + } +} \ No newline at end of file diff --git a/RegexBot-Modules/ModLogs/ModLogs_Messages.cs b/RegexBot-Modules/ModLogs/ModLogs_Messages.cs new file mode 100644 index 0000000..3981656 --- /dev/null +++ b/RegexBot-Modules/ModLogs/ModLogs_Messages.cs @@ -0,0 +1,133 @@ +using Discord; +using Microsoft.EntityFrameworkCore; +using RegexBot.Data; +using System.Text; + +namespace RegexBot.Modules.ModLogs; +// Contains handlers and all logic relating to logging message edits and deletions +public partial class ModLogs { + const string PreviewCutoffNotify = "**Message too long to preview; showing first {0} characters.**\n\n"; + const string NotCached = "Message not cached."; + + private async Task HandleDelete(Cacheable argMsg, Cacheable argChannel) { + const int MaxPreviewLength = 750; + if (argChannel.Value is not SocketTextChannel channel) return; + var conf = GetGuildState(channel.Guild.Id); + var reportChannel = conf?.ReportingChannel?.FindChannelIn(channel.Guild, true); + if (reportChannel == null) return; + if ((conf?.LogMessageDeletions ?? false) == false) return; + if (reportChannel.Id == channel.Id) { + Log($"[{channel.Guild.Name}] Message deletion detected in the reporting channel. Regular report has been suppressed."); + return; + } + + using var db = new BotDatabaseContext(); + var cachedMsg = db.GuildMessageCache + .Include(gm => gm.Author) + .Where(gm => gm.MessageId == (long)argMsg.Id) + .SingleOrDefault(); + + var reportEmbed = new EmbedBuilder() + .WithTitle("Message deleted") + .WithCurrentTimestamp(); + + if (cachedMsg != null) { + if (cachedMsg.Content.Length > MaxPreviewLength) { + reportEmbed.Description = string.Format(PreviewCutoffNotify, MaxPreviewLength) + + cachedMsg.Content[MaxPreviewLength..]; + } else { + reportEmbed.Description = cachedMsg.Content; + } + if (cachedMsg.Author == null) { + reportEmbed.Author = new EmbedAuthorBuilder() { + Name = $"User ID {cachedMsg.AuthorId}", + IconUrl = GetDefaultAvatarUrl("0") + }; + } else { + reportEmbed.Author = new EmbedAuthorBuilder() { + Name = $"{cachedMsg.Author.Username}#{cachedMsg.Author.Discriminator}", + IconUrl = cachedMsg.Author.AvatarUrl ?? GetDefaultAvatarUrl(cachedMsg.Author.Discriminator) + }; + } + } else { + reportEmbed.Description = NotCached; + } + + var contextStr = new StringBuilder(); + contextStr.AppendLine($"User: {(cachedMsg != null ? $"<@!{cachedMsg.AuthorId}>" : "Unknown")}"); + contextStr.AppendLine($"Channel: <#{channel.Id}> (#{channel.Name})"); + contextStr.AppendLine($"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(argMsg.Id))}"); + if (cachedMsg?.EditedAt != null) contextStr.AppendLine($"Last edit: {MakeTimestamp(cachedMsg.EditedAt.Value)}"); + contextStr.AppendLine($"Message ID: {argMsg.Id}"); + reportEmbed.AddField(new EmbedFieldBuilder() { + Name = "Context", + Value = contextStr.ToString() + }); + + await reportChannel.SendMessageAsync(embed: reportEmbed.Build()); + } + + private async Task HandleUpdate(CachedGuildMessage? oldMsg, SocketMessage newMsg) { + const int MaxPreviewLength = 500; + var channel = (SocketTextChannel)newMsg.Channel; + var conf = GetGuildState(channel.Guild.Id); + var reportChannel = conf?.ReportingChannel?.FindChannelIn(channel.Guild, true); + if (reportChannel == null) return; + if ((conf?.LogMessageEdits ?? false) == false) return; + if (reportChannel.Id == channel.Id) { + Log($"[{channel.Guild.Name}] Message edit detected in the reporting channel. Regular report has been suppressed."); + return; + } + + var reportEmbed = new EmbedBuilder() + .WithTitle("Message edited") + .WithCurrentTimestamp(); + Console.WriteLine(reportEmbed.Build().ToString()); + + reportEmbed.Author = new EmbedAuthorBuilder() { + Name = $"{newMsg.Author.Username}#{newMsg.Author.Discriminator}", + IconUrl = newMsg.Author.GetAvatarUrl() ?? newMsg.Author.GetDefaultAvatarUrl() + }; + Console.WriteLine(reportEmbed.Build().ToString()); + + var oldField = new EmbedFieldBuilder() { Name = "Old" }; + if (oldMsg != null) { + if (oldMsg.Content.Length > MaxPreviewLength) { + oldField.Value = string.Format(PreviewCutoffNotify, MaxPreviewLength) + + oldMsg.Content[MaxPreviewLength..]; + } else { + oldField.Value = oldMsg.Content; + } + } else { + oldField.Value = NotCached; + } + reportEmbed.AddField(oldField); + Console.WriteLine(reportEmbed.Build().ToString()); + + // TODO shorten 'new' preview, add clickable? check if this would be good usability-wise + var newField = new EmbedFieldBuilder() { Name = "New" }; + if (newMsg.Content.Length > MaxPreviewLength) { + newField.Value = string.Format(PreviewCutoffNotify, MaxPreviewLength) + + newMsg.Content[MaxPreviewLength..]; + } else { + newField.Value = newMsg.Content; + } + reportEmbed.AddField(newField); + Console.WriteLine(reportEmbed.Build().ToString()); + + var contextStr = new StringBuilder(); + contextStr.AppendLine($"User: <@!{newMsg.Author.Id}>"); + contextStr.AppendLine($"Channel: <#{channel.Id}> (#{channel.Name})"); + if ((oldMsg?.EditedAt) == null) contextStr.AppendLine($"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(newMsg.Id))}"); + else contextStr.AppendLine($"Previous edit: {MakeTimestamp(oldMsg.EditedAt.Value)}"); + contextStr.AppendLine($"Message ID: {newMsg.Id}"); + var contextField = new EmbedFieldBuilder() { + Name = "Context", + Value = contextStr.ToString() + }; + reportEmbed.AddField(contextField); + Console.WriteLine(reportEmbed.Build().ToString()); + + await reportChannel.SendMessageAsync(embed: reportEmbed.Build()); + } +} \ No newline at end of file diff --git a/RegexBot-Modules/ModLogs/ModuleConfig.cs b/RegexBot-Modules/ModLogs/ModuleConfig.cs new file mode 100644 index 0000000..c7cf0e7 --- /dev/null +++ b/RegexBot-Modules/ModLogs/ModuleConfig.cs @@ -0,0 +1,21 @@ +using RegexBot.Common; + +namespace RegexBot.Modules.ModLogs; +class ModuleConfig { + public EntityName ReportingChannel { get; } + + public bool LogMessageDeletions { get; } + public bool LogMessageEdits { get; } + + public ModuleConfig(JObject config) { + const string RptChError = $"'{nameof(ReportingChannel)}' must be set to a valid channel name."; + var rptch = config[nameof(ReportingChannel)]?.Value(); + if (string.IsNullOrWhiteSpace(rptch)) throw new ModuleLoadException(RptChError); + ReportingChannel = new EntityName(rptch); + if (ReportingChannel.Type != EntityType.Channel) throw new ModuleLoadException(RptChError); + + // Individual logging settings - all default to false + LogMessageDeletions = config[nameof(LogMessageDeletions)]?.Value() ?? false; + LogMessageEdits = config[nameof(LogMessageEdits)]?.Value() ?? false; + } +} \ No newline at end of file diff --git a/RegexBot/Services/EntityCache/EntityCacheService.cs b/RegexBot/Services/EntityCache/EntityCacheService.cs index 2ceca21..6a3f646 100644 --- a/RegexBot/Services/EntityCache/EntityCacheService.cs +++ b/RegexBot/Services/EntityCache/EntityCacheService.cs @@ -25,7 +25,7 @@ class EntityCacheService : Service { => _uc.DoGuildUserQuery(guildId, search); // Hooked - internal event RegexbotClient.CachePreUpdateHandler? OnCachePreUpdate { + internal event RegexbotClient.EcMessageUpdateHandler? 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 bbe9df5..834009c 100644 --- a/RegexBot/Services/EntityCache/Hooks.cs +++ b/RegexBot/Services/EntityCache/Hooks.cs @@ -1,4 +1,5 @@ -using RegexBot.Data; +using Discord.WebSocket; +using RegexBot.Data; using RegexBot.Services.EntityCache; namespace RegexBot; @@ -28,14 +29,20 @@ partial class RegexbotClient { /// 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. + /// This event serves as an alternative to , + /// pulling the previous state of the message from the entity cache instead of the library's cache. ///
- public event CachePreUpdateHandler? OnCachePreUpdate { + public event EcMessageUpdateHandler? EcOnMessageUpdate { add { _svcEntityCache.OnCachePreUpdate += value; } remove { _svcEntityCache.OnCachePreUpdate -= value; } } - public delegate Task CachePreUpdateHandler(CachedGuildMessage cachedMsg); + /// + /// Delegate used for the event. + /// + /// + /// The previous state of the message prior to being updated, as known by the entity cache. + /// The new, updated incoming message. + /// + public delegate Task EcMessageUpdateHandler(CachedGuildMessage? oldMsg, SocketMessage newMsg); } diff --git a/RegexBot/Services/EntityCache/MessageCachingSubservice.cs b/RegexBot/Services/EntityCache/MessageCachingSubservice.cs index 3722463..c9838f2 100644 --- a/RegexBot/Services/EntityCache/MessageCachingSubservice.cs +++ b/RegexBot/Services/EntityCache/MessageCachingSubservice.cs @@ -6,7 +6,7 @@ using static RegexBot.RegexbotClient; namespace RegexBot.Services.EntityCache; class MessageCachingSubservice { // Hooked - public event CachePreUpdateHandler? OnCachePreUpdate; + public event EcMessageUpdateHandler? OnCachePreUpdate; private readonly Action _log; @@ -17,17 +17,25 @@ class MessageCachingSubservice { } private Task DiscordClient_MessageReceived(SocketMessage arg) - => AddOrUpdateCacheItemAsync(arg); + => AddOrUpdateCacheItemAsync(arg, false); private Task DiscordClient_MessageUpdated(Cacheable arg1, SocketMessage arg2, ISocketMessageChannel arg3) - => AddOrUpdateCacheItemAsync(arg2); + => AddOrUpdateCacheItemAsync(arg2, true); - private async Task AddOrUpdateCacheItemAsync(SocketMessage arg) { + private async Task AddOrUpdateCacheItemAsync(SocketMessage arg, bool isUpdate) { if (!Common.Misc.IsValidUserMessage(arg, out _)) return; - using var db = new BotDatabaseContext(); - CachedGuildMessage? msg = db.GuildMessageCache.Where(m => m.MessageId == (long)arg.Id).SingleOrDefault(); - if (msg == null) { - msg = new() { + using var db = new BotDatabaseContext(); + CachedGuildMessage? cachedMsg = db.GuildMessageCache.Where(m => m.MessageId == (long)arg.Id).SingleOrDefault(); + + if (isUpdate) { + // Alternative for Discord.Net's MessageUpdated handler: + // Notify subscribers of message update using EC entry for the previous message state + var oldMsg = cachedMsg?.MemberwiseClone(); + await Task.Factory.StartNew(async () => await RunPreUpdateHandlersAsync(oldMsg, arg)); + } + + if (cachedMsg == null) { + cachedMsg = new() { MessageId = (long)arg.Id, AuthorId = (long)arg.Author.Id, GuildId = (long)((SocketGuildUser)arg.Author).Guild.Id, @@ -35,30 +43,28 @@ class MessageCachingSubservice { AttachmentNames = arg.Attachments.Select(a => a.Filename).ToList(), Content = arg.Content }; - db.GuildMessageCache.Add(msg); + db.GuildMessageCache.Add(cachedMsg); } 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); + cachedMsg.EditedAt = DateTimeOffset.UtcNow; + cachedMsg.Content = arg.Content; + cachedMsg.AttachmentNames = arg.Attachments.Select(a => a.Filename).ToList(); + db.GuildMessageCache.Update(cachedMsg); } await db.SaveChangesAsync(); } - private async Task RunPreUpdateHandlersAsync(CachedGuildMessage msg) { - CachePreUpdateHandler? eventList; - lock (this) eventList = OnCachePreUpdate; - if (eventList == null) return; + private async Task RunPreUpdateHandlersAsync(CachedGuildMessage? oldMsg, SocketMessage newMsg) { + Delegate[]? subscribers; + lock (this) { + subscribers = OnCachePreUpdate?.GetInvocationList(); + if (subscribers == null || subscribers.Length == 0) return; + } - foreach (var handler in eventList.GetInvocationList()) { + foreach (var handler in subscribers) { try { - await (Task)handler.DynamicInvoke(msg)!; + await (Task)handler.DynamicInvoke(oldMsg, newMsg)!; } catch (Exception ex) { - _log($"Unhandled exception in {nameof(RegexbotClient.OnCachePreUpdate)} handler '{handler.Method.Name}':", false); + _log($"Unhandled exception in {nameof(RegexbotClient.EcOnMessageUpdate)} handler '{handler.Method.Name}':", false); _log(ex.ToString(), false); } }