// 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.Json; using Catalogger.Backend.Extensions; using Dapper; using Remora.Discord.API; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Rest.Core; namespace Catalogger.Backend.Database.Repositories; public class MessageRepository( ILogger logger, DatabaseConnection conn, IEncryptionService encryptionService ) : IDisposable, IAsyncDisposable { private readonly ILogger _logger = logger.ForContext(); public async Task GetMessageAsync(ulong id, CancellationToken ct = default) { _logger.Debug("Retrieving message {MessageId}", id); var dbMsg = await conn.QueryFirstOrDefaultAsync( "select * from messages where id = @Id", new { Id = id } ); if (dbMsg == null) return null; return new Message( dbMsg.Id, dbMsg.OriginalId, dbMsg.UserId, dbMsg.ChannelId, dbMsg.GuildId, dbMsg.Member, dbMsg.System, Username: await Task.Run(() => encryptionService.Decrypt(dbMsg.Username), ct), Content: await Task.Run(() => encryptionService.Decrypt(dbMsg.Content), ct), Metadata: dbMsg.Metadata != null ? JsonSerializer.Deserialize( await Task.Run(() => encryptionService.Decrypt(dbMsg.Metadata), ct) ) : null, dbMsg.AttachmentSize ); } /// /// Adds a new message. If the message is already in the database, updates the existing message instead. /// public async Task SaveMessageAsync(IMessageCreate msg, CancellationToken ct = default) { var content = await Task.Run( () => encryptionService.Encrypt( string.IsNullOrWhiteSpace(msg.Content) ? "None" : msg.Content ), ct ); var username = await Task.Run(() => encryptionService.Encrypt(msg.Author.Tag()), ct); var metadata = await Task.Run( () => encryptionService.Encrypt( JsonSerializer.Serialize( new Metadata( IsWebhook: msg.WebhookID.HasValue, msg.Attachments.Select(a => new Attachment( a.Filename, a.Size, a.ContentType.Value )) ) ) ), ct ); // MessageUpdateResponder wants to know whether the message already existed, so query this *before* inserting. var exists = await conn.ExecuteScalarAsync( "select exists(select id from messages where id = @Id)", new { Id = msg.ID.Value } ); await conn.ExecuteAsync( """ insert into messages (id, user_id, channel_id, guild_id, username, content, metadata, attachment_size) values (@Id, @UserId, @ChannelId, @GuildId, @Username, @Content, @Metadata, @AttachmentSize) on conflict (id) do update set username = @Username, content = @Content, metadata = @Metadata """, new { Id = msg.ID.Value, UserId = msg.Author.ID.Value, ChannelId = msg.ChannelID.Value, GuildId = msg.GuildID.Map(s => s.Value).OrDefault(), Content = content, Username = username, Metadata = metadata, AttachmentSize = msg.Attachments.Select(a => a.Size).Sum(), } ); return exists; } public async Task<(bool IsStored, bool HasProxyInfo)> HasProxyInfoAsync(ulong id) { _logger.Debug("Checking if message {MessageId} has proxy information", id); var msg = await conn.QueryFirstOrDefaultAsync<(ulong Id, ulong OriginalId)>( "select id, original_id from messages where id = @Id", new { Id = id } ); return (msg.Id != 0, msg.OriginalId != 0); } /// /// Updates a stored message with PluralKit information. /// /// True if the message exists and was updated, false if it doesn't exist. public async Task SetProxiedMessageDataAsync( ulong id, ulong originalId, ulong authorId, string? systemId, string? memberId ) { _logger.Debug("Setting proxy information for message {MessageId}", id); var updatedCount = await conn.ExecuteAsync( "update messages set original_id = @OriginalId, user_id = @AuthorId, system = @SystemId, member = @MemberId where id = @Id", new { Id = id, OriginalId = originalId, AuthorId = authorId, SystemId = systemId, MemberId = memberId, } ); if (updatedCount == 0) { _logger.Debug("Message {MessageId} not found, can't set proxy data for it", id); return false; } return true; } public async Task IsMessageIgnoredAsync(ulong id) => await conn.ExecuteScalarAsync( "select exists(select id from ignored_messages where id = @Id)", new { Id = id } ); public const int MaxMessageAgeDays = 15; public async Task<(int Messages, int IgnoredMessages)> DeleteExpiredMessagesAsync() { var cutoff = DateTimeOffset.UtcNow - TimeSpan.FromDays(MaxMessageAgeDays); var cutoffId = Snowflake.CreateTimestampSnowflake(cutoff, Constants.DiscordEpoch).Value; var msgCount = await conn.ExecuteAsync( "delete from messages where id < @Cutoff", new { Cutoff = cutoffId } ); var ignoredMsgCount = await conn.ExecuteAsync( "delete from ignored_messages where id < @Cutoff", new { Cutoff = cutoffId } ); return (msgCount, ignoredMsgCount); } public async Task IgnoreMessageAsync(ulong id) => await conn.ExecuteAsync( "insert into ignored_messages (id) values (@Id) on conflict do nothing", new { Id = id } ); public record Message( ulong Id, ulong? OriginalId, ulong UserId, ulong ChannelId, ulong GuildId, string? Member, string? System, string Username, string Content, Metadata? Metadata, int AttachmentSize ); public record Metadata(bool IsWebhook, IEnumerable Attachments); public record Attachment(string Filename, int Size, string ContentType); public void Dispose() { conn.Dispose(); GC.SuppressFinalize(this); } public async ValueTask DisposeAsync() { await conn.DisposeAsync(); GC.SuppressFinalize(this); } }