// 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; using Catalogger.Backend.Database.Models; using Catalogger.Backend.Database.Queries; 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, DatabaseContext db, MessageRepository messageRepository, UserCache userCache, PkMessageHandler pkMessageHandler ) : IResponder { private readonly ILogger _logger = logger.ForContext(); public async Task RespondAsync(IMessageCreate msg, CancellationToken ct = default) { 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 db.GetGuildAsync(msg.GuildID, ct); // 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)) { db.IgnoredMessages.Add(new IgnoredMessage(msg.ID.ToUlong())); await db.SaveChangesAsync(ct); 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)) { db.IgnoredMessages.Add(new IgnoredMessage(msg.ID.Value)); await db.SaveChangesAsync(ct); 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) { _logger.Debug("Received PluralKit message"); await Task.Delay(500.Milliseconds()); _logger.Debug("Starting handling PluralKit message"); // 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)) { _logger.Debug("PluralKit message is not a log message because content is not a link"); 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) ) { _logger.Debug( "PK message is not a log message because there is no first embed or its footer doesn't match the regex" ); 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 db = scope.ServiceProvider.GetRequiredService(); var messageRepository = scope.ServiceProvider.GetRequiredService(); await messageRepository.SetProxiedMessageDataAsync( msgId, originalId, authorId, systemId: match.Groups[1].Value, memberId: match.Groups[2].Value ); db.IgnoredMessages.Add(new IgnoredMessage(originalId)); await db.SaveChangesAsync(); } public async Task HandleProxiedMessageAsync(ulong msgId) { await Task.Delay(3.Seconds()); await using var scope = services.CreateAsyncScope(); await using var db = scope.ServiceProvider.GetRequiredService(); 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; } await messageRepository.SetProxiedMessageDataAsync( msgId, pkMessage.Original, pkMessage.Sender, pkMessage.System?.Id, pkMessage.Member?.Id ); db.IgnoredMessages.Add(new IgnoredMessage(pkMessage.Original)); await db.SaveChangesAsync(); } }