feat: guild ban add/remove logging, store banned systems in database
This commit is contained in:
parent
ca99bdfb94
commit
8e030acaf3
12 changed files with 227 additions and 36 deletions
|
|
@ -0,0 +1,90 @@
|
|||
using Catalogger.Backend.Cache.InMemoryCache;
|
||||
using Catalogger.Backend.Database;
|
||||
using Catalogger.Backend.Database.Queries;
|
||||
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.Guilds;
|
||||
|
||||
public class GuildBanAddResponder(
|
||||
ILogger logger,
|
||||
DatabaseContext db,
|
||||
WebhookExecutorService webhookExecutor,
|
||||
UserCache userCache,
|
||||
AuditLogCache auditLogCache,
|
||||
PluralkitApiService pluralkitApi
|
||||
) : IResponder<IGuildBanAdd>
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<GuildBanAddResponder>();
|
||||
|
||||
public async Task<Result> RespondAsync(IGuildBanAdd evt, CancellationToken ct = default)
|
||||
{
|
||||
var guildConfig = await db.GetGuildAsync(evt.GuildID, ct);
|
||||
|
||||
// Delay 2 seconds for the audit log
|
||||
await Task.Delay(2000, ct);
|
||||
|
||||
var embed = new EmbedBuilder()
|
||||
.WithTitle("User banned")
|
||||
.WithAuthor(evt.User.Tag(), null, evt.User.AvatarUrl())
|
||||
.WithDescription($"<@{evt.User.ID}>")
|
||||
.WithColour(DiscordUtils.Red)
|
||||
.WithFooter($"User ID: {evt.User.ID}")
|
||||
.WithCurrentTimestamp();
|
||||
|
||||
if (auditLogCache.TryGetBan(evt.GuildID, evt.User.ID, out var actionData))
|
||||
{
|
||||
embed.AddField(
|
||||
"Responsible moderator",
|
||||
await userCache.TryFormatUserAsync(actionData.ModeratorId)
|
||||
);
|
||||
embed.AddField("Reason", actionData.Reason ?? "No reason given");
|
||||
}
|
||||
else
|
||||
{
|
||||
embed.AddField("Responsible moderator", "*(unknown)*");
|
||||
embed.AddField("Reason", "*(unknown)*");
|
||||
}
|
||||
|
||||
// Get PluralKit system, if any, and add it to the guild's banned systems list
|
||||
var pkSystem = await pluralkitApi.GetPluralKitSystemAsync(evt.User.ID.Value, ct);
|
||||
if (pkSystem != null)
|
||||
{
|
||||
if (!guildConfig.IsSystemBanned(pkSystem))
|
||||
{
|
||||
_logger.Information(
|
||||
"PluralKit system {SystemHid} will be banned from guild {GuildId}",
|
||||
pkSystem.Id,
|
||||
evt.GuildID
|
||||
);
|
||||
guildConfig.BannedSystems.Add(pkSystem.Id);
|
||||
guildConfig.BannedSystems.Add(pkSystem.Uuid.ToString());
|
||||
db.Update(guildConfig);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
embed.AddField(
|
||||
"PluralKit system",
|
||||
$"""
|
||||
**ID:** {pkSystem.Id}
|
||||
**UUID:** `{pkSystem.Uuid}`
|
||||
**Name:** {pkSystem.Name ?? "*(none)*"}
|
||||
**Tag:** {pkSystem.Tag ?? "*(none)*"}
|
||||
|
||||
This system has been marked as banned. You will be warned if another account linked to this system joins.
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
webhookExecutor.QueueLog(
|
||||
guildConfig,
|
||||
LogChannelType.GuildBanAdd,
|
||||
embed.Build().GetOrThrow()
|
||||
);
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
using Catalogger.Backend.Cache.InMemoryCache;
|
||||
using Catalogger.Backend.Database;
|
||||
using Catalogger.Backend.Database.Queries;
|
||||
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.Guilds;
|
||||
|
||||
public class GuildBanRemoveResponder(
|
||||
ILogger logger,
|
||||
DatabaseContext db,
|
||||
WebhookExecutorService webhookExecutor,
|
||||
UserCache userCache,
|
||||
AuditLogCache auditLogCache,
|
||||
PluralkitApiService pluralkitApi
|
||||
) : IResponder<IGuildBanRemove>
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<GuildBanRemoveResponder>();
|
||||
|
||||
public async Task<Result> RespondAsync(IGuildBanRemove evt, CancellationToken ct = default)
|
||||
{
|
||||
var guildConfig = await db.GetGuildAsync(evt.GuildID, ct);
|
||||
|
||||
// Delay 2 seconds for the audit log
|
||||
await Task.Delay(2000, ct);
|
||||
|
||||
var embed = new EmbedBuilder()
|
||||
.WithTitle("User unbanned")
|
||||
.WithAuthor(evt.User.Tag(), null, evt.User.AvatarUrl())
|
||||
.WithDescription($"<@{evt.User.ID}>")
|
||||
.WithColour(DiscordUtils.Green)
|
||||
.WithFooter($"User ID: {evt.User.ID}")
|
||||
.WithCurrentTimestamp();
|
||||
|
||||
if (auditLogCache.TryGetUnban(evt.GuildID, evt.User.ID, out var actionData))
|
||||
{
|
||||
embed.AddField(
|
||||
"Responsible moderator",
|
||||
await userCache.TryFormatUserAsync(actionData.ModeratorId)
|
||||
);
|
||||
embed.AddField("Reason", actionData.Reason ?? "No reason given");
|
||||
}
|
||||
else
|
||||
{
|
||||
embed.AddField("Responsible moderator", "*(unknown)*");
|
||||
embed.AddField("Reason", "*(unknown)*");
|
||||
}
|
||||
|
||||
var pkSystem = await pluralkitApi.GetPluralKitSystemAsync(evt.User.ID.Value, ct);
|
||||
if (pkSystem != null)
|
||||
{
|
||||
guildConfig.BannedSystems.Remove(pkSystem.Id);
|
||||
guildConfig.BannedSystems.Remove(pkSystem.Uuid.ToString());
|
||||
db.Update(guildConfig);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
embed.AddField(
|
||||
"PluralKit system",
|
||||
$"""
|
||||
**ID:** {pkSystem.Id}
|
||||
**UUID:** `{pkSystem.Uuid}`
|
||||
**Name:** {pkSystem.Name ?? "*(none)*"}
|
||||
**Tag:** {pkSystem.Tag ?? "*(none)*"}
|
||||
|
||||
This system has been unbanned.
|
||||
Note that other accounts linked to the system might still be banned, check `pk;system {pkSystem.Id}` for the linked accounts.
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
webhookExecutor.QueueLog(
|
||||
guildConfig,
|
||||
LogChannelType.GuildBanRemove,
|
||||
embed.Build().GetOrThrow()
|
||||
);
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
using Catalogger.Backend.Cache;
|
||||
using Catalogger.Backend.Cache.InMemoryCache;
|
||||
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;
|
||||
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.Guilds;
|
||||
|
||||
public class GuildMemberAddResponder(
|
||||
ILogger logger,
|
||||
DatabaseContext db,
|
||||
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);
|
||||
|
||||
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 db.GetGuildAsync(member.GuildID, ct);
|
||||
var guildRes = await guildApi.GetGuildAsync(member.GuildID, withCounts: true, ct);
|
||||
if (guildRes.IsSuccess)
|
||||
builder.Description +=
|
||||
$"\n{guildRes.Entity.ApproximateMemberCount.Value.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)
|
||||
{
|
||||
if (
|
||||
guildConfig.BannedSystems.Contains(pkSystem.Id)
|
||||
|| guildConfig.BannedSystems.Contains(pkSystem.Uuid.ToString())
|
||||
)
|
||||
{
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
using Catalogger.Backend.Cache;
|
||||
using Catalogger.Backend.Cache.InMemoryCache;
|
||||
using Catalogger.Backend.Database;
|
||||
using Catalogger.Backend.Database.Queries;
|
||||
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.Guilds;
|
||||
|
||||
public class GuildMemberRemoveResponder(
|
||||
ILogger logger,
|
||||
DatabaseContext db,
|
||||
IMemberCache memberCache,
|
||||
RoleCache roleCache,
|
||||
UserCache userCache,
|
||||
AuditLogCache auditLogCache,
|
||||
WebhookExecutorService webhookExecutor
|
||||
) : IResponder<IGuildMemberRemove>
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<GuildMemberAddResponder>();
|
||||
|
||||
public async Task<Result> 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 db.GetGuildAsync(evt.GuildID, ct);
|
||||
|
||||
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 <t:{member.JoinedAt.ToUnixTimeSeconds()}>\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.TryFormatModeratorAsync(actionData)
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,352 +0,0 @@
|
|||
using Catalogger.Backend.Cache;
|
||||
using Catalogger.Backend.Cache.InMemoryCache;
|
||||
using Catalogger.Backend.Database;
|
||||
using Catalogger.Backend.Database.Queries;
|
||||
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.Guilds;
|
||||
|
||||
public class GuildMemberUpdateResponder(
|
||||
ILogger logger,
|
||||
DatabaseContext db,
|
||||
UserCache userCache,
|
||||
RoleCache roleCache,
|
||||
IMemberCache memberCache,
|
||||
WebhookExecutorService webhookExecutor,
|
||||
AuditLogCache auditLogCache
|
||||
) : IResponder<IGuildMemberUpdate>
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<GuildMemberUpdateResponder>();
|
||||
|
||||
public async Task<Result> 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<Result> 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 db.GetGuildAsync(newMember.GuildID, ct);
|
||||
webhookExecutor.QueueLog(guildConfig, LogChannelType.GuildMemberAvatarUpdate, embed);
|
||||
return Result.Success;
|
||||
}
|
||||
|
||||
private async Task<Result> 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()}
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
var guildConfig = await db.GetGuildAsync(newMember.GuildID, ct);
|
||||
webhookExecutor.QueueLog(
|
||||
guildConfig,
|
||||
LogChannelType.GuildMemberNickUpdate,
|
||||
builder.Build().GetOrThrow()
|
||||
);
|
||||
return Result.Success;
|
||||
}
|
||||
|
||||
private async Task<Result> 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",
|
||||
$"<t:{timeoutUntil.Value.ToUnixTimeSeconds()}>\nin {timeoutUntil.Value.AddSeconds(5).Prettify()}"
|
||||
);
|
||||
}
|
||||
|
||||
if (auditLogCache.TryGetMemberUpdate(member.GuildID, member.User.ID, out var actionData))
|
||||
{
|
||||
var moderator = await userCache.TryFormatModeratorAsync(actionData);
|
||||
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 db.GetGuildAsync(member.GuildID, ct);
|
||||
webhookExecutor.QueueLog(
|
||||
guildConfig,
|
||||
LogChannelType.GuildMemberTimeout,
|
||||
embed.Build().GetOrThrow()
|
||||
);
|
||||
return Result.Success;
|
||||
}
|
||||
|
||||
private async Task<Result> HandleRoleUpdateAsync(
|
||||
IGuildMemberUpdate member,
|
||||
IReadOnlyList<Snowflake> oldRoles,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var guildConfig = await db.GetGuildAsync(member.GuildID, ct);
|
||||
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("Added", 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.TryFormatModeratorAsync(actionData);
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue