From e6d68338db2d6737e819025661d72af02032b85f Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 5 Nov 2024 22:22:12 +0100 Subject: [PATCH] feat: store timeouts in database and log them ending we have to do this because discord doesn't notify us when a timeout ends naturally, only when a moderator removes it early. --- .../Members/GuildMemberUpdateResponder.cs | 29 +++++ .../Migrations/003_store_timeouts.down.sql | 1 + .../Migrations/003_store_timeouts.up.sql | 9 ++ .../Database/Models/DiscordTimeout.cs | 12 ++ .../Repositories/TimeoutRepository.cs | 72 +++++++++++ .../Extensions/StartupExtensions.cs | 8 +- Catalogger.Backend/Services/TimeoutService.cs | 119 ++++++++++++++++++ 7 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 Catalogger.Backend/Database/Migrations/003_store_timeouts.down.sql create mode 100644 Catalogger.Backend/Database/Migrations/003_store_timeouts.up.sql create mode 100644 Catalogger.Backend/Database/Models/DiscordTimeout.cs create mode 100644 Catalogger.Backend/Database/Repositories/TimeoutRepository.cs create mode 100644 Catalogger.Backend/Services/TimeoutService.cs diff --git a/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs b/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs index 2440b83..6cdfe86 100644 --- a/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs @@ -15,9 +15,11 @@ using Catalogger.Backend.Cache; using Catalogger.Backend.Cache.InMemoryCache; +using Catalogger.Backend.Database.Models; using Catalogger.Backend.Database.Repositories; using Catalogger.Backend.Extensions; using Catalogger.Backend.Services; +using NodaTime.Extensions; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.Extensions.Embeds; @@ -30,6 +32,8 @@ namespace Catalogger.Backend.Bot.Responders.Members; public class GuildMemberUpdateResponder( ILogger logger, GuildRepository guildRepository, + TimeoutRepository timeoutRepository, + TimeoutService timeoutService, UserCache userCache, RoleCache roleCache, IMemberCache memberCache, @@ -245,11 +249,15 @@ public class GuildMemberUpdateResponder( var moderator = await userCache.TryFormatUserAsync(actionData.ModeratorId); embed.AddField("Responsible moderator", moderator); embed.AddField("Reason", actionData.Reason ?? "No reason given"); + + await UpdateTimeoutDatabaseAsync(member, actionData.ModeratorId); } else { embed.AddField("Responsible moderator", "*(unknown)*"); embed.AddField("Reason", "*(unknown)*"); + + await UpdateTimeoutDatabaseAsync(member, null); } var guildConfig = await guildRepository.GetAsync(member.GuildID); @@ -261,6 +269,27 @@ public class GuildMemberUpdateResponder( return Result.Success; } + private async Task UpdateTimeoutDatabaseAsync(IGuildMemberUpdate member, Snowflake? moderatorId) + { + var until = member.CommunicationDisabledUntil.OrDefault(); + if (until == null) + { + // timeout was ended early, delete database entry + var oldTimeout = await timeoutRepository.RemoveAsync(member.GuildID, member.User.ID); + if (oldTimeout != null) + timeoutService.RemoveTimer(oldTimeout.Id); + return; + } + + var dbTimeout = await timeoutRepository.SetAsync( + member.GuildID, + member.User.ID, + until.Value.ToInstant(), + moderatorId + ); + timeoutService.AddTimer(dbTimeout); + } + private async Task HandleRoleUpdateAsync( IGuildMemberUpdate member, IReadOnlyList oldRoles, diff --git a/Catalogger.Backend/Database/Migrations/003_store_timeouts.down.sql b/Catalogger.Backend/Database/Migrations/003_store_timeouts.down.sql new file mode 100644 index 0000000..d5f53c0 --- /dev/null +++ b/Catalogger.Backend/Database/Migrations/003_store_timeouts.down.sql @@ -0,0 +1 @@ +drop table timeouts; \ No newline at end of file diff --git a/Catalogger.Backend/Database/Migrations/003_store_timeouts.up.sql b/Catalogger.Backend/Database/Migrations/003_store_timeouts.up.sql new file mode 100644 index 0000000..41b4dd5 --- /dev/null +++ b/Catalogger.Backend/Database/Migrations/003_store_timeouts.up.sql @@ -0,0 +1,9 @@ +create table timeouts ( + id integer generated by default as identity primary key, + user_id bigint not null, + guild_id bigint not null, + moderator_id bigint, + until timestamptz not null +); + +create unique index ix_timeouts_user_guild on timeouts (user_id, guild_id); diff --git a/Catalogger.Backend/Database/Models/DiscordTimeout.cs b/Catalogger.Backend/Database/Models/DiscordTimeout.cs new file mode 100644 index 0000000..21ed5c5 --- /dev/null +++ b/Catalogger.Backend/Database/Models/DiscordTimeout.cs @@ -0,0 +1,12 @@ +using NodaTime; + +namespace Catalogger.Backend.Database.Models; + +public class DiscordTimeout +{ + public int Id { get; init; } + public ulong UserId { get; init; } + public ulong GuildId { get; init; } + public ulong? ModeratorId { get; init; } + public Instant Until { get; init; } +} diff --git a/Catalogger.Backend/Database/Repositories/TimeoutRepository.cs b/Catalogger.Backend/Database/Repositories/TimeoutRepository.cs new file mode 100644 index 0000000..c9c7ec5 --- /dev/null +++ b/Catalogger.Backend/Database/Repositories/TimeoutRepository.cs @@ -0,0 +1,72 @@ +using Catalogger.Backend.Database.Models; +using Dapper; +using NodaTime; +using Remora.Rest.Core; + +namespace Catalogger.Backend.Database.Repositories; + +public class TimeoutRepository(DatabaseConnection conn) : IDisposable, IAsyncDisposable +{ + public async Task GetAsync(int id) => + await conn.QueryFirstOrDefaultAsync( + "select * from timeouts where id = @id", + new { id } + ); + + public async Task GetAsync(Snowflake guildId, Snowflake userId) => + await conn.QueryFirstOrDefaultAsync( + "select * from timeouts where guild_id = @GuildId and user_id = @UserId", + new { GuildId = guildId.Value, UserId = userId.Value } + ); + + public async Task> GetAllAsync() => + (await conn.QueryAsync("select * from timeouts order by id")).ToList(); + + public async Task SetAsync( + Snowflake guildId, + Snowflake userId, + Instant until, + Snowflake? moderatorId + ) => + await conn.QueryFirstAsync( + """ + insert into timeouts (user_id, guild_id, moderator_id, until) + values (@UserId, @GuildId, @ModeratorId, @Until) + on conflict (user_id, guild_id) do update + set moderator_id = @ModeratorId, + until = @Until + returning * + """, + new + { + UserId = userId.Value, + GuildId = guildId.Value, + ModeratorId = moderatorId?.Value, + Until = until, + } + ); + + public async Task RemoveAsync(int id) => + await conn.QueryFirstOrDefaultAsync( + "delete from timeouts where id = @id returning *", + new { id } + ); + + public async Task RemoveAsync(Snowflake guildId, Snowflake userId) => + await conn.QueryFirstOrDefaultAsync( + "delete from timeouts where guild_id = @GuildId and user_id = @UserId returning *", + new { GuildId = guildId.Value, UserId = userId.Value } + ); + + public void Dispose() + { + conn.Dispose(); + GC.SuppressFinalize(this); + } + + public async ValueTask DisposeAsync() + { + await conn.DisposeAsync(); + GC.SuppressFinalize(this); + } +} diff --git a/Catalogger.Backend/Extensions/StartupExtensions.cs b/Catalogger.Backend/Extensions/StartupExtensions.cs index a84e232..aef61b0 100644 --- a/Catalogger.Backend/Extensions/StartupExtensions.cs +++ b/Catalogger.Backend/Extensions/StartupExtensions.cs @@ -108,6 +108,7 @@ public static class StartupExtensions .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddSingleton() .AddSingleton() .AddSingleton() @@ -117,12 +118,13 @@ public static class StartupExtensions .AddSingleton() .AddSingleton() .AddScoped() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(InMemoryDataService.Instance) - .AddSingleton() .AddTransient() + .AddSingleton() // Background services // GuildFetchService is added as a separate singleton as it's also injected into other services. .AddHostedService(serviceProvider => @@ -207,6 +209,7 @@ public static class StartupExtensions var config = scope.ServiceProvider.GetRequiredService(); var slashService = scope.ServiceProvider.GetRequiredService(); + var timeoutService = scope.ServiceProvider.GetRequiredService(); if (config.Discord.TestMode) logger.Warning( @@ -249,6 +252,9 @@ public static class StartupExtensions logger.Information( "Not syncing slash commands, Discord.SyncCommands is false or unset" ); + + // Initialize the timeout service by loading all the timeouts currently in the database. + await timeoutService.InitializeAsync(); } public static void MaybeAddDashboard(this WebApplication app) diff --git a/Catalogger.Backend/Services/TimeoutService.cs b/Catalogger.Backend/Services/TimeoutService.cs new file mode 100644 index 0000000..6615f43 --- /dev/null +++ b/Catalogger.Backend/Services/TimeoutService.cs @@ -0,0 +1,119 @@ +using System.Collections.Concurrent; +using Catalogger.Backend.Bot; +using Catalogger.Backend.Cache.InMemoryCache; +using Catalogger.Backend.Database.Models; +using Catalogger.Backend.Database.Repositories; +using Catalogger.Backend.Extensions; +using Remora.Discord.API; +using Remora.Discord.Extensions.Embeds; +using Remora.Rest.Core; + +namespace Catalogger.Backend.Services; + +public class TimeoutService( + IServiceProvider serviceProvider, + ILogger logger, + WebhookExecutorService webhookExecutor, + UserCache userCache +) +{ + private readonly ILogger _logger = logger.ForContext(); + private readonly ConcurrentDictionary _timers = new(); + + public async Task InitializeAsync() + { + _logger.Information("Populating timeout service with existing database timeouts"); + + await using var scope = serviceProvider.CreateAsyncScope(); + var timeoutRepository = scope.ServiceProvider.GetRequiredService(); + + var timeouts = await timeoutRepository.GetAllAsync(); + foreach (var timeout in timeouts) + AddTimer(timeout); + } + + public void AddTimer(DiscordTimeout timeout) + { + _logger.Debug("Adding timeout {TimeoutId} to queue", timeout.Id); + + RemoveTimer(timeout.Id); + _timers[timeout.Id] = new Timer( + _ => + { + var __ = SendTimeoutLogAsync(timeout.Id); + }, + null, + timeout.Until.ToDateTimeOffset() - DateTimeOffset.UtcNow, + Timeout.InfiniteTimeSpan + ); + } + + private async Task SendTimeoutLogAsync(int timeoutId) + { + _logger.Information("Sending timeout log for {TimeoutId}", timeoutId); + + await using var scope = serviceProvider.CreateAsyncScope(); + var guildRepository = scope.ServiceProvider.GetRequiredService(); + var timeoutRepository = scope.ServiceProvider.GetRequiredService(); + + var timeout = await timeoutRepository.RemoveAsync(timeoutId); + if (timeout == null) + { + _logger.Warning("Timeout {TimeoutId} not found, can't log anything", timeoutId); + return; + } + + var userId = DiscordSnowflake.New(timeout.UserId); + var moderatorId = + timeout.ModeratorId != null + ? DiscordSnowflake.New(timeout.ModeratorId.Value) + : (Snowflake?)null; + + var user = await userCache.GetUserAsync(userId); + if (user == null) + { + _logger.Warning("Could not get user {UserId} from cache, can't log timeout", userId); + return; + } + + var embed = new EmbedBuilder() + .WithAuthor(user.Tag(), null, user.AvatarUrl()) + .WithTitle("Member timeout ended") + .WithDescription($"<@{user.ID}>") + .WithColour(DiscordUtils.Green) + .WithFooter($"User ID: {user.ID}") + .WithCurrentTimestamp(); + + if (moderatorId != null) + { + var moderator = await userCache.TryFormatUserAsync(moderatorId.Value); + embed.AddField("Originally timed out by", moderator); + } + else + { + embed.AddField("Originally timed out by", "*(unknown)*"); + } + + try + { + var guildConfig = await guildRepository.GetAsync(DiscordSnowflake.New(timeout.GuildId)); + webhookExecutor.QueueLog( + guildConfig, + LogChannelType.GuildMemberTimeout, + embed.Build().GetOrThrow() + ); + } + catch (Exception e) + { + _logger.Error(e, "Could not log timeout {TimeoutId} expiring", timeout.Id); + } + } + + public void RemoveTimer(int timeoutId) + { + if (!_timers.TryRemove(timeoutId, out var timer)) + return; + _logger.Debug("Removing timeout {TimeoutId} from queue", timeoutId); + timer.Dispose(); + } +}