diff --git a/Catalogger.Backend/Bot/Commands/MetaCommands.cs b/Catalogger.Backend/Bot/Commands/MetaCommands.cs index a9a2208..e0b109a 100644 --- a/Catalogger.Backend/Bot/Commands/MetaCommands.cs +++ b/Catalogger.Backend/Bot/Commands/MetaCommands.cs @@ -15,7 +15,6 @@ using Remora.Discord.Commands.Extensions; using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Commands.Services; using Remora.Discord.Extensions.Embeds; -using Remora.Discord.Gateway; using Remora.Results; using IClock = NodaTime.IClock; using IResult = Remora.Results.IResult; diff --git a/Catalogger.Backend/Bot/Responders/Guilds/GuildBanAddResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/GuildBanAddResponder.cs new file mode 100644 index 0000000..49bf27e --- /dev/null +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildBanAddResponder.cs @@ -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 +{ + private readonly ILogger _logger = logger.ForContext(); + + public async Task 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; + } +} diff --git a/Catalogger.Backend/Bot/Responders/Guilds/GuildBanRemoveResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/GuildBanRemoveResponder.cs new file mode 100644 index 0000000..d8e9ff2 --- /dev/null +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildBanRemoveResponder.cs @@ -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 +{ + private readonly ILogger _logger = logger.ForContext(); + + public async Task 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; + } +} diff --git a/Catalogger.Backend/Bot/Responders/Guilds/GuildMemberAddResponder.cs b/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs similarity index 89% rename from Catalogger.Backend/Bot/Responders/Guilds/GuildMemberAddResponder.cs rename to Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs index e155cf1..487904b 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildMemberAddResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs @@ -15,7 +15,7 @@ using Remora.Discord.Extensions.Embeds; using Remora.Discord.Gateway.Responders; using Remora.Results; -namespace Catalogger.Backend.Bot.Responders.Guilds; +namespace Catalogger.Backend.Bot.Responders.Members; public class GuildMemberAddResponder( ILogger logger, @@ -48,9 +48,9 @@ public class GuildMemberAddResponder( var guildConfig = await db.GetGuildAsync(member.GuildID, ct); var guildRes = await guildApi.GetGuildAsync(member.GuildID, withCounts: true, ct); - if (guildRes.IsSuccess) + if (guildRes.IsSuccess && guildRes.Entity.ApproximateMemberCount.IsDefined()) builder.Description += - $"\n{guildRes.Entity.ApproximateMemberCount.Value.Ordinalize()} to join"; + $"\n{guildRes.Entity.ApproximateMemberCount.OrDefault(1).Ordinalize()} to join"; builder.Description += $"\ncreated {user.ID.Timestamp.Prettify()} ago\n"; @@ -171,25 +171,19 @@ 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( - new EmbedBuilder() - .WithTitle("Banned system") - .WithDescription( - "\u26a0\ufe0f The system associated with this account has been banned from the server." - ) - .WithColour(DiscordUtils.Red) - .WithFooter($"ID: {pkSystem.Id}") - .Build() - .GetOrThrow() - ); - } + embeds.Add( + new EmbedBuilder() + .WithTitle("Banned system") + .WithDescription( + "\u26a0\ufe0f The system associated with this account has been banned from the server." + ) + .WithColour(DiscordUtils.Red) + .WithFooter($"ID: {pkSystem.Id}") + .Build() + .GetOrThrow() + ); } if (embeds.Count > 1) diff --git a/Catalogger.Backend/Bot/Responders/Guilds/GuildMemberRemoveResponder.cs b/Catalogger.Backend/Bot/Responders/Members/GuildMemberRemoveResponder.cs similarity index 96% rename from Catalogger.Backend/Bot/Responders/Guilds/GuildMemberRemoveResponder.cs rename to Catalogger.Backend/Bot/Responders/Members/GuildMemberRemoveResponder.cs index a0cf287..da1cbfd 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildMemberRemoveResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Members/GuildMemberRemoveResponder.cs @@ -9,7 +9,7 @@ using Remora.Discord.Extensions.Embeds; using Remora.Discord.Gateway.Responders; using Remora.Results; -namespace Catalogger.Backend.Bot.Responders.Guilds; +namespace Catalogger.Backend.Bot.Responders.Members; public class GuildMemberRemoveResponder( ILogger logger, @@ -98,7 +98,7 @@ public class GuildMemberRemoveResponder( kick.AddField( "Responsible moderator", - await userCache.TryFormatModeratorAsync(actionData) + await userCache.TryFormatUserAsync(actionData.ModeratorId) ); kick.AddField("Reason", actionData.Reason ?? "No reason given"); diff --git a/Catalogger.Backend/Bot/Responders/Guilds/GuildMemberUpdateResponder.cs b/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs similarity index 98% rename from Catalogger.Backend/Bot/Responders/Guilds/GuildMemberUpdateResponder.cs rename to Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs index e349372..9fc5ea1 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildMemberUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs @@ -11,7 +11,7 @@ using Remora.Discord.Gateway.Responders; using Remora.Rest.Core; using Remora.Results; -namespace Catalogger.Backend.Bot.Responders.Guilds; +namespace Catalogger.Backend.Bot.Responders.Members; public class GuildMemberUpdateResponder( ILogger logger, @@ -220,7 +220,7 @@ public class GuildMemberUpdateResponder( 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("Reason", actionData.Reason ?? "No reason given"); } @@ -331,7 +331,7 @@ public class GuildMemberUpdateResponder( 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); } else diff --git a/Catalogger.Backend/Bot/Responders/ReadyResponder.cs b/Catalogger.Backend/Bot/Responders/ReadyResponder.cs index 6cce8bf..d5344b5 100644 --- a/Catalogger.Backend/Bot/Responders/ReadyResponder.cs +++ b/Catalogger.Backend/Bot/Responders/ReadyResponder.cs @@ -17,7 +17,7 @@ public class ReadyResponder(ILogger logger, WebhookExecutorService webhookExecut ? (shard.ShardID, shard.ShardCount) : (0, 1); _logger.Information( - "Ready as {User} on shard {ShardId} / {ShardCount}", + "Ready as {User} on shard {ShardId}/{ShardCount}", gatewayEvent.User.Tag(), shardId.Item1, shardId.Item2 diff --git a/Catalogger.Backend/Catalogger.Backend.csproj b/Catalogger.Backend/Catalogger.Backend.csproj index 73e806a..f2deae8 100644 --- a/Catalogger.Backend/Catalogger.Backend.csproj +++ b/Catalogger.Backend/Catalogger.Backend.csproj @@ -9,6 +9,7 @@ + diff --git a/Catalogger.Backend/Database/Models/Guild.cs b/Catalogger.Backend/Database/Models/Guild.cs index a288d93..6ab1053 100644 --- a/Catalogger.Backend/Database/Models/Guild.cs +++ b/Catalogger.Backend/Database/Models/Guild.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations.Schema; using Catalogger.Backend.Extensions; +using Catalogger.Backend.Services; using Remora.Rest.Core; namespace Catalogger.Backend.Database.Models; @@ -14,6 +15,9 @@ public class Guild public List BannedSystems { get; init; } = []; public List 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) { if ( diff --git a/Catalogger.Backend/Extensions/DiscordExtensions.cs b/Catalogger.Backend/Extensions/DiscordExtensions.cs index b7be6f6..4a28e0e 100644 --- a/Catalogger.Backend/Extensions/DiscordExtensions.cs +++ b/Catalogger.Backend/Extensions/DiscordExtensions.cs @@ -150,15 +150,19 @@ public static class DiscordExtensions return filterByIds != null ? sorted.Where(r => filterByIds.Contains(r.ID)) : sorted; } - public static async Task TryFormatModeratorAsync( + public static async Task TryFormatUserAsync( this UserCache userCache, - AuditLogCache.ActionData actionData + Snowflake userId, + bool addMention = true ) { - var moderator = await userCache.GetUserAsync(actionData.ModeratorId); - return moderator != null - ? $"{moderator.Tag()} <@{moderator.ID}>" - : $"*(unknown user {actionData.ModeratorId}) <@{actionData.ModeratorId}>*"; + var user = await userCache.GetUserAsync(userId); + if (addMention) + return user != null + ? $"{user.Tag()} <@{user.ID}>" + : $"*(unknown user {userId}) <@{userId}>*"; + + return user != null ? user.Tag() : $"*(unknown user {userId})*"; } public static int TextLength(this IEmbed embed) diff --git a/Catalogger.Backend/Extensions/TimeExtensions.cs b/Catalogger.Backend/Extensions/TimeExtensions.cs index fd5bc35..05c380d 100644 --- a/Catalogger.Backend/Extensions/TimeExtensions.cs +++ b/Catalogger.Backend/Extensions/TimeExtensions.cs @@ -7,7 +7,12 @@ namespace Catalogger.Backend.Extensions; public static class TimeExtensions { 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) => duration.ToTimeSpan().Prettify(minUnit); diff --git a/Catalogger.Backend/Services/WebhookExecutorService.cs b/Catalogger.Backend/Services/WebhookExecutorService.cs index 764c0bf..8f39756 100644 --- a/Catalogger.Backend/Services/WebhookExecutorService.cs +++ b/Catalogger.Backend/Services/WebhookExecutorService.cs @@ -32,6 +32,10 @@ public class WebhookExecutorService( private readonly ConcurrentDictionary _timers = new(); private IUser? _selfUser; + /// + /// 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. + /// public void SetSelfUser(IUser user) => _selfUser = user; /// @@ -67,7 +71,7 @@ public class WebhookExecutorService( /// Sends multiple embeds and/or files to a channel, bypassing the embed queue. /// /// The channel ID to send the content to. - /// The embeds to send. Must be under 6000 characters in length total, this is not checked by this method. + /// The embeds to send. Must be under 6000 characters in length total. /// The files to send. public async Task SendLogAsync( ulong channelId, @@ -90,6 +94,14 @@ public class WebhookExecutorService( 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( "Sending {EmbedCount} embeds/{FileCount} files to channel {ChannelId}", embeds.Count,