// 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 Humanizer; using Remora.Discord.API; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Objects; using Remora.Discord.Extensions.Embeds; using Remora.Discord.Gateway.Responders; using Remora.Results; namespace Catalogger.Backend.Bot.Responders.Members; public class GuildMemberAddResponder( ILogger logger, InviteRepository inviteRepository, GuildRepository guildRepository, WatchlistRepository watchlistRepository, IMemberCache memberCache, IInviteCache inviteCache, UserCache userCache, WebhookExecutorService webhookExecutor, IDiscordRestGuildAPI guildApi, PluralkitApiService pluralkitApi ) : IResponder { private readonly ILogger _logger = logger.ForContext(); private static readonly TimeSpan NewAccountThreshold = 7.Days(); public async Task RespondAsync(IGuildMemberAdd member, CancellationToken ct = default) { using var _ = LogUtils.Enrich(member); await memberCache.SetAsync(member.GuildID, member); await memberCache.SetMemberNamesAsync(member.GuildID, [member]); var user = member.User.GetOrThrow(); userCache.UpdateUser(user); var builder = new EmbedBuilder() .WithTitle("Member joined") .WithColour(DiscordUtils.Green) .WithAuthor(user.Tag(), null, user.AvatarUrl()) .WithDescription($"<@{user.ID}>") .WithCurrentTimestamp() .WithFooter($"ID: {user.ID}"); var guildConfig = await guildRepository.GetAsync(member.GuildID); var guildRes = await guildApi.GetGuildAsync(member.GuildID, withCounts: true, ct); if (guildRes.IsSuccess && guildRes.Entity.ApproximateMemberCount.IsDefined()) builder.Description += $"\n{guildRes.Entity.ApproximateMemberCount.OrDefault(1).Ordinalize()} to join"; builder.Description += $"\ncreated {user.ID.Timestamp.Prettify()} ago\n"; var pkSystem = await pluralkitApi.GetPluralKitSystemAsync(user.ID.Value, ct); if (pkSystem != null) { var createdAt = pkSystem.Created != null ? $"{pkSystem.Created.Value.Prettify()} ago ()" : "*(unknown)*"; builder.AddField( "PluralKit system", $""" **ID:** {pkSystem.Id} (`{pkSystem.Uuid}`) **Name:** {pkSystem.Name ?? "*(none)*"} **Tag:** {pkSystem.Tag ?? "*(none)*"} **Created:** {createdAt} """ ); } // Bots don't use invites, so the entire following block is useless if (user.IsBot.OrDefault()) { goto afterInvite; } var existingInvites = (await inviteCache.TryGetAsync(member.GuildID)).ToList(); var newInvitesRes = await guildApi.GetGuildInvitesAsync(member.GuildID, ct); if (!newInvitesRes.IsSuccess) { _logger.Error( "Could not fetch invites for guild {GuildId}: {Error}", member.GuildID, newInvitesRes.Error ); goto afterInvite; } // Update the invite cache immediately--we've already fetched a copy of the invites, after all await inviteCache.SetAsync(member.GuildID, newInvitesRes.Entity); // If we can't find a used invite, and the guild has a vanity link, that invite was used. // Otherwise, we give up var usedInvite = FindUsedInvite(existingInvites, newInvitesRes.Entity); if (usedInvite == null) { builder.AddField( "Invite used", guildRes is { IsSuccess: true, Entity.VanityUrlCode: not null } ? $"Vanity invite (`{guildRes.Entity.VanityUrlCode}`)" : "*Could not determine invite, sorry.*" ); goto afterInvite; } var inviteName = await inviteRepository.GetInviteNameAsync(member.GuildID, usedInvite.Code); var inviteDescription = $""" **Code:** {usedInvite.Code} **Name:** {inviteName} **Uses:** {usedInvite.Uses} **Created at:** """; if (usedInvite.Inviter.IsDefined(out var inviter)) inviteDescription += $"\n**Created by:** {inviter.Tag()} <@{inviter.ID}>"; builder.AddField("Invite used", inviteDescription); afterInvite: List embeds = [builder.Build().GetOrThrow()]; if (user.ID.Timestamp > DateTimeOffset.Now - NewAccountThreshold) { embeds.Add( new EmbedBuilder() .WithTitle("New account") .WithColour(DiscordUtils.Orange) .WithDescription($"\u26a0\ufe0f Created {user.ID.Timestamp.Prettify()} ago") .Build() .GetOrThrow() ); } var watchlist = await watchlistRepository.GetEntryAsync(member.GuildID, user.ID); if (watchlist != null) { var moderator = await userCache.GetUserAsync( DiscordSnowflake.New(watchlist.ModeratorId) ); var mod = moderator != null ? $"{moderator.Tag()} (<@{moderator.ID}>)" : $"<@{watchlist.ModeratorId}>"; embeds.Add( new EmbedBuilder() .WithTitle("⚠️ User on watchlist") .WithColour(DiscordUtils.Red) .WithDescription( $"**{user.Tag()}** is on this server's watch list.\n\n{watchlist.Reason}" ) .WithFooter($"ID: {user.ID} | Added") .WithTimestamp(watchlist.AddedAt.ToDateTimeOffset()) .AddField("Moderator", mod) .GetOrThrow() .Build() .GetOrThrow() ); } if (pkSystem != null && guildConfig.IsSystemBanned(pkSystem)) { 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) await webhookExecutor.SendLogAsync( guildConfig.Channels.GuildMemberAdd, embeds.Cast().ToList(), [] ); else webhookExecutor.QueueLog(guildConfig.Channels.GuildMemberAdd, embeds[0]); return Result.Success; } private static IInviteWithMetadata? FindUsedInvite( List existingInvites, IReadOnlyList newInvites ) { // First, we check all invites in *both* lists, and look for one that has more uses now than before. // If one matches that, it's probably the used invite. var usedInvite = existingInvites.FirstOrDefault(e => newInvites.Any(n => e.Code == n.Code && e.Uses < n.Uses) ); if (usedInvite != null) return usedInvite; // Then we check all new invites (i.e. ones that don't exist in the old list, but do in the new one) // and check for one that has one use. usedInvite = newInvites.FirstOrDefault(n => existingInvites.All(e => e.Code != n.Code) && n.Uses == 1 ); if (usedInvite != null) return usedInvite; // Finally, we check invites that exist in the old list but not the new one, and were one use away from expiry. // If one matches, we can safely say it was the used invite. return existingInvites.FirstOrDefault(e => newInvites.All(n => n.Code != e.Code) && e.MaxUses == e.Uses - 1 ); } }