// 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; using Catalogger.Backend.Database.Queries; using Catalogger.Backend.Extensions; using Catalogger.Backend.Services; using Humanizer; using NodaTime; using Remora.Discord.API; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Objects; using Remora.Discord.Extensions.Embeds; using Remora.Discord.Gateway.Responders; using Remora.Rest.Core; using Remora.Results; namespace Catalogger.Backend.Bot.Responders.Messages; public class MessageDeleteResponder( ILogger logger, DatabaseContext db, MessageRepository messageRepository, WebhookExecutorService webhookExecutor, ChannelCache channelCache, UserCache userCache, IClock clock, PluralkitApiService pluralkitApi ) : IResponder { private readonly ILogger _logger = logger.ForContext(); private static bool MaybePkProxyTrigger(Snowflake id) => id.Timestamp > DateTimeOffset.Now - 1.Minutes(); public async Task RespondAsync(IMessageDelete evt, CancellationToken ct = default) { if (!evt.GuildID.IsDefined()) return Result.Success; if (MaybePkProxyTrigger(evt.ID)) { _logger.Debug( "Deleted message {MessageId} is less than 1 minute old, delaying 5 seconds to give PK time to catch up", evt.ID ); await Task.Delay(5.Seconds(), ct); } if (await messageRepository.IsMessageIgnoredAsync(evt.ID.Value, ct)) return Result.Success; var guild = await db.GetGuildAsync(evt.GuildID, ct); if (guild.IsMessageIgnored(evt.ChannelID, evt.ID)) return Result.Success; var logChannel = webhookExecutor.GetLogChannel( guild, LogChannelType.MessageDelete, evt.ChannelID ); var msg = await messageRepository.GetMessageAsync(evt.ID.Value, ct); // Sometimes a message that *should* be logged isn't stored in the database, notify the user of that if (msg == null) { if (logChannel == null) return Result.Success; webhookExecutor.QueueLog( logChannel.Value, new Embed( Title: "Message deleted", Description: $"A message not found in the database was deleted in <#{evt.ChannelID}> ({evt.ChannelID}).", Footer: new EmbedFooter(Text: $"ID: {evt.ID}"), Timestamp: clock.GetCurrentInstant().ToDateTimeOffset() ) ); return Result.Success; } // Check if the message is an edit trigger message. // If it is, the API will return a valid message for its ID, but the ID won't match either `Id` or `Original`. // (We also won't have any system/member information stored for it) if (msg is { System: null, Member: null } && MaybePkProxyTrigger(evt.ID)) { var pkMsg = await pluralkitApi.GetPluralKitMessageAsync(evt.ID.Value, ct); if (pkMsg != null && pkMsg.Id != evt.ID.Value && pkMsg.Original != evt.ID.Value) { _logger.Debug( "Deleted message {MessageId} is a `pk;edit` message, ignoring", evt.ID ); return Result.Success; } } logChannel = webhookExecutor.GetLogChannel( guild, LogChannelType.MessageDelete, evt.ChannelID, msg.UserId ); if (logChannel == null) return Result.Success; var user = await userCache.GetUserAsync(DiscordSnowflake.New(msg.UserId)); var builder = new EmbedBuilder() .WithTitle("Message deleted") .WithDescription(msg.Content) .WithColour(DiscordUtils.Red) .WithFooter($"ID: {msg.Id}") .WithTimestamp(evt.ID); if (user != null) builder.WithAuthor(user.Tag(), url: null, iconUrl: user.AvatarUrl()); if (msg.Member != null) builder.WithTitle($"Message by {msg.Username} deleted"); string channelMention; if (!channelCache.TryGet(evt.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}"; var userMention = user != null ? $"<@{user.ID}>\n{user.Tag()}\nID: {user.ID}" : $"<@{msg.UserId}>\nID: {msg.UserId}"; builder.AddField("Channel", channelMention, true); builder.AddField( msg.System != null ? "Linked Discord account" : "Sender", userMention, true ); if (msg is { System: not null, Member: not null }) { builder.AddField("\u200b", "**PluralKit information**", false); builder.AddField("System ID", msg.System, true); builder.AddField("Member ID", msg.Member, true); } if (msg.Metadata != null) { var attachmentInfo = string.Join( "\n", msg.Metadata.Attachments.Select(a => $"{a.Filename} ({a.ContentType}, {a.Size.Bytes().Humanize()})" ) ); if (!string.IsNullOrWhiteSpace(attachmentInfo)) builder.AddField("Attachments", attachmentInfo, false); } webhookExecutor.QueueLog(logChannel.Value, builder.Build().GetOrThrow()); return Result.Success; } }