// Copyright (C) 2021-present sam (starshines.gay) // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . using Catalogger.Backend.Cache.InMemoryCache; using Catalogger.Backend.Database.Repositories; 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.Messages; public class MessageUpdateResponder( ILogger logger, GuildRepository guildRepository, ChannelCache channelCache, UserCache 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; } var guildConfig = await guildRepository.GetAsync(msg.GuildID); if (await messageRepository.IsMessageIgnoredAsync(msg.ID.Value)) return Result.Success; try { var logChannel = webhookExecutor.GetLogChannel( guildConfig, LogChannelType.MessageUpdate, msg.ChannelID, msg.Author.ID.Value ); if (logChannel == null) return Result.Success; var oldMessage = await messageRepository.GetMessageAsync(msg.ID.Value, ct); if (oldMessage == null) { _logger.Debug( "Message {MessageId} was edited and should be logged but is not in the database", msg.ID ); return Result.Success; } if ( oldMessage.Content == msg.Content || (oldMessage.Content == "None" && string.IsNullOrEmpty(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}" ); webhookExecutor.QueueLog(logChannel.Value, embedBuilder.Build().GetOrThrow()); return Result.Success; } finally { // Messages should be *saved* if any of the message events are enabled for this channel, but should only // be *logged* if the MessageUpdate event is enabled, so we check if we should save here. // You also can't return early in `finally` blocks, so this has to be nested :( if ( webhookExecutor.GetLogChannel( guildConfig, LogChannelType.MessageUpdate, msg.ChannelID, msg.Author.ID.Value ) != null || webhookExecutor.GetLogChannel( guildConfig, LogChannelType.MessageDelete, msg.ChannelID, msg.Author.ID.Value ) != null || webhookExecutor.GetLogChannel( guildConfig, LogChannelType.MessageDeleteBulk, msg.ChannelID, msg.Author.ID.Value ) != null ) { if ( !await messageRepository.SaveMessageAsync(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)); } }