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.
This commit is contained in:
sam 2024-11-05 22:22:12 +01:00
parent f0fcfd7bd3
commit e6d68338db
Signed by: sam
GPG key ID: 5F3C3C1B3166639D
7 changed files with 249 additions and 1 deletions

View file

@ -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<Result> HandleRoleUpdateAsync(
IGuildMemberUpdate member,
IReadOnlyList<Snowflake> oldRoles,

View file

@ -0,0 +1 @@
drop table timeouts;

View file

@ -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);

View file

@ -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; }
}

View file

@ -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<DiscordTimeout?> GetAsync(int id) =>
await conn.QueryFirstOrDefaultAsync<DiscordTimeout>(
"select * from timeouts where id = @id",
new { id }
);
public async Task<DiscordTimeout?> GetAsync(Snowflake guildId, Snowflake userId) =>
await conn.QueryFirstOrDefaultAsync<DiscordTimeout>(
"select * from timeouts where guild_id = @GuildId and user_id = @UserId",
new { GuildId = guildId.Value, UserId = userId.Value }
);
public async Task<List<DiscordTimeout>> GetAllAsync() =>
(await conn.QueryAsync<DiscordTimeout>("select * from timeouts order by id")).ToList();
public async Task<DiscordTimeout> SetAsync(
Snowflake guildId,
Snowflake userId,
Instant until,
Snowflake? moderatorId
) =>
await conn.QueryFirstAsync<DiscordTimeout>(
"""
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<DiscordTimeout?> RemoveAsync(int id) =>
await conn.QueryFirstOrDefaultAsync<DiscordTimeout>(
"delete from timeouts where id = @id returning *",
new { id }
);
public async Task<DiscordTimeout?> RemoveAsync(Snowflake guildId, Snowflake userId) =>
await conn.QueryFirstOrDefaultAsync<DiscordTimeout>(
"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);
}
}

View file

@ -108,6 +108,7 @@ public static class StartupExtensions
.AddScoped<GuildRepository>()
.AddScoped<InviteRepository>()
.AddScoped<WatchlistRepository>()
.AddScoped<TimeoutRepository>()
.AddSingleton<GuildCache>()
.AddSingleton<RoleCache>()
.AddSingleton<ChannelCache>()
@ -117,12 +118,13 @@ public static class StartupExtensions
.AddSingleton<PluralkitApiService>()
.AddSingleton<NewsService>()
.AddScoped<IEncryptionService, EncryptionService>()
.AddSingleton<TimeoutService>()
.AddSingleton<MetricsCollectionService>()
.AddSingleton<WebhookExecutorService>()
.AddSingleton<PkMessageHandler>()
.AddSingleton(InMemoryDataService<Snowflake, ChannelCommandData>.Instance)
.AddSingleton<GuildFetchService>()
.AddTransient<PermissionResolverService>()
.AddSingleton<GuildFetchService>()
// 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<Config>();
var slashService = scope.ServiceProvider.GetRequiredService<SlashService>();
var timeoutService = scope.ServiceProvider.GetRequiredService<TimeoutService>();
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)

View file

@ -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<TimeoutService>();
private readonly ConcurrentDictionary<int, Timer> _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<TimeoutRepository>();
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<GuildRepository>();
var timeoutRepository = scope.ServiceProvider.GetRequiredService<TimeoutRepository>();
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();
}
}