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:
parent
f0fcfd7bd3
commit
e6d68338db
7 changed files with 249 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
drop table timeouts;
|
||||
|
|
@ -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);
|
||||
12
Catalogger.Backend/Database/Models/DiscordTimeout.cs
Normal file
12
Catalogger.Backend/Database/Models/DiscordTimeout.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
119
Catalogger.Backend/Services/TimeoutService.cs
Normal file
119
Catalogger.Backend/Services/TimeoutService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue