// 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 System.Text.RegularExpressions; using Catalogger.Backend.Cache.InMemoryCache; using Catalogger.Backend.Database.Repositories; using Catalogger.Backend.Extensions; using Catalogger.Backend.Services; using Humanizer; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.Gateway.Responders; using Remora.Results; namespace Catalogger.Backend.Bot.Responders.Messages; public class MessageCreateResponder( ILogger logger, Config config, GuildRepository guildRepository, MessageRepository messageRepository, UserCache userCache, PkMessageHandler pkMessageHandler ) : IResponder { private readonly ILogger _logger = logger.ForContext(); public async Task RespondAsync(IMessageCreate msg, CancellationToken ct = default) { using var __ = LogUtils.Enrich(msg); userCache.UpdateUser(msg.Author); CataloggerMetrics.MessagesReceived.Inc(); 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 guild = await guildRepository.GetAsync(msg.GuildID); // The guild needs to have enabled at least one of the message logging events, // and the channel must not be ignored, to store the message. if ( guild.IsMessageIgnored( msg.ChannelID, msg.Author.ID, msg.Member.OrDefault()?.Roles.OrDefault() ) ) { await messageRepository.IgnoreMessageAsync(msg.ID.Value); return Result.Success; } if (msg.Author.ID == DiscordUtils.PkUserId) _ = pkMessageHandler.HandlePkMessageAsync(msg); if (msg.ApplicationID.Is(DiscordUtils.PkUserId)) _ = pkMessageHandler.HandleProxiedMessageAsync(msg.ID.Value); else if (msg.ApplicationID.HasValue && msg.ApplicationID.Is(config.Discord.ApplicationId)) { await messageRepository.IgnoreMessageAsync(msg.ID.Value); return Result.Success; } await messageRepository.SaveMessageAsync(msg, ct); return Result.Success; } } public partial class PkMessageHandler(ILogger logger, IServiceProvider services) { private readonly ILogger _logger = logger.ForContext(); [GeneratedRegex( @"^System ID: (\w{5,6}) \| Member ID: (\w{5,6}) \| Sender: .+ \((\d+)\) \| Message ID: (\d+) \| Original Message ID: (\d+)$" )] private static partial Regex FooterRegex(); [GeneratedRegex(@"^https:\/\/discord.com\/channels\/\d+\/(\d+)\/\d+$")] private static partial Regex LinkRegex(); public async Task HandlePkMessageAsync(IMessageCreate msg) { await Task.Delay(500.Milliseconds()); // Check if the content matches a Discord link--if not, it's not a log message (we already check if this is a PluralKit message earlier) if (!LinkRegex().IsMatch(msg.Content)) return; // The first (only, I think always?) embed's footer must match the expected format var firstEmbed = msg.Embeds.FirstOrDefault(); if ( firstEmbed == null || !firstEmbed.Footer.TryGet(out var footer) || !FooterRegex().IsMatch(footer.Text) ) return; var match = FooterRegex().Match(footer.Text); if (!ulong.TryParse(match.Groups[3].Value, out var authorId)) { _logger.Debug( "Author ID in PluralKit log {LogMessageId} was not a valid snowflake", msg.ID ); return; } if (!ulong.TryParse(match.Groups[4].Value, out var msgId)) { _logger.Debug( "Message ID in PluralKit log {LogMessageId} was not a valid snowflake", msg.ID ); return; } if (!ulong.TryParse(match.Groups[5].Value, out var originalId)) { _logger.Debug( "Original ID in PluralKit log {LogMessageId} was not a valid snowflake", msg.ID ); return; } await using var scope = services.CreateAsyncScope(); await using var messageRepository = scope.ServiceProvider.GetRequiredService(); _logger.Debug( "Setting proxy data for {MessageId} and ignoring {OriginalId}", msgId, originalId ); await messageRepository.SetProxiedMessageDataAsync( msgId, originalId, authorId, systemId: match.Groups[1].Value, memberId: match.Groups[2].Value ); await messageRepository.IgnoreMessageAsync(originalId); } public async Task HandleProxiedMessageAsync(ulong msgId) { await Task.Delay(3.Seconds()); await using var scope = services.CreateAsyncScope(); await using var messageRepository = scope.ServiceProvider.GetRequiredService(); var pluralkitApi = scope.ServiceProvider.GetRequiredService(); var (isStored, hasProxyInfo) = await messageRepository.HasProxyInfoAsync(msgId); if (!isStored) { _logger.Debug("Message with ID {MessageId} is not stored in the database", msgId); return; } if (hasProxyInfo) return; var pkMessage = await pluralkitApi.GetPluralKitMessageAsync(msgId); if (pkMessage == null) { _logger.Debug( "Message with ID {MessageId} was proxied by PluralKit, but API returned 404", msgId ); return; } _logger.Debug( "Setting proxy data for {MessageId} and ignoring {OriginalId}", msgId, pkMessage.Original ); await messageRepository.SetProxiedMessageDataAsync( msgId, pkMessage.Original, pkMessage.Sender, pkMessage.System?.Id, pkMessage.Member?.Id ); await messageRepository.IgnoreMessageAsync(pkMessage.Original); } }