Catalogger.NET/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs

245 lines
9.3 KiB
C#
Raw Normal View History

// 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 <https://www.gnu.org/licenses/>.
2024-08-20 21:03:03 +02:00
using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
2024-08-20 21:03:03 +02:00
using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Queries;
using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services;
using Humanizer;
using Microsoft.EntityFrameworkCore;
using Remora.Discord.API;
2024-08-20 21:03:03 +02:00
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
2024-08-20 21:03:03 +02:00
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;
2024-08-20 21:03:03 +02:00
public class GuildMemberAddResponder(
ILogger logger,
DatabaseContext db,
IMemberCache memberCache,
IInviteCache inviteCache,
UserCache userCache,
2024-08-20 21:03:03 +02:00
WebhookExecutorService webhookExecutor,
IDiscordRestGuildAPI guildApi,
2024-10-09 17:35:11 +02:00
PluralkitApiService pluralkitApi
) : IResponder<IGuildMemberAdd>
2024-08-20 21:03:03 +02:00
{
private readonly ILogger _logger = logger.ForContext<GuildMemberAddResponder>();
private static readonly TimeSpan NewAccountThreshold = 7.Days();
public async Task<Result> RespondAsync(IGuildMemberAdd member, CancellationToken ct = default)
{
await memberCache.SetAsync(member.GuildID, member);
await memberCache.SetMemberNamesAsync(member.GuildID, [member]);
2024-08-20 21:03:03 +02:00
var user = member.User.GetOrThrow();
userCache.UpdateUser(user);
2024-08-20 21:03:03 +02:00
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 db.GetGuildAsync(member.GuildID, false, ct);
2024-08-20 21:03:03 +02:00
var guildRes = await guildApi.GetGuildAsync(member.GuildID, withCounts: true, ct);
if (guildRes.IsSuccess && guildRes.Entity.ApproximateMemberCount.IsDefined())
2024-10-09 17:35:11 +02:00
builder.Description +=
$"\n{guildRes.Entity.ApproximateMemberCount.OrDefault(1).Ordinalize()} to join";
2024-08-20 21:03:03 +02:00
builder.Description +=
$"\ncreated {user.ID.Timestamp.Prettify()} ago\n<t:{user.ID.Timestamp.ToUnixTimeSeconds()}:F>";
2024-08-20 21:03:03 +02:00
var pkSystem = await pluralkitApi.GetPluralKitSystemAsync(user.ID.Value, ct);
if (pkSystem != null)
{
2024-10-09 17:35:11 +02:00
var createdAt =
pkSystem.Created != null
? $"{pkSystem.Created.Value.Prettify()} ago (<t:{pkSystem.Created.Value.ToUnixTimeSeconds()}:F>)"
: "*(unknown)*";
builder.AddField(
"PluralKit system",
$"""
**ID:** {pkSystem.Id} (`{pkSystem.Uuid}`)
**Name:** {pkSystem.Name ?? "*(none)*"}
**Tag:** {pkSystem.Tag ?? "*(none)*"}
**Created:** {createdAt}
"""
);
2024-08-20 21:03:03 +02:00
}
// 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 db
.Invites.Where(i => i.Code == usedInvite.Code && i.GuildId == member.GuildID.Value)
.Select(i => i.Name)
.FirstOrDefaultAsync(ct) ?? "*(unnamed)*";
var inviteDescription = $"""
**Code:** {usedInvite.Code}
**Name:** {inviteName}
**Uses:** {usedInvite.Uses}
**Created at:** <t:{usedInvite.CreatedAt.ToUnixTimeSeconds()}>
""";
if (usedInvite.Inviter.IsDefined(out var inviter))
inviteDescription += $"\n**Created by:** {inviter.Tag()} <@{inviter.ID}>";
builder.AddField("Invite used", inviteDescription);
afterInvite:
2024-08-20 21:03:03 +02:00
List<Embed> embeds = [builder.Build().GetOrThrow()];
if (user.ID.Timestamp > DateTimeOffset.Now - NewAccountThreshold)
{
2024-10-09 17:35:11 +02:00
embeds.Add(
new EmbedBuilder()
.WithTitle("New account")
.WithColour(DiscordUtils.Orange)
.WithDescription($"\u26a0\ufe0f Created {user.ID.Timestamp.Prettify()} ago")
.Build()
.GetOrThrow()
);
}
var watchlist = await db.GetWatchlistEntryAsync(member.GuildID, user.ID, ct);
if (watchlist != null)
{
2024-10-09 17:35:11 +02:00
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()
);
2024-08-20 21:03:03 +02:00
}
if (pkSystem != null && guildConfig.IsSystemBanned(pkSystem))
2024-08-20 21:03:03 +02:00
{
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()
);
2024-08-20 21:03:03 +02:00
}
if (embeds.Count > 1)
2024-10-09 17:35:11 +02:00
await webhookExecutor.SendLogAsync(
guildConfig.Channels.GuildMemberAdd,
embeds.Cast<IEmbed>().ToList(),
[]
);
else
webhookExecutor.QueueLog(guildConfig.Channels.GuildMemberAdd, embeds[0]);
2024-08-20 21:03:03 +02:00
return Result.Success;
}
private static IInviteWithMetadata? FindUsedInvite(
List<IInviteWithMetadata> existingInvites,
IReadOnlyList<IInviteWithMetadata> 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
);
}
2024-10-09 17:35:11 +02:00
}