using Catalogger.Backend.Cache; using Catalogger.Backend.Database; using Catalogger.Backend.Database.Queries; using Catalogger.Backend.Extensions; using Catalogger.Backend.Services; using Remora.Discord.API; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Gateway.Events; using Remora.Discord.API.Objects; using Remora.Discord.Extensions.Embeds; using Remora.Discord.Gateway.Responders; using Remora.Results; namespace Catalogger.Backend.Bot.Responders; public class MessageUpdateResponder( ILogger logger, DatabaseContext db, ChannelCacheService channelCache, UserCacheService userCache, MessageRepository messageRepository, WebhookExecutorService webhookExecutor, PluralkitApiService pluralkitApi) : IResponder { private readonly ILogger _logger = logger.ForContext(); public async Task RespondAsync(IMessageUpdate evt, CancellationToken ct = default) { // Discord only *very* recently changed message update events to have all fields, // so we convert the event to a MessageCreate to avoid having to unwrap every single field var msg = ConvertToMessageCreate(evt); if (!msg.GuildID.IsDefined()) { _logger.Debug("Received message create event for message {MessageId} despite it not being in a guild", msg.ID); return Result.Success; } _logger.Debug("Guild is {GuildId}", msg.GuildID.Value); var guildConfig = await db.GetGuildAsync(msg.GuildID.Value, ct); if (await messageRepository.IsMessageIgnoredAsync(msg.ID.Value, ct)) { _logger.Debug("Message {MessageId} should be ignored", msg.ID); return Result.Success; } var logChannel = webhookExecutor.GetLogChannel(guildConfig, LogChannelType.MessageUpdate, msg.ChannelID, msg.Author.ID.Value); if (logChannel == null) return Result.Success; try { var oldMessage = await messageRepository.GetMessageAsync(msg.ID.Value, ct); if (oldMessage == null) { logger.Debug("Message {Id} was edited and should be logged but is not in the database", msg.ID); return Result.Success; } if (oldMessage.Content == msg.Content) return Result.Success; var user = msg.Author; if (msg.Author.ID != oldMessage.UserId) { var systemAccount = await userCache.GetUserAsync(DiscordSnowflake.New(oldMessage.UserId)); if (systemAccount != null) user = systemAccount; } var embedBuilder = new EmbedBuilder() .WithAuthor(user.Tag(), null, user.AvatarUrl()) .WithTitle("Message edited") .WithDescription(oldMessage.Content) .WithColour(DiscordUtils.Purple) .WithFooter($"ID: {msg.ID}") .WithTimestamp(msg.ID.Timestamp); var fields = ChunksUpTo(msg.Content, 1000) .Select((s, i) => new EmbedField($"New content{(i != 0 ? " (cont.)" : "")}", s, false)) .ToList(); embedBuilder.SetFields(fields); string channelMention; if (!channelCache.TryGet(msg.ChannelID, out var channel)) channelMention = $"<#{msg.ChannelID}>"; else if (channel.Type is ChannelType.AnnouncementThread or ChannelType.PrivateThread or ChannelType.PublicThread) channelMention = $"<#{channel.ParentID.Value}>\nID: {channel.ParentID.Value}\n\nThread: {channel.Name} (<#{channel.ID}>)"; else channelMention = $"<#{channel.ID}>\nID: {channel.ID}"; embedBuilder.AddField("Channel", channelMention, true); embedBuilder.AddField("Sender", $"<@{user.ID}>\n{user.Tag()}\nID: {user.ID}", true); if (oldMessage is { System: not null, Member: not null }) { embedBuilder.WithTitle($"Message by {msg.Author.Username} edited"); embedBuilder.AddField("\u200b", "**PluralKit information**", false); embedBuilder.AddField("System ID", oldMessage.System, true); embedBuilder.AddField("Member ID", oldMessage.Member, true); } embedBuilder.AddField("Link", $"https://discord.com/channels/{msg.GuildID}/{msg.ChannelID}/{msg.ID}"); await webhookExecutor.QueueLogAsync(logChannel.Value, embedBuilder.Build().GetOrThrow()); return Result.Success; } finally { if (!await messageRepository.UpdateMessageAsync(msg, ct) && msg.ApplicationID.Is(DiscordUtils.PkUserId)) { _logger.Debug( "Message {MessageId} wasn't stored yet and was proxied by PluralKit, fetching proxy information from its API", msg.ID); var pkMsg = await pluralkitApi.GetPluralKitMessageAsync(msg.ID.Value, ct); if (pkMsg != null) await messageRepository.SetProxiedMessageDataAsync(msg.ID.Value, pkMsg.Original, pkMsg.Sender, pkMsg.System?.Id, pkMsg.Member?.Id); } } } private static MessageCreate ConvertToMessageCreate(IMessageUpdate evt) => new(evt.GuildID, evt.Member, evt.Mentions.GetOrThrow(), evt.ID.GetOrThrow(), evt.ChannelID.GetOrThrow(), evt.Author.GetOrThrow(), evt.Content.GetOrThrow(), evt.Timestamp.GetOrThrow(), evt.EditedTimestamp.GetOrThrow(), IsTTS: false, evt.MentionsEveryone.GetOrThrow(), evt.MentionedRoles.GetOrThrow(), evt.MentionedChannels, evt.Attachments.GetOrThrow(), evt.Embeds.GetOrThrow(), evt.Reactions, evt.Nonce, evt.IsPinned.GetOrThrow(), evt.WebhookID, evt.Type.GetOrThrow(), evt.Activity, evt.Application, evt.ApplicationID, evt.MessageReference, evt.Flags, evt.ReferencedMessage, evt.Interaction, evt.Thread, evt.Components, evt.StickerItems, evt.Position, evt.Resolved, evt.InteractionMetadata, evt.Poll); private static IEnumerable ChunksUpTo(string str, int maxChunkSize) { for (var i = 0; i < str.Length; i += maxChunkSize) yield return str.Substring(i, Math.Min(maxChunkSize, str.Length - i)); } }