From 69f9da53132c80fd467b4447e31cefb2f6966899 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Tue, 20 Feb 2018 00:48:11 -0800 Subject: [PATCH] Added mostly complete MessageCache --- Module/ModLogs/MessageCache.cs | 254 +++++++++++++++++++++++++++++---- 1 file changed, 224 insertions(+), 30 deletions(-) diff --git a/Module/ModLogs/MessageCache.cs b/Module/ModLogs/MessageCache.cs index 7c67a27..2969fe6 100644 --- a/Module/ModLogs/MessageCache.cs +++ b/Module/ModLogs/MessageCache.cs @@ -1,4 +1,5 @@ -using Discord.WebSocket; +using Discord; +using Discord.WebSocket; using Npgsql; using NpgsqlTypes; using System; @@ -31,28 +32,178 @@ namespace Noikoio.RegexBot.Module.ModLogs } #region Event handling - private async Task Client_MessageReceived(SocketMessage arg) => await CacheMessage(arg); + private async Task Client_MessageReceived(SocketMessage arg) => await AddOrUpdateCacheItemAsync(arg); - private Task Client_MessageUpdated(Discord.Cacheable arg1, SocketMessage arg2, ISocketMessageChannel arg3) + private async Task Client_MessageUpdated( + Discord.Cacheable before, + SocketMessage after, ISocketMessageChannel channel) { - /* - * TODO: - * Edited messages seem to retain their ID. Need to look into this. - * In any case, the new message must be stored in case of future edits. - * The change must be sent to the reporting channel (if one exists) as if it were - * a typical log entry (even though it's not). - */ - throw new NotImplementedException(); + if (after is SocketUserMessage afterMsg) + { + // We're not interested in all message updates, only those that leave a timestamp. + if (!afterMsg.EditedTimestamp.HasValue) return; + } + else return; // no after??? + + // Once an edited message is cached, the original message contents are discarded. + // This is the only time to report it. + await ProcessReportMessage(false, before.Id, channel, after.Content); + + await AddOrUpdateCacheItemAsync(after); } - private Task Client_MessageDeleted(Discord.Cacheable arg1, ISocketMessageChannel arg2) + private async Task Client_MessageDeleted( + Discord.Cacheable msg, ISocketMessageChannel channel) { - // TODO report message deletion, if reporting channel exists and message is in cache. - throw new NotImplementedException(); + await ProcessReportMessage(true, msg.Id, channel, null); } #endregion - #region Database manipulation + #region Reporting + + // Reports an edited or deleted message as if it were a log entry (even though it's not). + private async Task ProcessReportMessage( + bool isDelete, ulong messageId, ISocketMessageChannel ch, string editMsg) + { + var cht = ch as SocketTextChannel; + if (cht == null) + { + // TODO remove debug print + Console.WriteLine("Incoming message not of a text channel"); + return; + } + ulong guildId = cht.Guild.Id; + + // Check if enabled before doing anything else + var rptTarget = _outGetConfig(guildId) as ConfigItem.EntityName?; + if (!rptTarget.HasValue) return; + + // Regardless of delete or edit, it is necessary to get database information. + EntityCache.CacheUser ucd = null; + ulong userId; + string cacheMsg; + try + { + using (var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync()) + { + using (var c = db.CreateCommand()) + { + c.CommandText = "SELECT author_id, message FROM " + TableMessage + + " WHERE message_id = @MessageId"; + c.Parameters.Add("@MessageId", NpgsqlDbType.Bigint).Value = messageId; + c.Prepare(); + using (var r = await c.ExecuteReaderAsync()) + { + if (await r.ReadAsync()) + { + userId = unchecked((ulong)r.GetInt64(0)); + cacheMsg = r.GetString(1); + } + else + { + userId = 0; + cacheMsg = "*(Message not in cache.)*"; + } + } + } + } + if (userId != 0) ucd = await EntityCache.EntityCache.QueryAsync(guildId, userId); + } + catch (NpgsqlException ex) + { + await _outLog($"SQL error in {nameof(ProcessReportMessage)}: " + ex.Message); + cacheMsg = "**Database error. See log.**"; + } + + // Find target channel, prepare and send out message + var em = CreateReportEmbed(isDelete, ucd, messageId, ch, (cacheMsg, editMsg)); + var rptTargetChannel = _dClient.GetGuild(guildId)?.GetTextChannel(rptTarget.Value.Id.Value); + if (rptTargetChannel == null) + { + await _outLog("Target channel not found."); + // TODO make a more descriptive error message + return; + } + await rptTargetChannel.SendMessageAsync("", embed: em); + } + + const int ReportCutoffLength = 750; + const string ReportCutoffNotify = "**Message length too long; showing first {0} characters.**\n\n"; + private EmbedBuilder CreateReportEmbed( + bool isDelete, + EntityCache.CacheUser ucd, ulong messageId, ISocketMessageChannel chInfo, + (string, string) content) // tuple: Item1 = cached content. Item2 = after-edit message + { + string before = content.Item1; + string after = content.Item2; + if (content.Item1.Length > ReportCutoffLength) + { + before = string.Format(ReportCutoffNotify, ReportCutoffLength) + + content.Item1.Substring(ReportCutoffLength); + } + if (isDelete && content.Item2.Length > ReportCutoffLength) + { + after = string.Format(ReportCutoffNotify, ReportCutoffLength) + + content.Item2.Substring(ReportCutoffLength); + } + + // Note: Value for ucb is null if cached user could not be determined + var eb = new EmbedBuilder + { + Author = new EmbedAuthorBuilder() + { + IconUrl = ucd?.AvatarUrl + }, + Fields = new System.Collections.Generic.List(), + Footer = new EmbedFooterBuilder() + { + Text = (ucd == null ? "" : $"UID {ucd.UserId} - ") + $"MID {messageId}", + IconUrl = _dClient.CurrentUser.GetAvatarUrl() + }, + Timestamp = DateTimeOffset.Now + }; + + if (isDelete) + { + eb.Color = new Color(0x9b9b9b); + eb.Description = content.Item1; + eb.Author.Name = "Message deleted by " + + ucd == null ? "unknown user" : $"{ucd.Username}#{ucd.Discriminator}"; + } + else + { + eb.Color = new Color(8615955); + eb.Fields.Add(new EmbedFieldBuilder() + { + Name = "Before", + Value = before + }); + eb.Fields.Add(new EmbedFieldBuilder() + { + Name = "After", + Value = after + }); + } + + if (ucd != null) eb.Fields.Add(new EmbedFieldBuilder() + { + Name = "Username", + Value = $"<@!{ucd.UserId}>", + IsInline = true + }); + eb.Fields.Add(new EmbedFieldBuilder() + { + Name = "Channel", + Value = $"<#{chInfo.Id}>\n#{chInfo.Name}", + IsInline = true + }); + + return eb; + } + + #endregion + + #region Database storage/retrieval const string TableMessage = "cache_messages"; private void CreateCacheTables() @@ -65,20 +216,61 @@ namespace Noikoio.RegexBot.Module.ModLogs + "message_id bigint primary key, " + "author_id bigint not null, " + "guild_id bigint not null, " - + "channel_id bigint not null, " // channel cache later? something to think about... + + "channel_id bigint not null, " // TODO channel cache fk when that gets implemented + "created_ts timestamptz not null, " + "edited_ts timestamptz null, " + "message text not null, " + $"FOREIGN KEY (author_id, guild_id) references {EntityCache.SqlHelper.TableUser} (user_id, guild_id)" + ")"; - // TODO are more columns needed for edit info? c.ExecuteNonQuery(); } } } - #endregion - private async Task CacheMessage(SocketMessage msg) + private async Task AddOrUpdateCacheItemAsync(SocketMessage msg) + { + try + { + using (var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync()) + { + // No upsert. Delete, then add. + using (var t = db.BeginTransaction()) + { + using (var c = db.CreateCommand()) + { + c.CommandText = "DELETE FROM " + TableMessage + " WHERE message_id = @MessageId"; + c.Parameters.Add("@MessageId", NpgsqlDbType.Bigint).Value = msg.Id; + c.Prepare(); + await c.ExecuteNonQueryAsync(); + } + using (var c = db.CreateCommand()) + { + c.CommandText = "INSERT INTO " + TableMessage + + " (message_id, author_id, guild_id, channel_id, created_ts, edited_ts, message) VALUES " + + "(@MessageId, @UserId, @GuildId, @ChannelId, @Date, @Edit, @Message)"; + c.Parameters.Add("@MessageId", NpgsqlDbType.Bigint).Value = msg.Id; + c.Parameters.Add("@UserId", NpgsqlDbType.Bigint).Value = msg.Author.Id; + c.Parameters.Add("@GuildId", NpgsqlDbType.Bigint).Value = ((SocketGuildUser)msg.Author).Guild.Id; + c.Parameters.Add("@ChannelId", NpgsqlDbType.Bigint).Value = msg.Channel.Id; + c.Parameters.Add("@Date", NpgsqlDbType.TimestampTZ).Value = msg.Timestamp; + if (msg.EditedTimestamp.HasValue) + c.Parameters.Add("@Edit", NpgsqlDbType.TimestampTZ).Value = msg.EditedTimestamp.Value; + else + c.Parameters.Add("@Edit", NpgsqlDbType.TimestampTZ).Value = DBNull.Value; + c.Parameters.Add("@Message", NpgsqlDbType.Text).Value = msg.Content; + c.Prepare(); + await c.ExecuteNonQueryAsync(); + } + } + } + } + catch (NpgsqlException ex) + { + await _outLog($"SQL error in {nameof(AddOrUpdateCacheItemAsync)}: " + ex.Message); + } + } + + private async Task GetCachedMessageAsync(ulong messageId) { try { @@ -86,24 +278,26 @@ namespace Noikoio.RegexBot.Module.ModLogs { using (var c = db.CreateCommand()) { - c.CommandText = "INSERT INTO " + TableMessage - + " (message_id, author_id, guild_id, channel_id, created_ts, message) VALUES " - + "(@MessageId, @UserId, @GuildId, @ChannelId, @Date, @Message)"; - c.Parameters.Add("@MessageId", NpgsqlDbType.Bigint).Value = msg.Id; - c.Parameters.Add("@UserId", NpgsqlDbType.Bigint).Value = msg.Author.Id; - c.Parameters.Add("@GuildId", NpgsqlDbType.Bigint).Value = ((SocketGuildUser)msg.Author).Guild.Id; - c.Parameters.Add("@ChannelId", NpgsqlDbType.Bigint).Value = msg.Channel.Id; - c.Parameters.Add("@Date", NpgsqlDbType.TimestampTZ).Value = msg.Timestamp; - c.Parameters.Add("@Message", NpgsqlDbType.Text).Value = msg.Content; + c.CommandText = "SELECT message FROM " + TableMessage + + " WHERE message_id = @MessageId"; + c.Parameters.Add("@MessageId", NpgsqlDbType.Bigint).Value = messageId; c.Prepare(); - await c.ExecuteNonQueryAsync(); + using (var r = await c.ExecuteReaderAsync()) + { + if (await r.ReadAsync()) + return r.GetString(0); + else + return null; + } } } } catch (NpgsqlException ex) { - await _outLog($"SQL error in {nameof(CacheMessage)}: " + ex.Message); + await _outLog($"SQL error in {nameof(GetCachedMessageAsync)}: " + ex.Message); + return null; } } + #endregion } }