From b31a20bb81e73c0abdfcb2826646687ce076aa1a Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 21 Sep 2024 20:32:02 +0200 Subject: [PATCH] feat: flesh out member remove responder --- Catalogger.Backend/Bot/DiscordUtils.cs | 1 + .../Responders/Guilds/AuditLogResponder.cs | 8 +++- .../Guilds/GuildMemberRemoveResponder.cs | 46 ++++++++++++++++--- .../Cache/InMemoryCache/AuditLogCache.cs | 22 +++++++-- Catalogger.Backend/Catalogger.Backend.csproj | 1 + .../Extensions/DiscordExtensions.cs | 14 ++++++ .../Extensions/StartupExtensions.cs | 2 + .../AuditLogEnrichedResponderService.cs | 37 +++++++++++++++ 8 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 Catalogger.Backend/Services/AuditLogEnrichedResponderService.cs diff --git a/Catalogger.Backend/Bot/DiscordUtils.cs b/Catalogger.Backend/Bot/DiscordUtils.cs index 5120bfb..6509fa1 100644 --- a/Catalogger.Backend/Bot/DiscordUtils.cs +++ b/Catalogger.Backend/Bot/DiscordUtils.cs @@ -1,5 +1,6 @@ using System.Drawing; using Remora.Discord.API; +using Remora.Discord.API.Abstractions.Objects; using Remora.Rest.Core; namespace Catalogger.Backend.Bot; diff --git a/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs index 98d5dbc..71898b9 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs @@ -1,14 +1,20 @@ using Catalogger.Backend.Cache.InMemoryCache; +using Newtonsoft.Json; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.Gateway.Responders; using Remora.Results; namespace Catalogger.Backend.Bot.Responders.Guilds; -public class AuditLogResponder(AuditLogCache auditLogCache) : IResponder +public class AuditLogResponder(AuditLogCache auditLogCache, ILogger logger) : IResponder { + private readonly ILogger _logger = logger.ForContext(); + public Task RespondAsync(IGuildAuditLogEntryCreate evt, CancellationToken ct = default) { + _logger.Debug("type: {ActionType}", evt.ActionType); + _logger.Debug("{Id}, {Reason}", evt.ID, evt.Reason); + throw new NotImplementedException(); } } \ No newline at end of file diff --git a/Catalogger.Backend/Bot/Responders/Guilds/GuildMemberRemoveResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/GuildMemberRemoveResponder.cs index 898df85..dab6702 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildMemberRemoveResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildMemberRemoveResponder.cs @@ -1,10 +1,10 @@ using Catalogger.Backend.Cache; 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.API.Abstractions.Rest; using Remora.Discord.Extensions.Embeds; using Remora.Discord.Gateway.Responders; using Remora.Results; @@ -15,16 +15,19 @@ public class GuildMemberRemoveResponder( ILogger logger, DatabaseContext db, IMemberCache memberCache, - UserCache userCache, + RoleCache roleCache, WebhookExecutorService webhookExecutor, - IDiscordRestGuildAPI guildApi) : IResponder + AuditLogEnrichedResponderService auditLogEnrichedResponderService) : IResponder { private readonly ILogger _logger = logger.ForContext(); - + public async Task RespondAsync(IGuildMemberRemove evt, CancellationToken ct = default) { try { + // spin events that Discord doesn't send us all the data for off to another responder + _ = auditLogEnrichedResponderService.RespondAsync(evt); + var embed = new EmbedBuilder() .WithTitle("Member left") .WithAuthor(evt.User.Tag(), iconUrl: evt.User.AvatarUrl()) @@ -32,13 +35,42 @@ public class GuildMemberRemoveResponder( .WithDescription($"<@{evt.User.ID}>") .WithFooter($"ID: {evt.User.ID}") .WithCurrentTimestamp(); - + + var guildConfig = await db.GetGuildAsync(evt.GuildID, ct); + var member = await memberCache.TryGetAsync(evt.GuildID, evt.User.ID); - if (member != null) + if (member == null) { - + _logger.Information( + "Guild member {UserId} in {GuildId} left but wasn't in the cache, sending limited embed", + evt.User.ID, evt.GuildID); + webhookExecutor.QueueLog(guildConfig, LogChannelType.GuildMemberRemove, embed.Build().GetOrThrow()); + return Result.Success; } + embed.Description += + $"\njoined \n({member.JoinedAt.Prettify()} ago)"; + + // get the member's roles, sort them, and turn them into mentions + var guildRoles = roleCache.GuildRoles(evt.GuildID); + var roles = guildRoles.Sorted(member.Roles).ToList(); + + var roleMentions = ""; + foreach (var (idx, role) in roles.Select((r, i) => (i, r))) + { + if (roleMentions.Length > 900) + { + roleMentions += $"\n(too many roles to list, showing {idx}/{roles.Count})"; + break; + } + + roleMentions += $"<@&{role.ID}>"; + if (idx != roles.Count - 1) roleMentions += ", "; + } + + embed.AddField("Roles", roleMentions, inline: false); + + webhookExecutor.QueueLog(guildConfig, LogChannelType.GuildMemberRemove, embed.Build().GetOrThrow()); return Result.Success; } finally diff --git a/Catalogger.Backend/Cache/InMemoryCache/AuditLogCache.cs b/Catalogger.Backend/Cache/InMemoryCache/AuditLogCache.cs index ca904b3..be6786e 100644 --- a/Catalogger.Backend/Cache/InMemoryCache/AuditLogCache.cs +++ b/Catalogger.Backend/Cache/InMemoryCache/AuditLogCache.cs @@ -6,16 +6,30 @@ namespace Catalogger.Backend.Cache.InMemoryCache; public class AuditLogCache { - private readonly ConcurrentDictionary<(Snowflake, Snowflake), ModactionData> _kicks = new(); - private readonly ConcurrentDictionary<(Snowflake, Snowflake), ModactionData> _bans = new(); + private readonly ConcurrentDictionary<(Snowflake GuildId, Snowflake TargetId), ActionData> _kicks = new(); + private readonly ConcurrentDictionary<(Snowflake GuildId, Snowflake TargetId), ActionData> _bans = new(); public void SetKick(Snowflake guildId, string targetId, Snowflake moderatorId, Optional reason) { if (!DiscordSnowflake.TryParse(targetId, out var targetUser)) throw new CataloggerError("Target ID was not a valid snowflake"); - _kicks[(guildId, targetUser.Value)] = new ModactionData(moderatorId, reason.OrDefault()); + _kicks[(guildId, targetUser.Value)] = new ActionData(moderatorId, reason.OrDefault()); } - public record struct ModactionData(Snowflake ModeratorId, string? Reason); + public bool TryGetKick(Snowflake guildId, Snowflake targetId, out ActionData data) => + _kicks.TryGetValue((guildId, targetId), out data); + + public void SetBan(Snowflake guildId, string targetId, Snowflake moderatorId, Optional reason) + { + if (!DiscordSnowflake.TryParse(targetId, out var targetUser)) + throw new CataloggerError("Target ID was not a valid snowflake"); + + _bans[(guildId, targetUser.Value)] = new ActionData(moderatorId, reason.OrDefault()); + } + + public bool TryGetBan(Snowflake guildId, Snowflake targetId, out ActionData data) => + _bans.TryGetValue((guildId, targetId), out data); + + public record struct ActionData(Snowflake ModeratorId, string? Reason); } \ No newline at end of file diff --git a/Catalogger.Backend/Catalogger.Backend.csproj b/Catalogger.Backend/Catalogger.Backend.csproj index cfde964..3bf81ce 100644 --- a/Catalogger.Backend/Catalogger.Backend.csproj +++ b/Catalogger.Backend/Catalogger.Backend.csproj @@ -27,6 +27,7 @@ + diff --git a/Catalogger.Backend/Extensions/DiscordExtensions.cs b/Catalogger.Backend/Extensions/DiscordExtensions.cs index 576e34a..52c81aa 100644 --- a/Catalogger.Backend/Extensions/DiscordExtensions.cs +++ b/Catalogger.Backend/Extensions/DiscordExtensions.cs @@ -81,6 +81,20 @@ public static class DiscordExtensions if (!ctx.TryGetGuildID(out var guildId)) throw new CataloggerError("No guild ID in context"); return (userId, guildId); } + + /// + /// Sorts a list of roles by their position in the Discord interface. + /// + /// The list of guild roles to filter. + /// An optional list of role IDs to return, from a member object or similar. + /// If null, the entire list is returned. + /// + public static IEnumerable Sorted(this IEnumerable roles, + IEnumerable? filterByIds = null) + { + var sorted = roles.OrderByDescending(r => r.Position); + return filterByIds != null ? sorted.Where(r => filterByIds.Contains(r.ID)) : sorted; + } public class DiscordRestException(string message) : Exception(message); } \ No newline at end of file diff --git a/Catalogger.Backend/Extensions/StartupExtensions.cs b/Catalogger.Backend/Extensions/StartupExtensions.cs index e996000..3ac5200 100644 --- a/Catalogger.Backend/Extensions/StartupExtensions.cs +++ b/Catalogger.Backend/Extensions/StartupExtensions.cs @@ -73,9 +73,11 @@ public static class StartupExtensions .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddScoped() .AddSingleton() + .AddSingleton() .AddScoped() .AddSingleton() .AddSingleton() diff --git a/Catalogger.Backend/Services/AuditLogEnrichedResponderService.cs b/Catalogger.Backend/Services/AuditLogEnrichedResponderService.cs new file mode 100644 index 0000000..780ad9e --- /dev/null +++ b/Catalogger.Backend/Services/AuditLogEnrichedResponderService.cs @@ -0,0 +1,37 @@ +using Catalogger.Backend.Cache.InMemoryCache; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Results; + +namespace Catalogger.Backend.Services; + +public class AuditLogEnrichedResponderService(AuditLogCache auditLogCache, ILogger logger) +{ + private readonly ILogger _logger = logger.ForContext(); + + public async Task RespondAsync(IGuildMemberRemove evt) + { + // give a second or so for the audit log to catch up + await Task.Delay(1000); + + if (auditLogCache.TryGetKick(evt.GuildID, evt.User.ID, out var kickData)) + return await HandleKickAsync(evt, kickData); + + if (auditLogCache.TryGetBan(evt.GuildID, evt.User.ID, out var banData)) + return await HandleBanAsync(evt, banData); + + _logger.Debug("Guild member remove event for guild {GuildId}/user {UserId} didn't match an audit log entry", + evt.GuildID, evt.User.ID); + + return Result.Success; + } + + private async Task HandleKickAsync(IGuildMemberRemove evt, AuditLogCache.ActionData kickData) + { + return Result.Success; + } + + private async Task HandleBanAsync(IGuildMemberRemove evt, AuditLogCache.ActionData banData) + { + return Result.Success; + } +} \ No newline at end of file