feat: role delete logging, used invite logging, also some random changes

This commit is contained in:
sam 2024-10-09 22:31:58 +02:00
parent 4f54077c68
commit c906a4d6b6
18 changed files with 386 additions and 76 deletions

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="com.intellij.csharpier">
<option name="customPath" value="" />
<option name="runOnSave" value="true" />
</component>
</project>

View file

@ -1,5 +1,4 @@
using Catalogger.Backend.Cache.InMemoryCache;
using Newtonsoft.Json;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.Gateway.Responders;
using Remora.Results;

View file

@ -5,6 +5,7 @@ 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;
@ -20,6 +21,7 @@ public class GuildMemberAddResponder(
ILogger logger,
DatabaseContext db,
IMemberCache memberCache,
IInviteCache inviteCache,
UserCache userCache,
WebhookExecutorService webhookExecutor,
IDiscordRestGuildAPI guildApi,
@ -70,7 +72,62 @@ public class GuildMemberAddResponder(
);
}
// TODO: find used invite
// 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()];
@ -145,4 +202,32 @@ public class GuildMemberAddResponder(
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
);
}
}

View file

@ -16,8 +16,7 @@ public class GuildMemberRemoveResponder(
DatabaseContext db,
IMemberCache memberCache,
RoleCache roleCache,
WebhookExecutorService webhookExecutor,
AuditLogEnrichedResponderService auditLogEnrichedResponderService
WebhookExecutorService webhookExecutor
) : IResponder<IGuildMemberRemove>
{
private readonly ILogger _logger = logger.ForContext<GuildMemberAddResponder>();
@ -26,9 +25,6 @@ public class GuildMemberRemoveResponder(
{
try
{
// spin events that Discord doesn't send us all the data for off to another responder
_ = auditLogEnrichedResponderService.RespondAsync(evt);
var embed = new EmbedBuilder()
.WithTitle("Member left")
.WithAuthor(evt.User.Tag(), iconUrl: evt.User.AvatarUrl())

View file

@ -0,0 +1,38 @@
using Catalogger.Backend.Cache;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
namespace Catalogger.Backend.Bot.Responders.MemberUpdate;
public class GuildMemberUpdateResponder(ILogger logger, IMemberCache memberCache)
: 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;
}
}
finally
{
await memberCache.UpdateAsync(newMember);
}
return Result.Success;
}
}

View file

@ -0,0 +1,71 @@
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.Roles;
public class RoleDeleteResponder(
ILogger logger,
DatabaseContext db,
RoleCache roleCache,
WebhookExecutorService webhookExecutor
) : IResponder<IGuildRoleDelete>
{
private readonly ILogger _logger = logger.ForContext<RoleDeleteResponder>();
public async Task<Result> RespondAsync(IGuildRoleDelete evt, CancellationToken ct = default)
{
try
{
if (!roleCache.TryGet(evt.RoleID, out var role))
{
_logger.Information(
"Received role delete event for {RoleId} but it wasn't cached, ignoring",
evt.RoleID
);
return Result.Success;
}
var guildConfig = await db.GetGuildAsync(evt.GuildID, ct);
var embed = new EmbedBuilder()
.WithTitle($"Role \"{role.Name}\" deleted")
.WithColour(DiscordUtils.Red)
.WithFooter($"ID: {role.ID}")
.WithCurrentTimestamp()
.WithDescription(
$"""
**Name:** {role.Name}
**Colour:** {role.Colour.ToPrettyString()}
**Mentionable:** {role.IsMentionable}
**Shown separately:** {role.IsHoisted}
**Position:** {role.Position}
Created <t:{role.ID.Timestamp.ToUnixTimeSeconds()}> ({role.ID.Timestamp.Prettify()} ago)
"""
);
if (!role.Permissions.Value.IsZero)
{
embed.AddField("Permissions", role.Permissions.ToPrettyString());
}
webhookExecutor.QueueLog(
guildConfig,
LogChannelType.GuildRoleDelete,
embed.Build().GetOrThrow()
);
}
finally
{
roleCache.Remove(evt.RoleID, evt.GuildID, out _);
}
return Result.Success;
}
}

