246 lines
9.3 KiB
C#
246 lines
9.3 KiB
C#
// 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/>.
|
|
|
|
using Catalogger.Backend.Cache;
|
|
using Catalogger.Backend.Cache.InMemoryCache;
|
|
using Catalogger.Backend.Database;
|
|
using Catalogger.Backend.Database.Dapper.Repositories;
|
|
using Catalogger.Backend.Database.Queries;
|
|
using Catalogger.Backend.Extensions;
|
|
using Catalogger.Backend.Services;
|
|
using Humanizer;
|
|
using Microsoft.EntityFrameworkCore;
|
|
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,
|
|
DatabaseContext db,
|
|
GuildRepository guildRepository,
|
|
IMemberCache memberCache,
|
|
IInviteCache inviteCache,
|
|
UserCache userCache,
|
|
WebhookExecutorService webhookExecutor,
|
|
IDiscordRestGuildAPI guildApi,
|
|
PluralkitApiService pluralkitApi
|
|
) : IResponder<IGuildMemberAdd>
|
|
{
|
|
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]);
|
|
|
|
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<t:{user.ID.Timestamp.ToUnixTimeSeconds()}:F>";
|
|
|
|
var pkSystem = await pluralkitApi.GetPluralKitSystemAsync(user.ID.Value, ct);
|
|
if (pkSystem != null)
|
|
{
|
|
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}
|
|
"""
|
|
);
|
|
}
|
|
|
|
// 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:
|
|
|
|
List<Embed> 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 db.GetWatchlistEntryAsync(member.GuildID, user.ID, ct);
|
|
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<IEmbed>().ToList(),
|
|
[]
|
|
);
|
|
else
|
|
webhookExecutor.QueueLog(guildConfig.Channels.GuildMemberAdd, embeds[0]);
|
|
|
|
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
|
|
);
|
|
}
|
|
}
|