feat: role delete logging, used invite logging, also some random changes
This commit is contained in:
parent
4f54077c68
commit
c906a4d6b6
18 changed files with 386 additions and 76 deletions
7
.idea/.idea.catalogger/.idea/CSharpierPlugin.xml
generated
Normal file
7
.idea/.idea.catalogger/.idea/CSharpierPlugin.xml
generated
Normal 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>
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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}";
|
||||||
|
|
|
||||||
|
|
@ -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"/>
|
||||||
|
|
|
||||||
|
|
@ -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>()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
5
global.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"msbuild-sdks": {
|
||||||
|
"Remora.Sdk": "3.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue