feat: guild ban add/remove logging, store banned systems in database

This commit is contained in:
sam 2024-10-13 14:58:44 +02:00
parent ca99bdfb94
commit 8e030acaf3
12 changed files with 227 additions and 36 deletions

View file

@ -15,7 +15,6 @@ using Remora.Discord.Commands.Extensions;
using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Commands.Services; using Remora.Discord.Commands.Services;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway;
using Remora.Results; using Remora.Results;
using IClock = NodaTime.IClock; using IClock = NodaTime.IClock;
using IResult = Remora.Results.IResult; using IResult = Remora.Results.IResult;

View file

@ -0,0 +1,90 @@
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Queries;
using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
namespace Catalogger.Backend.Bot.Responders.Guilds;
public class GuildBanAddResponder(
ILogger logger,
DatabaseContext db,
WebhookExecutorService webhookExecutor,
UserCache userCache,
AuditLogCache auditLogCache,
PluralkitApiService pluralkitApi
) : IResponder<IGuildBanAdd>
{
private readonly ILogger _logger = logger.ForContext<GuildBanAddResponder>();
public async Task<Result> RespondAsync(IGuildBanAdd evt, CancellationToken ct = default)
{
var guildConfig = await db.GetGuildAsync(evt.GuildID, ct);
// Delay 2 seconds for the audit log
await Task.Delay(2000, ct);
var embed = new EmbedBuilder()
.WithTitle("User banned")
.WithAuthor(evt.User.Tag(), null, evt.User.AvatarUrl())
.WithDescription($"<@{evt.User.ID}>")
.WithColour(DiscordUtils.Red)
.WithFooter($"User ID: {evt.User.ID}")
.WithCurrentTimestamp();
if (auditLogCache.TryGetBan(evt.GuildID, evt.User.ID, out var actionData))
{
embed.AddField(
"Responsible moderator",
await userCache.TryFormatUserAsync(actionData.ModeratorId)
);
embed.AddField("Reason", actionData.Reason ?? "No reason given");
}
else
{
embed.AddField("Responsible moderator", "*(unknown)*");
embed.AddField("Reason", "*(unknown)*");
}
// Get PluralKit system, if any, and add it to the guild's banned systems list
var pkSystem = await pluralkitApi.GetPluralKitSystemAsync(evt.User.ID.Value, ct);
if (pkSystem != null)
{
if (!guildConfig.IsSystemBanned(pkSystem))
{
_logger.Information(
"PluralKit system {SystemHid} will be banned from guild {GuildId}",
pkSystem.Id,
evt.GuildID
);
guildConfig.BannedSystems.Add(pkSystem.Id);
guildConfig.BannedSystems.Add(pkSystem.Uuid.ToString());
db.Update(guildConfig);
await db.SaveChangesAsync(ct);
}
embed.AddField(
"PluralKit system",
$"""
**ID:** {pkSystem.Id}
**UUID:** `{pkSystem.Uuid}`
**Name:** {pkSystem.Name ?? "*(none)*"}
**Tag:** {pkSystem.Tag ?? "*(none)*"}
This system has been marked as banned. You will be warned if another account linked to this system joins.
"""
);
}
webhookExecutor.QueueLog(
guildConfig,
LogChannelType.GuildBanAdd,
embed.Build().GetOrThrow()
);
return Result.Success;
}
}

View file

