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 Catalogger.Backend.Cache.InMemoryCache;
using Newtonsoft.Json;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
using Remora.Results; using Remora.Results;

View file

@ -5,6 +5,7 @@ using Catalogger.Backend.Database.Queries;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services; using Catalogger.Backend.Services;
using Humanizer; using Humanizer;
using Microsoft.EntityFrameworkCore;
using Remora.Discord.API; using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -20,6 +21,7 @@ public class GuildMemberAddResponder(
ILogger logger, ILogger logger,
DatabaseContext db, DatabaseContext db,
IMemberCache memberCache, IMemberCache memberCache,
IInviteCache inviteCache,
UserCache userCache, UserCache userCache,
WebhookExecutorService webhookExecutor, WebhookExecutorService webhookExecutor,
IDiscordRestGuildAPI guildApi, 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()]; List<Embed> embeds = [builder.Build().GetOrThrow()];
@ -145,4 +202,32 @@ public class GuildMemberAddResponder(
return Result.Success; 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, DatabaseContext db,
IMemberCache memberCache, IMemberCache memberCache,
RoleCache roleCache, RoleCache roleCache,
WebhookExecutorService webhookExecutor, WebhookExecutorService webhookExecutor
AuditLogEnrichedResponderService auditLogEnrichedResponderService
) : IResponder<IGuildMemberRemove> ) : IResponder<IGuildMemberRemove>
{ {
private readonly ILogger _logger = logger.ForContext<GuildMemberAddResponder>(); private readonly ILogger _logger = logger.ForContext<GuildMemberAddResponder>();
@ -26,9 +25,6 @@ public class GuildMemberRemoveResponder(
{ {
try try
{ {
// spin events that Discord doesn't send us all the data for off to another responder
_ = auditLogEnrichedResponderService.RespondAsync(evt);
var embed = new EmbedBuilder() var embed = new EmbedBuilder()
.WithTitle("Member left") .WithTitle("Member left")
.WithAuthor(evt.User.Tag(), iconUrl: evt.User.AvatarUrl()) .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 // 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) // (we don't care about position, for example, because it's not actually useful)
if (embed.Fields.Count == 0) if (embed.Fields.Count == 0)
{
_logger.Debug("We don't care about update of role {RoleId}, ignoring", evt.Role.ID);
return Result.Success; return Result.Success;
}
var guildConfig = await db.GetGuildAsync(evt.GuildID, ct); var guildConfig = await db.GetGuildAsync(evt.GuildID, ct);
webhookExecutor.QueueLog( webhookExecutor.QueueLog(

View file

@ -5,6 +5,6 @@ namespace Catalogger.Backend.Cache;
public interface IInviteCache public interface IInviteCache
{ {
public Task<IEnumerable<IInvite>> TryGetAsync(Snowflake guildId); public Task<IEnumerable<IInviteWithMetadata>> TryGetAsync(Snowflake guildId);
public Task SetAsync(Snowflake guildId, IEnumerable<IInvite> invites); 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.Discord.API.Abstractions.Objects;
using Remora.Rest.Core; using Remora.Rest.Core;
@ -12,4 +13,5 @@ public interface IMemberCache
public Task<bool> IsGuildCachedAsync(Snowflake guildId); public Task<bool> IsGuildCachedAsync(Snowflake guildId);
public Task MarkAsCachedAsync(Snowflake guildId); public Task MarkAsCachedAsync(Snowflake guildId);
public Task MarkAsUncachedAsync(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 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) _invites.TryGetValue(guildId, out var invites)
? Task.FromResult(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; _invites[guildId] = invites;
return Task.CompletedTask; return Task.CompletedTask;

View file

@ -1,12 +1,19 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Catalogger.Backend.Cache.InMemoryCache; 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(); private readonly ConcurrentDictionary<Snowflake, byte> _guilds = new();
#pragma warning disable CS8619 // Nullability of reference types in value doesn't match target type. #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 _); _guilds.Remove(guildId, out _);
return Task.CompletedTask; 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 class RedisInviteCache(RedisService redisService) : IInviteCache
{ {
public async Task<IEnumerable<IInvite>> TryGetAsync(Snowflake guildId) public async Task<IEnumerable<IInviteWithMetadata>> TryGetAsync(Snowflake guildId)
{ {
var redisInvites = var redisInvites =
await redisService.GetAsync<List<RedisInvite>>(InvitesKey(guildId)) ?? []; await redisService.GetAsync<List<RedisInvite>>(InvitesKey(guildId)) ?? [];
return redisInvites.Select(r => r.ToRemoraInvite()); 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)); await redisService.SetAsync(InvitesKey(guildId), invites.Select(RedisInvite.FromIInvite));
private static string InvitesKey(Snowflake guildId) => $"guild-invites:{guildId}"; private static string InvitesKey(Snowflake guildId) => $"guild-invites:{guildId}";
@ -25,24 +25,39 @@ internal record RedisInvite(
string Code, string Code,
RedisPartialGuild? Guild, RedisPartialGuild? Guild,
RedisPartialChannel? Channel, RedisPartialChannel? Channel,
int Uses,
int MaxUses,
TimeSpan MaxAge,
bool IsTemporary,
DateTimeOffset CreatedAt,
RedisUser? Inviter, RedisUser? Inviter,
DateTimeOffset? ExpiresAt DateTimeOffset? ExpiresAt
) )
{ {
public static RedisInvite FromIInvite(IInvite invite) => public static RedisInvite FromIInvite(IInviteWithMetadata invite) =>
new( new(
invite.Code, invite.Code,
invite.Guild.Map(RedisPartialGuild.FromIPartialGuild).OrDefault(), invite.Guild.Map(RedisPartialGuild.FromIPartialGuild).OrDefault(),
invite.Channel != null ? RedisPartialChannel.FromIPartialChannel(invite.Channel) : null, 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.Inviter.Map(RedisUser.FromIUser).OrDefault(),
invite.ExpiresAt.OrDefault() invite.ExpiresAt.OrDefault()
); );
public Invite ToRemoraInvite() => public InviteWithMetadata ToRemoraInvite() =>
new( new(
Code, Code,
Guild?.ToRemoraPartialGuild() ?? new Optional<IPartialGuild>(), Guild?.ToRemoraPartialGuild() ?? new Optional<IPartialGuild>(),
Channel?.ToRemoraPartialChannel(), Channel?.ToRemoraPartialChannel(),
Uses,
MaxUses,
MaxAge,
IsTemporary,
CreatedAt,
Inviter?.ToRemoraUser() ?? new Optional<IUser>(), Inviter?.ToRemoraUser() ?? new Optional<IUser>(),
ExpiresAt: ExpiresAt ExpiresAt: ExpiresAt
); );

View file

@ -1,32 +1,46 @@
using Catalogger.Backend.Database.Redis; using Catalogger.Backend.Database.Redis;
using Remora.Discord.API; using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects; using Remora.Discord.API.Objects;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Catalogger.Backend.Cache.RedisCache; 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) public async Task<IGuildMember?> TryGetAsync(Snowflake guildId, Snowflake userId)
{ {
var redisMember = await redisService.GetHashAsync<RedisMember>( var redisMember = await TryGetInnerAsync(guildId, userId);
GuildMembersKey(guildId),
userId.ToString()
);
return redisMember?.ToRemoraMember(); 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) public async Task SetAsync(Snowflake guildId, IGuildMember member)
{ {
if (!member.User.IsDefined()) if (!member.User.IsDefined())
throw new CataloggerError( throw new CataloggerError(
"Member with undefined User passed to RedisMemberCache.SetAsync" "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( await redisService.SetHashAsync(
GuildMembersKey(guildId), GuildMembersKey(guildId),
member.User.Value.ID.ToString(), member.User.Id.ToString(),
RedisMember.FromIGuildMember(member) member
); );
} }
@ -59,6 +73,58 @@ public class RedisMemberCache(RedisService redisService) : IMemberCache
public async Task MarkAsUncachedAsync(Snowflake guildId) => public async Task MarkAsUncachedAsync(Snowflake guildId) =>
await redisService.GetDatabase().SetRemoveAsync(GuildCacheKey, guildId.ToString()); 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 const string GuildCacheKey = "cached-guilds";
private static string GuildMembersKey(Snowflake guildId) => $"guild-members:{guildId}"; private static string GuildMembersKey(Snowflake guildId) => $"guild-members:{guildId}";

View file

@ -6,6 +6,20 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </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> <ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/> <PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.3"/> <PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.3"/>

View file

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

View file

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