// Copyright (C) 2021-present sam (starshines.gay) // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . using Catalogger.Backend.Cache; using Catalogger.Backend.Cache.InMemoryCache; using Catalogger.Backend.Database.Repositories; 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.Rest.Core; using Remora.Results; namespace Catalogger.Backend.Bot.Responders.Members; public class GuildMemberUpdateResponder( ILogger logger, GuildRepository guildRepository, UserCache userCache, RoleCache roleCache, IMemberCache memberCache, WebhookExecutorService webhookExecutor, AuditLogCache auditLogCache ) : IResponder { private readonly ILogger _logger = logger.ForContext(); public async Task RespondAsync( IGuildMemberUpdate newMember, CancellationToken ct = default ) { try { var oldMember = await memberCache.TryGetAsync(newMember.GuildID, newMember.User.ID); if (oldMember == null) { _logger.Information( "Received member update event for {MemberId} in {GuildId} but they weren't cached, ignoring", newMember.User.ID, newMember.GuildID ); 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); } if ( newMember.Roles.Except(oldMember.Roles).Any() || oldMember.Roles.Except(newMember.Roles).Any() ) { return await HandleRoleUpdateAsync(newMember, oldMember.Roles, 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 guildRepository.GetAsync(newMember.GuildID); 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()} """ ); await memberCache.UpdateMemberNameAsync( newMember.GuildID, newMember.User.ID, oldUser.Tag(), newMember.User.Tag() ); } var guildConfig = await guildRepository.GetAsync(newMember.GuildID); 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.TryFormatUserAsync(actionData.ModeratorId); embed.AddField("Responsible moderator", moderator); embed.AddField("Reason", actionData.Reason ?? "No reason given"); } else { embed.AddField("Responsible moderator", "*(unknown)*"); embed.AddField("Reason", "*(unknown)*"); } var guildConfig = await guildRepository.GetAsync(member.GuildID); webhookExecutor.QueueLog( guildConfig, LogChannelType.GuildMemberTimeout, embed.Build().GetOrThrow() ); return Result.Success; } private async Task HandleRoleUpdateAsync( IGuildMemberUpdate member, IReadOnlyList oldRoles, CancellationToken ct = default ) { var guildConfig = await guildRepository.GetAsync(member.GuildID); var guildRoles = roleCache.GuildRoles(member.GuildID).ToList(); var keyRoleUpdate = new EmbedBuilder() .WithAuthor(member.User.Tag(), null, member.User.AvatarUrl()) .WithTitle("Key roles added or removed") .WithDescription($"<@{member.User.ID}>") .WithColour(DiscordUtils.Purple) .WithFooter($"User ID: {member.User.ID}") .WithCurrentTimestamp(); var roleUpdate = new EmbedBuilder() .WithAuthor(member.User.Tag(), null, member.User.AvatarUrl()) .WithTitle("Roles added or removed") .WithDescription($"<@{member.User.ID}>") .WithColour(DiscordUtils.Purple) .WithFooter($"User ID: {member.User.ID}") .WithCurrentTimestamp(); var addedRoles = member.Roles.Except(oldRoles).Select(s => s.Value).ToList(); var removedRoles = oldRoles.Except(member.Roles).Select(s => s.Value).ToList(); if (addedRoles.Count != 0) { roleUpdate.AddField("Added", string.Join(", ", addedRoles.Select(id => $"<@&{id}>"))); // Add all added key roles to the log if (!addedRoles.Except(guildConfig.KeyRoles).Any()) { var value = string.Join( "\n", addedRoles .Where(guildConfig.KeyRoles.Contains) .Select(id => { var role = guildRoles.FirstOrDefault(r => r.ID.Value == id); return role != null ? $"{role.Name} <@&{role.ID}>" : $"<@&{id}>"; }) ); keyRoleUpdate.AddField("Added", value); } } if (removedRoles.Count != 0) { roleUpdate.AddField( "Removed", string.Join(", ", removedRoles.Select(id => $"<@&{id}>")) ); // Add all removed key roles to the log if (!removedRoles.Except(guildConfig.KeyRoles).Any()) { var value = string.Join( "\n", removedRoles .Where(guildConfig.KeyRoles.Contains) .Select(id => { var role = guildRoles.FirstOrDefault(r => r.ID.Value == id); return role != null ? $"{role.Name} <@&{role.ID}>" : $"<@&{id}>"; }) ); keyRoleUpdate.AddField("Removed", value); } } // If there are any fields in the role update embed, we should send it if (roleUpdate.Fields.Count != 0) { webhookExecutor.QueueLog( guildConfig, LogChannelType.GuildMemberUpdate, roleUpdate.Build().GetOrThrow() ); } // Do the same for the key role update embed, but we also need to fetch the moderator that updated them if (keyRoleUpdate.Fields.Count != 0) { if ( auditLogCache.TryGetMemberUpdate(member.GuildID, member.User.ID, out var actionData) ) { var moderator = await userCache.TryFormatUserAsync(actionData.ModeratorId); keyRoleUpdate.AddField("Responsible moderator", moderator); } else { keyRoleUpdate.AddField("Responsible moderator", "*(unknown)*"); } // Finally, send the embed webhookExecutor.QueueLog( guildConfig, LogChannelType.GuildKeyRoleUpdate, keyRoleUpdate.Build().GetOrThrow() ); } return Result.Success; } }