@ -0,0 +1,82 @@
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Queries;
using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
namespace Catalogger.Backend.Bot.Responders.Guilds;
public class GuildBanRemoveResponder(
ILogger logger,
DatabaseContext db,
WebhookExecutorService webhookExecutor,
UserCache userCache,
AuditLogCache auditLogCache,
PluralkitApiService pluralkitApi
) : IResponder<IGuildBanRemove>
{
private readonly ILogger _logger = logger.ForContext<GuildBanRemoveResponder>();
public async Task<Result> RespondAsync(IGuildBanRemove evt, CancellationToken ct = default)
{
var guildConfig = await db.GetGuildAsync(evt.GuildID, ct);
// Delay 2 seconds for the audit log
await Task.Delay(2000, ct);
var embed = new EmbedBuilder()
.WithTitle("User unbanned")
.WithAuthor(evt.User.Tag(), null, evt.User.AvatarUrl())
.WithDescription($"<@{evt.User.ID}>")
.WithColour(DiscordUtils.Green)
.WithFooter($"User ID: {evt.User.ID}")
.WithCurrentTimestamp();
if (auditLogCache.TryGetUnban(evt.GuildID, evt.User.ID, out var actionData))
{
embed.AddField(
"Responsible moderator",
await userCache.TryFormatUserAsync(actionData.ModeratorId)
);
embed.AddField("Reason", actionData.Reason ?? "No reason given");
}
else
{
embed.AddField("Responsible moderator", "*(unknown)*");
embed.AddField("Reason", "*(unknown)*");
}
var pkSystem = await pluralkitApi.GetPluralKitSystemAsync(evt.User.ID.Value, ct);
if (pkSystem != null)
{
guildConfig.BannedSystems.Remove(pkSystem.Id);
guildConfig.BannedSystems.Remove(pkSystem.Uuid.ToString());
db.Update(guildConfig);
await db.SaveChangesAsync(ct);
embed.AddField(
"PluralKit system",
$"""
**ID:** {pkSystem.Id}
**UUID:** `{pkSystem.Uuid}`
**Name:** {pkSystem.Name ?? "*(none)*"}
**Tag:** {pkSystem.Tag ?? "*(none)*"}
This system has been unbanned.
Note that other accounts linked to the system might still be banned, check `pk;system {pkSystem.Id}` for the linked accounts.
"""
);
}
webhookExecutor.QueueLog(
guildConfig,
LogChannelType.GuildBanRemove,
embed.Build().GetOrThrow()
);
return Result.Success;
}
}

View file

