From d445b5ba44875762e1318ec25eed285c3b9c8350 Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 11 Oct 2024 20:38:53 +0200 Subject: [PATCH] feat: member avatar/name logging, timeout logging --- .../Bot/Commands/ChannelCommands.cs | 34 ++- .../Bot/Commands/ChannelCommandsComponents.cs | 6 + .../Responders/Guilds/AuditLogResponder.cs | 35 ++- .../Guilds/GuildMemberAddResponder.cs | 1 + .../GuildMemberUpdateResponder.cs | 203 +++++++++++++++++- .../Cache/InMemoryCache/AuditLogCache.cs | 46 ++++ Catalogger.Backend/Database/Models/Guild.cs | 1 + .../Extensions/DiscordExtensions.cs | 35 +++ Catalogger.Backend/Program.cs | 4 +- .../Services/WebhookExecutorService.cs | 2 + 10 files changed, 350 insertions(+), 17 deletions(-) diff --git a/Catalogger.Backend/Bot/Commands/ChannelCommands.cs b/Catalogger.Backend/Bot/Commands/ChannelCommands.cs index e4b2312..0411ffa 100644 --- a/Catalogger.Backend/Bot/Commands/ChannelCommands.cs +++ b/Catalogger.Backend/Bot/Commands/ChannelCommands.cs @@ -144,6 +144,11 @@ public class ChannelCommands( PrettyChannelString(guildConfig.Channels.GuildMemberAvatarUpdate), true ), + new EmbedField( + "Timeouts", + PrettyChannelString(guildConfig.Channels.GuildMemberTimeout), + true + ), new EmbedField( "Kicks", PrettyChannelString(guildConfig.Channels.GuildMemberKick), @@ -306,12 +311,24 @@ public class ChannelCommands( ), new ButtonComponent( ButtonComponentStyle.Primary, - Label: "Members avatar changes", + Label: "Member avatar changes", CustomID: CustomIDHelpers.CreateButtonIDWithState( "config-channels", nameof(LogChannelType.GuildMemberAvatarUpdate) ) ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Timeouts", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildMemberTimeout) + ) + ), + ] + ), + new ActionRowComponent( + [ new ButtonComponent( ButtonComponentStyle.Primary, Label: "Kicks", @@ -320,10 +337,6 @@ public class ChannelCommands( nameof(LogChannelType.GuildMemberKick) ) ), - ] - ), - new ActionRowComponent( - [ new ButtonComponent( ButtonComponentStyle.Primary, Label: "Bans", @@ -356,6 +369,10 @@ public class ChannelCommands( nameof(LogChannelType.InviteDelete) ) ), + ] + ), + new ActionRowComponent( + [ new ButtonComponent( ButtonComponentStyle.Primary, Label: "Edited messages", @@ -364,10 +381,6 @@ public class ChannelCommands( nameof(LogChannelType.MessageUpdate) ) ), - ] - ), - new ActionRowComponent( - [ new ButtonComponent( ButtonComponentStyle.Primary, Label: "Deleted messages", @@ -420,10 +433,11 @@ public class ChannelCommands( LogChannelType.ChannelUpdate => "Edited channels", LogChannelType.ChannelDelete => "Deleted channels", LogChannelType.GuildMemberAdd => "Members joining", - LogChannelType.GuildMemberUpdate => "Members leaving", + LogChannelType.GuildMemberUpdate => "Member role changes", LogChannelType.GuildKeyRoleUpdate => "Key role changes", LogChannelType.GuildMemberNickUpdate => "Member name changes", LogChannelType.GuildMemberAvatarUpdate => "Member avatar changes", + LogChannelType.GuildMemberTimeout => "Timeouts", LogChannelType.GuildMemberRemove => "Members leaving", LogChannelType.GuildMemberKick => "Kicks", LogChannelType.GuildBanAdd => "Bans", diff --git a/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs b/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs index 40b6574..ca025c9 100644 --- a/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs +++ b/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs @@ -120,6 +120,9 @@ public class ChannelCommandsComponents( case LogChannelType.GuildMemberRemove: guildConfig.Channels.GuildMemberRemove = 0; break; + case LogChannelType.GuildMemberTimeout: + guildConfig.Channels.GuildMemberTimeout = 0; + break; case LogChannelType.GuildMemberKick: guildConfig.Channels.GuildMemberKick = 0; break; @@ -305,6 +308,9 @@ public class ChannelCommandsComponents( case LogChannelType.GuildMemberRemove: guildConfig.Channels.GuildMemberRemove = channelId; break; + case LogChannelType.GuildMemberTimeout: + guildConfig.Channels.GuildMemberTimeout = channelId; + break; case LogChannelType.GuildMemberKick: guildConfig.Channels.GuildMemberKick = channelId; break; diff --git a/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs index 0241b48..36a7946 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs @@ -1,5 +1,7 @@ using Catalogger.Backend.Cache.InMemoryCache; +using Remora.Discord.API; using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.Gateway.Responders; using Remora.Results; @@ -12,9 +14,36 @@ public class AuditLogResponder(AuditLogCache auditLogCache, ILogger logger) public Task RespondAsync(IGuildAuditLogEntryCreate evt, CancellationToken ct = default) { - _logger.Debug("type: {ActionType}", evt.ActionType); - _logger.Debug("{Id}, {Reason}", evt.ID, evt.Reason); + if (evt.TargetID == null || evt.UserID == null) + return Task.FromResult(Result.Success); - throw new NotImplementedException(); + switch (evt.ActionType) + { + case AuditLogEvent.MemberKick: + auditLogCache.SetKick(evt.GuildID, evt.TargetID, evt.UserID.Value, evt.Reason); + break; + case AuditLogEvent.MemberBanAdd: + auditLogCache.SetBan(evt.GuildID, evt.TargetID, evt.UserID.Value, evt.Reason); + break; + case AuditLogEvent.MemberBanRemove: + auditLogCache.SetUnban(evt.GuildID, evt.TargetID, evt.UserID.Value, evt.Reason); + break; + case AuditLogEvent.MemberUpdate: + auditLogCache.SetMemberUpdate( + evt.GuildID, + evt.TargetID, + evt.UserID.Value, + evt.Reason + ); + break; + default: + _logger.Debug( + "Received audit log event {Id} that we don't care about, ignoring", + evt.ID + ); + break; + } + + return Task.FromResult(Result.Success); } } diff --git a/Catalogger.Backend/Bot/Responders/Guilds/GuildMemberAddResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/GuildMemberAddResponder.cs index 4e272a2..e155cf1 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildMemberAddResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildMemberAddResponder.cs @@ -36,6 +36,7 @@ public class GuildMemberAddResponder( await memberCache.SetAsync(member.GuildID, member); var user = member.User.GetOrThrow(); + userCache.UpdateUser(user); var builder = new EmbedBuilder() .WithTitle("Member joined") diff --git a/Catalogger.Backend/Bot/Responders/MemberUpdate/GuildMemberUpdateResponder.cs b/Catalogger.Backend/Bot/Responders/MemberUpdate/GuildMemberUpdateResponder.cs index 4d5e056..81be74c 100644 --- a/Catalogger.Backend/Bot/Responders/MemberUpdate/GuildMemberUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/MemberUpdate/GuildMemberUpdateResponder.cs @@ -1,12 +1,25 @@ 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.Objects; +using Remora.Discord.Extensions.Embeds; using Remora.Discord.Gateway.Responders; using Remora.Results; namespace Catalogger.Backend.Bot.Responders.MemberUpdate; -public class GuildMemberUpdateResponder(ILogger logger, IMemberCache memberCache) - : IResponder +public class GuildMemberUpdateResponder( + ILogger logger, + DatabaseContext db, + UserCache userCache, + IMemberCache memberCache, + WebhookExecutorService webhookExecutor, + AuditLogCache auditLogCache +) : IResponder { private readonly ILogger _logger = logger.ForContext(); @@ -27,12 +40,198 @@ public class GuildMemberUpdateResponder(ILogger logger, IMemberCache memberCache ); return Result.Success; } + + var oldUser = oldMember.User.GetOrThrow(); + + if ( + !Equals(oldMember.Avatar.OrDefault(), newMember.Avatar.OrDefault()) + || !Equals(oldUser.Avatar, newMember.User.Avatar) + ) + { + return await HandleAvatarUpdateAsync(newMember, oldMember, ct); + } + + if ( + newMember.Nickname.OrDefault() != oldMember.Nickname.OrDefault() + || newMember.User.Tag() != oldUser.Tag() + || newMember.User.GlobalName.OrDefault() != oldUser.GlobalName.OrDefault() + ) + { + return await HandleNameUpdateAsync(newMember, oldMember, ct); + } + + if ( + newMember.CommunicationDisabledUntil.OrDefault() + != oldMember.CommunicationDisabledUntil.OrDefault() + ) + { + return await HandleTimeoutAsync(newMember, ct); + } } finally { await memberCache.UpdateAsync(newMember); + userCache.UpdateUser(newMember.User); } return Result.Success; } + + private async Task HandleAvatarUpdateAsync( + IGuildMemberUpdate newMember, + IGuildMember oldMember, + CancellationToken ct = default + ) + { + IEmbed embed; + + if (!Equals(oldMember.Avatar.OrDefault(), newMember.Avatar.OrDefault())) + { + var builder = new EmbedBuilder() + .WithAuthor(newMember.User.Tag(), null, newMember.User.AvatarUrl()) + .WithColour(DiscordUtils.Green) + .WithFooter($"User ID: {newMember.User.ID}") + .WithCurrentTimestamp(); + + if (newMember.Avatar.IsDefined()) + { + builder = builder + .WithTitle("Changed server avatar") + .WithThumbnailUrl(newMember.AvatarUrl(1024)!); + } + else + { + builder = builder.WithTitle("Removed server avatar"); + } + + embed = builder.Build().GetOrThrow(); + } + else + { + embed = new EmbedBuilder() + .WithAuthor(newMember.User.Tag(), null, newMember.User.AvatarUrl()) + .WithTitle("Changed avatar") + .WithThumbnailUrl(newMember.User.AvatarUrl(1024)) + .WithColour(DiscordUtils.Green) + .WithFooter($"User ID: {newMember.User.ID}") + .WithCurrentTimestamp() + .Build() + .GetOrThrow(); + } + + var guildConfig = await db.GetGuildAsync(newMember.GuildID, ct); + webhookExecutor.QueueLog(guildConfig, LogChannelType.GuildMemberAvatarUpdate, embed); + return Result.Success; + } + + private async Task HandleNameUpdateAsync( + IGuildMemberUpdate newMember, + IGuildMember oldMember, + CancellationToken ct = default + ) + { + var oldUser = oldMember.User.GetOrThrow(); + + var builder = new EmbedBuilder() + .WithAuthor(newMember.User.Tag(), null, newMember.User.AvatarUrl()) + .WithColour(DiscordUtils.Green) + .WithFooter($"User ID: {newMember.User.ID}") + .WithCurrentTimestamp(); + + if (newMember.Nickname.OrDefault() != oldMember.Nickname.OrDefault()) + { + builder.AddField( + "Changed nickname", + $""" + **Before:** {oldMember.Nickname.OrDefault("*(none)*")} + **After:** {newMember.Nickname.OrDefault("*(none)*")} + """ + ); + } + + if (newMember.User.GlobalName.OrDefault() != oldUser.GlobalName.OrDefault()) + { + builder.AddField( + "Changed display name", + $""" + **Before:** {oldUser.GlobalName.OrDefault("*(none)*")} + **After:** {newMember.User.GlobalName.OrDefault("*(none)*")} + """ + ); + } + + if (newMember.User.Tag() != oldUser.Tag()) + { + builder.AddField( + "Changed username", + $""" + **Before:** {oldUser.Tag()} + **After:** {newMember.User.Tag()} + """ + ); + } + + var guildConfig = await db.GetGuildAsync(newMember.GuildID, ct); + webhookExecutor.QueueLog( + guildConfig, + LogChannelType.GuildMemberNickUpdate, + builder.Build().GetOrThrow() + ); + return Result.Success; + } + + private async Task HandleTimeoutAsync( + IGuildMemberUpdate member, + CancellationToken ct = default + ) + { + // Delay 2 seconds to make sure the timeout audit log got cached + await Task.Delay(2000, ct); + + var timeoutUntil = member.CommunicationDisabledUntil.OrDefault(); + + var embed = new EmbedBuilder() + .WithAuthor(member.User.Tag(), null, member.User.AvatarUrl()) + .WithTitle( + timeoutUntil != null ? "Member timed out" : "Member removed from timeout early" + ) + .WithDescription($"<@{member.User.ID}>") + .WithColour(DiscordUtils.Red) + .WithFooter($"User ID: {member.User.ID}") + .WithCurrentTimestamp(); + + if (timeoutUntil != null) + { + embed.AddField( + "Until", + $"\nin {timeoutUntil.Value.AddSeconds(5).Prettify()}" + ); + } + + if (auditLogCache.TryGetMemberUpdate(member.GuildID, member.User.ID, out var actionData)) + { + var moderator = await userCache.GetUserAsync(actionData.ModeratorId); + embed.AddField( + "Responsible moderator", + moderator == null + ? $"*(unknown user {actionData.ModeratorId}) <@{actionData.ModeratorId}>*" + : $"{moderator.Tag()} <@{moderator.ID}>" + ); + + embed.AddField("Reason", actionData.Reason ?? "No reason given"); + } + else + { + embed.AddField("Responsible moderator", "*(unknown)*"); + embed.AddField("Reason", "*(unknown)*"); + } + + var guildConfig = await db.GetGuildAsync(member.GuildID, ct); + webhookExecutor.QueueLog( + guildConfig, + LogChannelType.GuildMemberTimeout, + embed.Build().GetOrThrow() + ); + return Result.Success; + } } diff --git a/Catalogger.Backend/Cache/InMemoryCache/AuditLogCache.cs b/Catalogger.Backend/Cache/InMemoryCache/AuditLogCache.cs index 18f72ca..16b3d78 100644 --- a/Catalogger.Backend/Cache/InMemoryCache/AuditLogCache.cs +++ b/Catalogger.Backend/Cache/InMemoryCache/AuditLogCache.cs @@ -10,11 +10,22 @@ public class AuditLogCache (Snowflake GuildId, Snowflake TargetId), ActionData > _kicks = new(); + private readonly ConcurrentDictionary< (Snowflake GuildId, Snowflake TargetId), ActionData > _bans = new(); + private readonly ConcurrentDictionary< + (Snowflake GuildId, Snowflake TargetId), + ActionData + > _unbans = new(); + + private readonly ConcurrentDictionary< + (Snowflake GuildId, Snowflake TargetId), + ActionData + > _memberUpdates = new(); + public void SetKick( Snowflake guildId, string targetId, @@ -47,5 +58,40 @@ public class AuditLogCache public bool TryGetBan(Snowflake guildId, Snowflake targetId, out ActionData data) => _bans.TryGetValue((guildId, targetId), out data); + public void SetUnban( + 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"); + + _unbans[(guildId, targetUser.Value)] = new ActionData(moderatorId, reason.OrDefault()); + } + + public bool TryGetUnban(Snowflake guildId, Snowflake targetId, out ActionData data) => + _unbans.TryGetValue((guildId, targetId), out data); + + public void SetMemberUpdate( + 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"); + + _memberUpdates[(guildId, targetUser.Value)] = new ActionData( + moderatorId, + reason.OrDefault() + ); + } + + public bool TryGetMemberUpdate(Snowflake guildId, Snowflake targetId, out ActionData data) => + _memberUpdates.TryGetValue((guildId, targetId), out data); + public record struct ActionData(Snowflake ModeratorId, string? Reason); } diff --git a/Catalogger.Backend/Database/Models/Guild.cs b/Catalogger.Backend/Database/Models/Guild.cs index 4792cc2..a288d93 100644 --- a/Catalogger.Backend/Database/Models/Guild.cs +++ b/Catalogger.Backend/Database/Models/Guild.cs @@ -54,6 +54,7 @@ public class Guild public ulong GuildKeyRoleUpdate { get; set; } public ulong GuildMemberNickUpdate { get; set; } public ulong GuildMemberAvatarUpdate { get; set; } + public ulong GuildMemberTimeout { get; set; } public ulong GuildMemberRemove { get; set; } public ulong GuildMemberKick { get; set; } public ulong GuildBanAdd { get; set; } diff --git a/Catalogger.Backend/Extensions/DiscordExtensions.cs b/Catalogger.Backend/Extensions/DiscordExtensions.cs index 6779e52..6249716 100644 --- a/Catalogger.Backend/Extensions/DiscordExtensions.cs +++ b/Catalogger.Backend/Extensions/DiscordExtensions.cs @@ -1,6 +1,7 @@ using System.Drawing; using Humanizer; using OneOf; +using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Objects; @@ -35,6 +36,40 @@ public static class DiscordExtensions return $"https://cdn.discordapp.com/embed/avatars/{avatarIndex}.png?size={size}"; } + public static string? AvatarUrl(this IGuildMemberUpdate member, int size = 256) => + GuildAvatarUrl( + member.GuildID, + member.User.ID, + member.Avatar.OrDefault()?.Value, + isAnimated: member.Avatar.OrDefault()?.HasGif, + size + ); + + public static string? AvatarUrl(this IGuildMember member, Snowflake guildId, int size = 256) => + GuildAvatarUrl( + guildId, + member.User.GetOrThrow().ID, + member.Avatar.OrDefault()?.Value, + isAnimated: member.Avatar.OrDefault()?.HasGif, + size + ); + + private static string? GuildAvatarUrl( + Snowflake guildId, + Snowflake userId, + string? hash, + bool? isAnimated, + int size = 256 + ) + { + if (hash == null) + return null; + + var ext = isAnimated == true ? ".gif" : ".webp"; + + return $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{hash}{ext}?size={size}"; + } + public static string? IconUrl(this IGuild guild, int size = 256) { if (guild.Icon == null) diff --git a/Catalogger.Backend/Program.cs b/Catalogger.Backend/Program.cs index 1f950a2..014dd9e 100644 --- a/Catalogger.Backend/Program.cs +++ b/Catalogger.Backend/Program.cs @@ -35,13 +35,13 @@ builder .Configure(g => g.Intents = GatewayIntents.Guilds - | GatewayIntents.GuildBans + | GatewayIntents.GuildBans // Actually GUILD_MODERATION | GatewayIntents.GuildInvites | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages | GatewayIntents.GuildWebhooks | GatewayIntents.MessageContents - | GatewayIntents.GuildEmojisAndStickers + | GatewayIntents.GuildEmojisAndStickers // Actually GUILD_EXPRESSIONS ) .AddDiscordCaching() .AddDiscordCommands(enableSlash: true, useDefaultCommandResponder: false) diff --git a/Catalogger.Backend/Services/WebhookExecutorService.cs b/Catalogger.Backend/Services/WebhookExecutorService.cs index 567f863..20d48a6 100644 --- a/Catalogger.Backend/Services/WebhookExecutorService.cs +++ b/Catalogger.Backend/Services/WebhookExecutorService.cs @@ -274,6 +274,7 @@ public class WebhookExecutorService( LogChannelType.GuildKeyRoleUpdate => guild.Channels.GuildKeyRoleUpdate, LogChannelType.GuildMemberNickUpdate => guild.Channels.GuildMemberNickUpdate, LogChannelType.GuildMemberAvatarUpdate => guild.Channels.GuildMemberAvatarUpdate, + LogChannelType.GuildMemberTimeout => guild.Channels.GuildMemberTimeout, LogChannelType.GuildMemberRemove => guild.Channels.GuildMemberRemove, LogChannelType.GuildMemberKick => guild.Channels.GuildMemberKick, LogChannelType.GuildBanAdd => guild.Channels.GuildBanAdd, @@ -302,6 +303,7 @@ public enum LogChannelType GuildKeyRoleUpdate, GuildMemberNickUpdate, GuildMemberAvatarUpdate, + GuildMemberTimeout, GuildMemberRemove, GuildMemberKick, GuildBanAdd,