// 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.Extensions.Embeds; using Remora.Discord.Gateway.Responders; using Remora.Results; namespace Catalogger.Backend.Bot.Responders.Members; public class GuildMemberRemoveResponder( ILogger logger, GuildRepository guildRepository, IMemberCache memberCache, RoleCache roleCache, UserCache userCache, AuditLogCache auditLogCache, WebhookExecutorService webhookExecutor ) : IResponder { private readonly ILogger _logger = logger.ForContext(); public async Task RespondAsync(IGuildMemberRemove evt, CancellationToken ct = default) { try { var embed = new EmbedBuilder() .WithTitle("Member left") .WithAuthor(evt.User.Tag(), iconUrl: evt.User.AvatarUrl()) .WithColour(DiscordUtils.Orange) .WithDescription($"<@{evt.User.ID}>") .WithFooter($"ID: {evt.User.ID}") .WithCurrentTimestamp(); var guildConfig = await guildRepository.GetAsync(evt.GuildID); var member = await memberCache.TryGetAsync(evt.GuildID, evt.User.ID); 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() ); // Check for a kick audit log event. We don't get a separate "kick" event so we have to check this manually await Task.Delay(2000, ct); if (!auditLogCache.TryGetKick(evt.GuildID, evt.User.ID, out var actionData)) { return Result.Success; } var kick = new EmbedBuilder() .WithTitle("User kicked") .WithAuthor(evt.User.Tag(), iconUrl: evt.User.AvatarUrl()) .WithColour(DiscordUtils.Red) .WithCurrentTimestamp() .WithDescription($"<@{evt.User.ID}>"); kick.AddField( "Responsible moderator", await userCache.TryFormatUserAsync(actionData.ModeratorId) ); kick.AddField("Reason", actionData.Reason ?? "No reason given"); webhookExecutor.QueueLog( guildConfig, LogChannelType.GuildMemberKick, kick.Build().GetOrThrow() ); return Result.Success; } finally { await memberCache.RemoveAsync(evt.GuildID, evt.User.ID); await memberCache.TryRemoveMemberNameAsync(evt.GuildID, evt.User.Username); } } }