@ -15,7 +15,7 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
using Remora.Results; using Remora.Results;
namespace Catalogger.Backend.Bot.Responders.Guilds; namespace Catalogger.Backend.Bot.Responders.Members;
public class GuildMemberAddResponder( public class GuildMemberAddResponder(
ILogger logger, ILogger logger,
@ -48,9 +48,9 @@ public class GuildMemberAddResponder(
var guildConfig = await db.GetGuildAsync(member.GuildID, ct); var guildConfig = await db.GetGuildAsync(member.GuildID, ct);
var guildRes = await guildApi.GetGuildAsync(member.GuildID, withCounts: true, ct); var guildRes = await guildApi.GetGuildAsync(member.GuildID, withCounts: true, ct);
if (guildRes.IsSuccess) if (guildRes.IsSuccess && guildRes.Entity.ApproximateMemberCount.IsDefined())
builder.Description += builder.Description +=
$"\n{guildRes.Entity.ApproximateMemberCount.Value.Ordinalize()} to join"; $"\n{guildRes.Entity.ApproximateMemberCount.OrDefault(1).Ordinalize()} to join";
builder.Description += builder.Description +=
$"\ncreated {user.ID.Timestamp.Prettify()} ago\n<t:{user.ID.Timestamp.ToUnixTimeSeconds()}:F>"; $"\ncreated {user.ID.Timestamp.Prettify()} ago\n<t:{user.ID.Timestamp.ToUnixTimeSeconds()}:F>";
@ -171,12 +171,7 @@ public class GuildMemberAddResponder(
); );
} }
if (pkSystem != null) if (pkSystem != null && guildConfig.IsSystemBanned(pkSystem))
{
if (
guildConfig.BannedSystems.Contains(pkSystem.Id)
|| guildConfig.BannedSystems.Contains(pkSystem.Uuid.ToString())
)
{ {
embeds.Add( embeds.Add(
new EmbedBuilder() new EmbedBuilder()
@ -190,7 +185,6 @@ public class GuildMemberAddResponder(
.GetOrThrow() .GetOrThrow()
); );
} }
}
if (embeds.Count > 1) if (embeds.Count > 1)
await webhookExecutor.SendLogAsync( await webhookExecutor.SendLogAsync(

View file

@ -9,7 +9,7 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
using Remora.Results; using Remora.Results;
namespace Catalogger.Backend.Bot.Responders.Guilds; namespace Catalogger.Backend.Bot.Responders.Members;
public class GuildMemberRemoveResponder( public class GuildMemberRemoveResponder(
ILogger logger, ILogger logger,
@ -98,7 +98,7 @@ public class GuildMemberRemoveResponder(
kick.AddField( kick.AddField(
"Responsible moderator", "Responsible moderator",
await userCache.TryFormatModeratorAsync(actionData) await userCache.TryFormatUserAsync(actionData.ModeratorId)
); );
kick.AddField("Reason", actionData.Reason ?? "No reason given"); kick.AddField("Reason", actionData.Reason ?? "No reason given");

View file

@ -11,7 +11,7 @@ using Remora.Discord.Gateway.Responders;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
namespace Catalogger.Backend.Bot.Responders.Guilds; namespace Catalogger.Backend.Bot.Responders.Members;
public class GuildMemberUpdateResponder( public class GuildMemberUpdateResponder(
ILogger logger, ILogger logger,
@ -220,7 +220,7 @@ public class GuildMemberUpdateResponder(
if (auditLogCache.TryGetMemberUpdate(member.GuildID, member.User.ID, out var actionData)) if (auditLogCache.TryGetMemberUpdate(member.GuildID, member.User.ID, out var actionData))
{ {
var moderator = await userCache.TryFormatModeratorAsync(actionData); var moderator = await userCache.TryFormatUserAsync(actionData.ModeratorId);
embed.AddField("Responsible moderator", moderator); embed.AddField("Responsible moderator", moderator);
embed.AddField("Reason", actionData.Reason ?? "No reason given"); embed.AddField("Reason", actionData.Reason ?? "No reason given");
} }
@ -331,7 +331,7 @@ public class GuildMemberUpdateResponder(
auditLogCache.TryGetMemberUpdate(member.GuildID, member.User.ID, out var actionData) auditLogCache.TryGetMemberUpdate(member.GuildID, member.User.ID, out var actionData)
) )
{ {
var moderator = await userCache.TryFormatModeratorAsync(actionData); var moderator = await userCache.TryFormatUserAsync(actionData.ModeratorId);
keyRoleUpdate.AddField("Responsible moderator", moderator); keyRoleUpdate.AddField("Responsible moderator", moderator);
} }
else else

View file

@ -17,7 +17,7 @@ public class ReadyResponder(ILogger logger, WebhookExecutorService webhookExecut
? (shard.ShardID, shard.ShardCount) ? (shard.ShardID, shard.ShardCount)
: (0, 1); : (0, 1);
_logger.Information( _logger.Information(
"Ready as {User} on shard {ShardId} / {ShardCount}", "Ready as {User} on shard {ShardId}/{ShardCount}",
gatewayEvent.User.Tag(), gatewayEvent.User.Tag(),
shardId.Item1, shardId.Item1,
shardId.Item2 shardId.Item2

View file

@ -9,6 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3" /> <PackageReference Include="EFCore.NamingConventions" Version="8.0.3" />
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.3" /> <PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.3" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="LazyCache" Version="2.4.0" /> <PackageReference Include="LazyCache" Version="2.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />

View file

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Catalogger.Backend.Database.Models; namespace Catalogger.Backend.Database.Models;
@ -14,6 +15,9 @@ public class Guild
public List<string> BannedSystems { get; init; } = []; public List<string> BannedSystems { get; init; } = [];
public List<ulong> KeyRoles { get; init; } = []; public List<ulong> KeyRoles { get; init; } = [];
public bool IsSystemBanned(PluralkitApiService.PkSystem system) =>
BannedSystems.Contains(system.Id) || BannedSystems.Contains(system.Uuid.ToString());
public bool IsMessageIgnored(Snowflake channelId, Snowflake userId) public bool IsMessageIgnored(Snowflake channelId, Snowflake userId)
{ {
if ( if (

View file

@ -150,15 +150,19 @@ public static class DiscordExtensions
return filterByIds != null ? sorted.Where(r => filterByIds.Contains(r.ID)) : sorted; return filterByIds != null ? sorted.Where(r => filterByIds.Contains(r.ID)) : sorted;
} }
public static async Task<string> TryFormatModeratorAsync( public static async Task<string> TryFormatUserAsync(
this UserCache userCache, this UserCache userCache,
AuditLogCache.ActionData actionData Snowflake userId,
bool addMention = true
) )
{ {
var moderator = await userCache.GetUserAsync(actionData.ModeratorId); var user = await userCache.GetUserAsync(userId);
return moderator != null if (addMention)
? $"{moderator.Tag()} <@{moderator.ID}>" return user != null
: $"*(unknown user {actionData.ModeratorId}) <@{actionData.ModeratorId}>*"; ? $"{user.Tag()} <@{user.ID}>"
: $"*(unknown user {userId}) <@{userId}>*";
return user != null ? user.Tag() : $"*(unknown user {userId})*";
} }
public static int TextLength(this IEmbed embed) public static int TextLength(this IEmbed embed)

View file

@ -7,7 +7,12 @@ namespace Catalogger.Backend.Extensions;
public static class TimeExtensions public static class TimeExtensions
{ {
public static string Prettify(this TimeSpan timespan, TimeUnit minUnit = TimeUnit.Minute) => public static string Prettify(this TimeSpan timespan, TimeUnit minUnit = TimeUnit.Minute) =>
timespan.Humanize(minUnit: minUnit, precision: 5, collectionSeparator: null); timespan.Humanize(
maxUnit: TimeUnit.Year,
minUnit: minUnit,
precision: 5,
collectionSeparator: null
);
public static string Prettify(this Duration duration, TimeUnit minUnit = TimeUnit.Minute) => public static string Prettify(this Duration duration, TimeUnit minUnit = TimeUnit.Minute) =>
duration.ToTimeSpan().Prettify(minUnit); duration.ToTimeSpan().Prettify(minUnit);

View file

@ -32,6 +32,10 @@ public class WebhookExecutorService(
private readonly ConcurrentDictionary<ulong, Timer> _timers = new(); private readonly ConcurrentDictionary<ulong, Timer> _timers = new();
private IUser? _selfUser; private IUser? _selfUser;
/// <summary>
/// Sets the current user for this webhook executor service. This must be called as soon as possible,
/// before any logs are sent, such as in a READY event.
/// </summary>
public void SetSelfUser(IUser user) => _selfUser = user; public void SetSelfUser(IUser user) => _selfUser = user;
/// <summary> /// <summary>
@ -67,7 +71,7 @@ public class WebhookExecutorService(
/// Sends multiple embeds and/or files to a channel, bypassing the embed queue. /// Sends multiple embeds and/or files to a channel, bypassing the embed queue.
/// </summary> /// </summary>
/// <param name="channelId">The channel ID to send the content to.</param> /// <param name="channelId">The channel ID to send the content to.</param>
/// <param name="embeds">The embeds to send. Must be under 6000 characters in length total, this is not checked by this method.</param> /// <param name="embeds">The embeds to send. Must be under 6000 characters in length total.</param>
/// <param name="files">The files to send.</param> /// <param name="files">The files to send.</param>
public async Task SendLogAsync( public async Task SendLogAsync(
ulong channelId, ulong channelId,
@ -90,6 +94,14 @@ public class WebhookExecutorService(
return; return;
} }
if (embeds.Select(e => e.TextLength()).Sum() > MaxContentLength)
{
_logger.Error(
"SendLogAsync was called with embeds totaling more than 6000 characters, bailing to prevent a bad request error"
);
return;
}
_logger.Debug( _logger.Debug(
"Sending {EmbedCount} embeds/{FileCount} files to channel {ChannelId}", "Sending {EmbedCount} embeds/{FileCount} files to channel {ChannelId}",
embeds.Count, embeds.Count,