View file

@ -78,10 +78,7 @@ public class RoleUpdateResponder(
// All updates are shown in fields. If there are no fields, there were no updates we care about
// (we don't care about position, for example, because it's not actually useful)
if (embed.Fields.Count == 0)
{
_logger.Debug("We don't care about update of role {RoleId}, ignoring", evt.Role.ID);
return Result.Success;
}
var guildConfig = await db.GetGuildAsync(evt.GuildID, ct);
webhookExecutor.QueueLog(

View file

@ -5,6 +5,6 @@ namespace Catalogger.Backend.Cache;
public interface IInviteCache
{
public Task<IEnumerable<IInvite>> TryGetAsync(Snowflake guildId);
public Task SetAsync(Snowflake guildId, IEnumerable<IInvite> invites);
public Task<IEnumerable<IInviteWithMetadata>> TryGetAsync(Snowflake guildId);
public Task SetAsync(Snowflake guildId, IEnumerable<IInviteWithMetadata> invites);
}

View file

@ -1,3 +1,4 @@
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core;
@ -12,4 +13,5 @@ public interface IMemberCache
public Task<bool> IsGuildCachedAsync(Snowflake guildId);
public Task MarkAsCachedAsync(Snowflake guildId);
public Task MarkAsUncachedAsync(Snowflake guildId);
public Task UpdateAsync(IGuildMemberUpdate newMember);
}

View file

@ -6,14 +6,15 @@ namespace Catalogger.Backend.Cache.InMemoryCache;
public class InMemoryInviteCache : IInviteCache
{
private readonly ConcurrentDictionary<Snowflake, IEnumerable<IInvite>> _invites = new();
private readonly ConcurrentDictionary<Snowflake, IEnumerable<IInviteWithMetadata>> _invites =
new();
public Task<IEnumerable<IInvite>> TryGetAsync(Snowflake guildId) =>
public Task<IEnumerable<IInviteWithMetadata>> TryGetAsync(Snowflake guildId) =>
_invites.TryGetValue(guildId, out var invites)
? Task.FromResult(invites)
: Task.FromResult<IEnumerable<IInvite>>([]);
: Task.FromResult<IEnumerable<IInviteWithMetadata>>([]);
public Task SetAsync(Snowflake guildId, IEnumerable<IInvite> invites)
public Task SetAsync(Snowflake guildId, IEnumerable<IInviteWithMetadata> invites)
{
_invites[guildId] = invites;
return Task.CompletedTask;

View file

@ -1,12 +1,19 @@
using System.Collections.Concurrent;
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.Rest.Core;
namespace Catalogger.Backend.Cache.InMemoryCache;
public class InMemoryMemberCache : IMemberCache
public class InMemoryMemberCache(IDiscordRestGuildAPI guildApi, ILogger logger) : IMemberCache
{
private readonly ConcurrentDictionary<(Snowflake, Snowflake), IGuildMember> _members = new();
private readonly ILogger _logger = logger.ForContext<InMemoryMemberCache>();
private readonly ConcurrentDictionary<
(Snowflake GuildId, Snowflake UserId),
IGuildMember
> _members = new();
private readonly ConcurrentDictionary<Snowflake, byte> _guilds = new();
#pragma warning disable CS8619 // Nullability of reference types in value doesn't match target type.
@ -52,4 +59,56 @@ public class InMemoryMemberCache : IMemberCache
_guilds.Remove(guildId, out _);
return Task.CompletedTask;
}
public async Task UpdateAsync(IGuildMemberUpdate newMember)
{
if (!_members.TryGetValue((newMember.GuildID, newMember.User.ID), out var oldMember))
{
_logger.Warning(
"Received member update event for {MemberId} in {GuildId}, but member wasn't found in cache. Fetching them and storing.",
newMember.User.ID,
newMember.GuildID
);
var memberResult = await guildApi.GetGuildMemberAsync(
newMember.GuildID,
newMember.User.ID
);
if (!memberResult.IsSuccess)
{
_logger.Error(
"Could not get uncached member {MemberId} in {GuildId} via REST: {Error}",
newMember.User.ID,
newMember.GuildID,
memberResult.Error
);
return;
}
_members[(newMember.GuildID, newMember.User.ID)] = memberResult.Entity;
return;
}
// Unwrapping the interface because fuck it, we ball
var member = (GuildMember)oldMember with
{
User = new Optional<IUser>(newMember.User),
Nickname = newMember.Nickname.HasValue ? newMember.Nickname.Value : oldMember.Nickname,
Avatar = newMember.Avatar.HasValue ? newMember.Avatar : oldMember.Avatar,
Roles = newMember.Roles.ToArray(),
JoinedAt = newMember.JoinedAt ?? oldMember.JoinedAt,
PremiumSince = newMember.PremiumSince.HasValue
? newMember.PremiumSince.Value
: oldMember.PremiumSince,
IsPending = newMember.IsPending.HasValue
? newMember.IsPending.Value
: oldMember.IsPending,
CommunicationDisabledUntil = newMember.CommunicationDisabledUntil.HasValue
? newMember.CommunicationDisabledUntil.Value
: oldMember.CommunicationDisabledUntil,
};
_members[(newMember.GuildID, newMember.User.ID)] = member;
}
}

View file

@ -8,14 +8,14 @@ namespace Catalogger.Backend.Cache.RedisCache;
public class RedisInviteCache(RedisService redisService) : IInviteCache
{
public async Task<IEnumerable<IInvite>> TryGetAsync(Snowflake guildId)
public async Task<IEnumerable<IInviteWithMetadata>> TryGetAsync(Snowflake guildId)
{
var redisInvites =
await redisService.GetAsync<List<RedisInvite>>(InvitesKey(guildId)) ?? [];
return redisInvites.Select(r => r.ToRemoraInvite());
}
public async Task SetAsync(Snowflake guildId, IEnumerable<IInvite> invites) =>
public async Task SetAsync(Snowflake guildId, IEnumerable<IInviteWithMetadata> invites) =>
await redisService.SetAsync(InvitesKey(guildId), invites.Select(RedisInvite.FromIInvite));
private static string InvitesKey(Snowflake guildId) => $"guild-invites:{guildId}";
@ -25,24 +25,39 @@ internal record RedisInvite(
string Code,
RedisPartialGuild? Guild,
RedisPartialChannel? Channel,
int Uses,
int MaxUses,
TimeSpan MaxAge,
bool IsTemporary,
DateTimeOffset CreatedAt,
RedisUser? Inviter,
DateTimeOffset? ExpiresAt
)
{
public static RedisInvite FromIInvite(IInvite invite) =>
public static RedisInvite FromIInvite(IInviteWithMetadata invite) =>
new(
invite.Code,
invite.Guild.Map(RedisPartialGuild.FromIPartialGuild).OrDefault(),
invite.Channel != null ? RedisPartialChannel.FromIPartialChannel(invite.Channel) : null,
invite.Uses,
invite.MaxUses,
invite.MaxAge,
invite.IsTemporary,
invite.CreatedAt,
invite.Inviter.Map(RedisUser.FromIUser).OrDefault(),
invite.ExpiresAt.OrDefault()
);
public Invite ToRemoraInvite() =>
public InviteWithMetadata ToRemoraInvite() =>
new(
Code,
Guild?.ToRemoraPartialGuild() ?? new Optional<IPartialGuild>(),
Channel?.ToRemoraPartialChannel(),
Uses,
MaxUses,
MaxAge,
IsTemporary,
CreatedAt,
Inviter?.ToRemoraUser() ?? new Optional<IUser>(),
ExpiresAt: ExpiresAt
);

View file

@ -1,32 +1,46 @@
using Catalogger.Backend.Database.Redis;
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.Rest.Core;
namespace Catalogger.Backend.Cache.RedisCache;
public class RedisMemberCache(RedisService redisService) : IMemberCache
public class RedisMemberCache(
RedisService redisService,
IDiscordRestGuildAPI guildApi,
ILogger logger
) : IMemberCache
{
private readonly ILogger _logger = logger.ForContext<RedisMemberCache>();
public async Task<IGuildMember?> TryGetAsync(Snowflake guildId, Snowflake userId)
{
var redisMember = await redisService.GetHashAsync<RedisMember>(
GuildMembersKey(guildId),
userId.ToString()
);
var redisMember = await TryGetInnerAsync(guildId, userId);
return redisMember?.ToRemoraMember();
}
private async Task<RedisMember?> TryGetInnerAsync(Snowflake guildId, Snowflake userId) =>
await redisService.GetHashAsync<RedisMember>(GuildMembersKey(guildId), userId.ToString());
public async Task SetAsync(Snowflake guildId, IGuildMember member)
{
if (!member.User.IsDefined())
throw new CataloggerError(
"Member with undefined User passed to RedisMemberCache.SetAsync"
);
await SetInnerAsync(guildId, RedisMember.FromIGuildMember(member));
}
private async Task SetInnerAsync(Snowflake guildId, RedisMember member)
{
await redisService.SetHashAsync(
GuildMembersKey(guildId),
member.User.Value.ID.ToString(),
RedisMember.FromIGuildMember(member)
member.User.Id.ToString(),
member
);
}
@ -59,6 +73,58 @@ public class RedisMemberCache(RedisService redisService) : IMemberCache
public async Task MarkAsUncachedAsync(Snowflake guildId) =>
await redisService.GetDatabase().SetRemoveAsync(GuildCacheKey, guildId.ToString());
public async Task UpdateAsync(IGuildMemberUpdate newMember)
{
var oldMember = await TryGetInnerAsync(newMember.GuildID, newMember.User.ID);
if (oldMember == null)
{
_logger.Warning(
"Received member update event for {MemberId} in {GuildId}, but member wasn't found in cache. Fetching them and storing.",
newMember.User.ID,
newMember.GuildID
);
var memberResult = await guildApi.GetGuildMemberAsync(
newMember.GuildID,
newMember.User.ID
);
if (!memberResult.IsSuccess)
{
_logger.Error(
"Could not get uncached member {MemberId} in {GuildId} via REST: {Error}",
newMember.User.ID,
newMember.GuildID,
memberResult.Error
);
return;
}
await SetAsync(newMember.GuildID, memberResult.Entity);
return;
}
var member = oldMember with
{
User = RedisUser.FromIUser(newMember.User),
Nickname = newMember.Nickname.HasValue ? newMember.Nickname.Value : oldMember.Nickname,
Avatar = newMember.Avatar.HasValue ? newMember.Avatar.Value?.Value : oldMember.Avatar,
Roles = newMember.Roles.ToArray(),
JoinedAt = newMember.JoinedAt ?? oldMember.JoinedAt,
PremiumSince = newMember.PremiumSince.HasValue
? newMember.PremiumSince.Value
: oldMember.PremiumSince,
IsPending = newMember.IsPending.HasValue
? newMember.IsPending.Value
: oldMember.IsPending,
CommunicationDisabledUntil = newMember.CommunicationDisabledUntil.HasValue
? newMember.CommunicationDisabledUntil.Value
: oldMember.CommunicationDisabledUntil,
};
await SetInnerAsync(newMember.GuildID, member);
}
private const string GuildCacheKey = "cached-guilds";
private static string GuildMembersKey(Snowflake guildId) => $"guild-members:{guildId}";

View file

@ -6,6 +6,20 @@
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<!-- Local clones of Remora.Discord with our PRs merged in, until a new version is released on nuget -->
<ItemGroup>
<ProjectReference Include="../../Remora.Discord/Backend/Remora.Discord.API.Abstractions/Remora.Discord.API.Abstractions.csproj"/>
<ProjectReference Include="../../Remora.Discord/Backend/Remora.Discord.API/Remora.Discord.API.csproj"/>
<ProjectReference Include="../../Remora.Discord/Backend/Remora.Discord.Gateway/Remora.Discord.Gateway.csproj"/>
<ProjectReference Include="../../Remora.Discord/Backend/Remora.Discord.Rest/Remora.Discord.Rest.csproj"/>
<ProjectReference Include="../../Remora.Discord/Remora.Discord.Commands/Remora.Discord.Commands.csproj"/>
<ProjectReference Include="../../Remora.Discord/Remora.Discord.Extensions/Remora.Discord.Extensions.csproj"/>
<ProjectReference Include="../../Remora.Discord/Remora.Discord.Hosting/Remora.Discord.Hosting.csproj"/>
<ProjectReference Include="../../Remora.Discord/Remora.Discord.Interactivity/Remora.Discord.Interactivity.csproj"/>
<ProjectReference Include="../../Remora.Discord/Remora.Discord.Pagination/Remora.Discord.Pagination.csproj"/>
<ProjectReference Include="../../Remora.Discord/Remora.Discord/Remora.Discord.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.3"/>

View file

@ -85,7 +85,6 @@ public static class StartupExtensions
.AddSingleton<PluralkitApiService>()
.AddScoped<IEncryptionService, EncryptionService>()
.AddSingleton<MetricsCollectionService>()
.AddSingleton<AuditLogEnrichedResponderService>()
.AddScoped<MessageRepository>()
.AddSingleton<WebhookExecutorService>()
.AddSingleton<PkMessageHandler>()

View file

@ -6,6 +6,7 @@ using Newtonsoft.Json.Serialization;
using Prometheus;
using Remora.Commands.Extensions;
using Remora.Discord.API.Abstractions.Gateway.Commands;
using Remora.Discord.Caching.Extensions;
using Remora.Discord.Commands.Extensions;
using Remora.Discord.Extensions.Extensions;
using Remora.Discord.Gateway;
@ -42,6 +43,7 @@ builder
| GatewayIntents.MessageContents
| GatewayIntents.GuildEmojisAndStickers
)
.AddDiscordCaching()
.AddDiscordCommands(enableSlash: true, useDefaultCommandResponder: false)
.AddCommandTree()
// Start command tree

View file

@ -1,46 +0,0 @@
using Catalogger.Backend.Cache.InMemoryCache;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Results;
namespace Catalogger.Backend.Services;
public class AuditLogEnrichedResponderService(AuditLogCache auditLogCache, ILogger logger)
{
private readonly ILogger _logger = logger.ForContext<AuditLogEnrichedResponderService>();
public async Task<Result> RespondAsync(IGuildMemberRemove evt)
{
// give a second or so for the audit log to catch up
await Task.Delay(1000);
if (auditLogCache.TryGetKick(evt.GuildID, evt.User.ID, out var kickData))
return await HandleKickAsync(evt, kickData);
if (auditLogCache.TryGetBan(evt.GuildID, evt.User.ID, out var banData))
return await HandleBanAsync(evt, banData);
_logger.Debug(
"Guild member remove event for guild {GuildId}/user {UserId} didn't match an audit log entry",
evt.GuildID,
evt.User.ID
);
return Result.Success;
}
private async Task<Result> HandleKickAsync(
IGuildMemberRemove evt,
AuditLogCache.ActionData kickData
)
{
return Result.Success;
}
private async Task<Result> HandleBanAsync(
IGuildMemberRemove evt,
AuditLogCache.ActionData banData
)
{
return Result.Success;
}
}

5
global.json Normal file
View file

@ -0,0 +1,5 @@
{
"msbuild-sdks": {
"Remora.Sdk": "3.1.2"
}
}