move classes around, name caches more consistently, add more caches

This commit is contained in:
sam 2024-08-19 16:12:28 +02:00
parent e86b37ce2a
commit e17dcf90a1
Signed by: sam
GPG key ID: 5F3C3C1B3166639D
30 changed files with 443 additions and 51 deletions

View file

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="GrazieInspection" enabled="false" level="GRAMMAR_ERROR" enabled_by_default="false" />
</profile>
</component>

View file

@ -1,5 +1,6 @@
using System.ComponentModel; using System.ComponentModel;
using Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database; using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Queries; using Catalogger.Backend.Database.Queries;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
@ -26,8 +27,8 @@ namespace Catalogger.Backend.Bot.Commands;
public class ChannelCommands( public class ChannelCommands(
ILogger logger, ILogger logger,
DatabaseContext db, DatabaseContext db,
GuildCacheService guildCache, GuildCache guildCache,
ChannelCacheService channelCache, ChannelCache channelCache,
IFeedbackService feedbackService, IFeedbackService feedbackService,
ContextInjectionService contextInjection, ContextInjectionService contextInjection,
InMemoryDataService<Snowflake, ChannelCommandData> dataService) : CommandGroup InMemoryDataService<Snowflake, ChannelCommandData> dataService) : CommandGroup

View file

@ -1,4 +1,5 @@
using Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database; using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Queries; using Catalogger.Backend.Database.Queries;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
@ -22,8 +23,8 @@ namespace Catalogger.Backend.Bot.Commands;
public class ChannelCommandsComponents( public class ChannelCommandsComponents(
ILogger logger, ILogger logger,
DatabaseContext db, DatabaseContext db,
GuildCacheService guildCache, GuildCache guildCache,
ChannelCacheService channelCache, ChannelCache channelCache,
ContextInjectionService contextInjection, ContextInjectionService contextInjection,
IFeedbackService feedbackService, IFeedbackService feedbackService,
IDiscordRestInteractionAPI interactionApi, IDiscordRestInteractionAPI interactionApi,

View file

@ -1,8 +1,10 @@
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.Json;
using App.Metrics; using App.Metrics;
using Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database; using Catalogger.Backend.Database;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
using Humanizer; using Humanizer;
@ -11,7 +13,11 @@ using Remora.Commands.Attributes;
using Remora.Commands.Groups; using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Extensions;
using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Commands.Services;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway; using Remora.Discord.Gateway;
using Remora.Results; using Remora.Results;
@ -26,8 +32,10 @@ public class MetaCommands(
IMetrics metrics, IMetrics metrics,
DiscordGatewayClient client, DiscordGatewayClient client,
IFeedbackService feedbackService, IFeedbackService feedbackService,
GuildCacheService guildCache, ContextInjectionService contextInjection,
ChannelCacheService channelCache, IInviteCache inviteCache,
GuildCache guildCache,
ChannelCache channelCache,
IDiscordRestChannelAPI channelApi) : CommandGroup IDiscordRestChannelAPI channelApi) : CommandGroup
{ {
[Command("ping")] [Command("ping")]
@ -65,4 +73,17 @@ public class MetaCommands(
return (Result)await channelApi.EditMessageAsync(msg.ChannelID, msg.ID, content: "", embeds: embeds); return (Result)await channelApi.EditMessageAsync(msg.ChannelID, msg.ID, content: "", embeds: embeds);
} }
[Command("debug-invites")]
[Description("Show a representation of this server's invites")]
public async Task<IResult> DebugInvitesAsync()
{
if (contextInjection.Context is not IInteractionCommandContext ctx) throw new CataloggerError("No context");
if (!ctx.TryGetGuildID(out var guildId)) throw new CataloggerError("No guild ID in context");
var invites = await inviteCache.TryGetAsync(guildId);
var text = invites.Select(i => $"{i.Code} in {i.Channel?.ID.Value}");
return await feedbackService.SendContextualAsync(string.Join("\n", text));
}
} }

View file

@ -1,4 +1,5 @@
using Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database; using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Queries; using Catalogger.Backend.Database.Queries;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
@ -13,8 +14,8 @@ namespace Catalogger.Backend.Bot.Responders.Channels;
public class ChannelCreateResponder( public class ChannelCreateResponder(
DatabaseContext db, DatabaseContext db,
RoleCacheService roleCache, RoleCache roleCache,
ChannelCacheService channelCache, ChannelCache channelCache,
WebhookExecutorService webhookExecutor) : IResponder<IChannelCreate> WebhookExecutorService webhookExecutor) : IResponder<IChannelCreate>
{ {
public async Task<Result> RespondAsync(IChannelCreate ch, CancellationToken ct = default) public async Task<Result> RespondAsync(IChannelCreate ch, CancellationToken ct = default)

View file

@ -1,26 +1,27 @@
using System.Diagnostics;
using Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database; using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Models; using Catalogger.Backend.Database.Models;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services; using Catalogger.Backend.Services;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Gateway.Events;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
using Remora.Results; using Remora.Results;
namespace Catalogger.Backend.Bot.Responders; namespace Catalogger.Backend.Bot.Responders.Guilds;
public class GuildCreateResponder( public class GuildCreateResponder(
Config config, Config config,
ILogger logger, ILogger logger,
DatabaseContext db, DatabaseContext db,
GuildCacheService guildCache, GuildCache guildCache,
RoleCacheService roleCache, RoleCache roleCache,
ChannelCacheService channelCache, ChannelCache channelCache,
WebhookExecutorService webhookExecutor) WebhookExecutorService webhookExecutor,
: IResponder<IGuildCreate>, IResponder<GuildDelete> IMemberCache memberCache,
GuildFetchService guildFetchService)
: IResponder<IGuildCreate>, IResponder<IGuildDelete>
{ {
private readonly ILogger _logger = logger.ForContext<GuildCreateResponder>(); private readonly ILogger _logger = logger.ForContext<GuildCreateResponder>();
@ -37,6 +38,9 @@ public class GuildCreateResponder(
guildCache.Set(guild); guildCache.Set(guild);
foreach (var c in guild.Channels) channelCache.Set(c, guild.ID); foreach (var c in guild.Channels) channelCache.Set(c, guild.ID);
foreach (var r in guild.Roles) roleCache.Set(r, guild.ID); foreach (var r in guild.Roles) roleCache.Set(r, guild.ID);
if (!await memberCache.IsGuildCachedAsync(guild.ID))
guildFetchService.EnqueueGuild(guild.ID);
} }
else else
{ {
@ -71,7 +75,7 @@ public class GuildCreateResponder(
return Result.Success; return Result.Success;
} }
public async Task<Result> RespondAsync(GuildDelete evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildDelete evt, CancellationToken ct = default)
{ {
if (evt.IsUnavailable.OrDefault(false)) if (evt.IsUnavailable.OrDefault(false))
{ {

View file

@ -0,0 +1,27 @@
using Catalogger.Backend.Cache;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
namespace Catalogger.Backend.Bot.Responders.Guilds;
public class GuildMembersChunkResponder(ILogger logger, IMemberCache memberCache) : IResponder<IGuildMembersChunk>
{
private readonly ILogger _logger = logger.ForContext<GuildMembersChunkResponder>();
public async Task<Result> RespondAsync(IGuildMembersChunk evt, CancellationToken ct = default)
{
_logger.Debug("Received chunk {ChunkIndex} / {ChunkCount} for guild {GuildId}", evt.ChunkIndex + 1,
evt.ChunkCount, evt.GuildID);
await memberCache.SetManyAsync(evt.GuildID, evt.Members);
if (evt.ChunkIndex == evt.ChunkCount - 1)
{
_logger.Debug("Final chunk for guild {GuildId} received, marking as cached", evt.GuildID);
await memberCache.MarkAsCachedAsync(evt.GuildID);
}
return Result.Success;
}
}

View file

@ -1,16 +1,15 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using App.Metrics; using App.Metrics;
using Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database; using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Models; using Catalogger.Backend.Database.Models;
using Catalogger.Backend.Database.Queries; 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 Remora.Discord.API;
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.Rest.Core;
using Remora.Results; using Remora.Results;
namespace Catalogger.Backend.Bot.Responders; namespace Catalogger.Backend.Bot.Responders;
@ -20,7 +19,7 @@ public class MessageCreateResponder(
Config config, Config config,
DatabaseContext db, DatabaseContext db,
MessageRepository messageRepository, MessageRepository messageRepository,
UserCacheService userCache, UserCache userCache,
PkMessageHandler pkMessageHandler, PkMessageHandler pkMessageHandler,
IMetrics metrics) IMetrics metrics)
: IResponder<IMessageCreate> : IResponder<IMessageCreate>

View file

@ -1,4 +1,5 @@
using Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database; using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Queries; using Catalogger.Backend.Database.Queries;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
@ -22,8 +23,8 @@ public class MessageDeleteResponder(
DatabaseContext db, DatabaseContext db,
MessageRepository messageRepository, MessageRepository messageRepository,
WebhookExecutorService webhookExecutor, WebhookExecutorService webhookExecutor,
ChannelCacheService channelCache, ChannelCache channelCache,
UserCacheService userCache, UserCache userCache,
IClock clock, IClock clock,
PluralkitApiService pluralkitApi) : IResponder<IMessageDelete> PluralkitApiService pluralkitApi) : IResponder<IMessageDelete>
{ {

View file

@ -1,4 +1,5 @@
using Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database; using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Queries; using Catalogger.Backend.Database.Queries;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
@ -17,8 +18,8 @@ namespace Catalogger.Backend.Bot.Responders;
public class MessageUpdateResponder( public class MessageUpdateResponder(
ILogger logger, ILogger logger,
DatabaseContext db, DatabaseContext db,
ChannelCacheService channelCache, ChannelCache channelCache,
UserCacheService userCache, UserCache userCache,
MessageRepository messageRepository, MessageRepository messageRepository,
WebhookExecutorService webhookExecutor, WebhookExecutorService webhookExecutor,
PluralkitApiService pluralkitApi) : IResponder<IMessageUpdate> PluralkitApiService pluralkitApi) : IResponder<IMessageUpdate>
@ -60,7 +61,8 @@ public class MessageUpdateResponder(
return Result.Success; return Result.Success;
} }
if (oldMessage.Content == msg.Content) return Result.Success; if (oldMessage.Content == msg.Content ||
(oldMessage.Content == "None" && string.IsNullOrEmpty(msg.Content))) return Result.Success;
var user = msg.Author; var user = msg.Author;
if (msg.Author.ID != oldMessage.UserId) if (msg.Author.ID != oldMessage.UserId)

View file

@ -0,0 +1,10 @@
using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core;
namespace Catalogger.Backend.Cache;
public interface IInviteCache
{
public Task<IEnumerable<IInvite>> TryGetAsync(Snowflake guildId);
public Task SetAsync(Snowflake guildId, IEnumerable<IInvite> invites);
}

View file

@ -0,0 +1,14 @@
using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core;
namespace Catalogger.Backend.Cache;
public interface IMemberCache
{
public Task<IGuildMember?> TryGetAsync(Snowflake guildId, Snowflake userId);
public Task SetAsync(Snowflake guildId, IGuildMember member);
public Task SetManyAsync(Snowflake guildId, IReadOnlyList<IGuildMember> members);
public Task<bool> IsGuildCachedAsync(Snowflake guildId);
public Task MarkAsCachedAsync(Snowflake guildId);
public Task MarkAsUncachedAsync(Snowflake guildId);
}

View file

@ -3,7 +3,7 @@ using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Catalogger.Backend.Services; namespace Catalogger.Backend.Cache;
public interface IWebhookCache public interface IWebhookCache
{ {

View file

@ -3,9 +3,9 @@ using System.Diagnostics.CodeAnalysis;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Catalogger.Backend.Cache; namespace Catalogger.Backend.Cache.InMemoryCache;
public class ChannelCacheService public class ChannelCache
{ {
private readonly ConcurrentDictionary<Snowflake, IChannel> _channels = new(); private readonly ConcurrentDictionary<Snowflake, IChannel> _channels = new();
private readonly ConcurrentDictionary<Snowflake, HashSet<Snowflake>> _guildChannels = new(); private readonly ConcurrentDictionary<Snowflake, HashSet<Snowflake>> _guildChannels = new();

View file

@ -3,9 +3,9 @@ using System.Diagnostics.CodeAnalysis;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Catalogger.Backend.Cache; namespace Catalogger.Backend.Cache.InMemoryCache;
public class GuildCacheService public class GuildCache
{ {
private readonly ConcurrentDictionary<Snowflake, IGuild> _guilds = new(); private readonly ConcurrentDictionary<Snowflake, IGuild> _guilds = new();

View file

@ -0,0 +1,20 @@
using System.Collections.Concurrent;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core;
namespace Catalogger.Backend.Cache.InMemoryCache;
public class InMemoryInviteCache : IInviteCache
{
private readonly ConcurrentDictionary<Snowflake, IEnumerable<IInvite>> _invites = new();
public Task<IEnumerable<IInvite>> TryGetAsync(Snowflake guildId) => _invites.TryGetValue(guildId, out var invites)
? Task.FromResult(invites)
: Task.FromResult<IEnumerable<IInvite>>([]);
public Task SetAsync(Snowflake guildId, IEnumerable<IInvite> invites)
{
_invites[guildId] = invites;
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,46 @@
using System.Collections.Concurrent;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core;
namespace Catalogger.Backend.Cache.InMemoryCache;
public class InMemoryMemberCache : IMemberCache
{
private readonly ConcurrentDictionary<(Snowflake, Snowflake), 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.
public Task<IGuildMember?> TryGetAsync(Snowflake guildId, Snowflake userId) =>
_members.TryGetValue((guildId, userId), out var member)
? Task.FromResult(member)
: Task.FromResult<IGuildMember?>(null);
#pragma warning restore CS8619 // Nullability of reference types in value doesn't match target type.
public Task SetAsync(Snowflake guildId, IGuildMember member)
{
if (!member.User.IsDefined())
throw new CataloggerError("Member with undefined User passed to RedisMemberCache.SetAsync");
_members[(guildId, member.User.Value.ID)] = member;
return Task.CompletedTask;
}
public async Task SetManyAsync(Snowflake guildId, IReadOnlyList<IGuildMember> members)
{
foreach (var member in members)
await SetAsync(guildId, member);
}
public Task<bool> IsGuildCachedAsync(Snowflake guildId) => Task.FromResult(_guilds.ContainsKey(guildId));
public Task MarkAsCachedAsync(Snowflake guildId)
{
_guilds[guildId] = 1;
return Task.CompletedTask;
}
public Task MarkAsUncachedAsync(Snowflake guildId)
{
_guilds.Remove(guildId, out _);
return Task.CompletedTask;
}
}

View file

@ -1,6 +1,6 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
namespace Catalogger.Backend.Services; namespace Catalogger.Backend.Cache.InMemoryCache;
public class InMemoryWebhookCache : IWebhookCache public class InMemoryWebhookCache : IWebhookCache
{ {

View file

@ -3,9 +3,9 @@ using System.Diagnostics.CodeAnalysis;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Catalogger.Backend.Cache; namespace Catalogger.Backend.Cache.InMemoryCache;
public class RoleCacheService public class RoleCache
{ {
private readonly ConcurrentDictionary<Snowflake, IRole> _roles = new(); private readonly ConcurrentDictionary<Snowflake, IRole> _roles = new();
private readonly ConcurrentDictionary<Snowflake, HashSet<Snowflake>> _guildRoles = new(); private readonly ConcurrentDictionary<Snowflake, HashSet<Snowflake>> _guildRoles = new();

View file

@ -3,9 +3,9 @@ using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Catalogger.Backend.Cache; namespace Catalogger.Backend.Cache.InMemoryCache;
public class UserCacheService(IDiscordRestUserAPI userApi) public class UserCache(IDiscordRestUserAPI userApi)
{ {
private readonly ConcurrentDictionary<Snowflake, IUser> _cache = new(); private readonly ConcurrentDictionary<Snowflake, IUser> _cache = new();

View file

@ -0,0 +1,53 @@
using Catalogger.Backend.Database.Redis;
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Objects;
using Remora.Rest.Core;
namespace Catalogger.Backend.Cache.RedisCache;
public class RedisInviteCache(RedisService redisService) : IInviteCache
{
public async Task<IEnumerable<IInvite>> 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) =>
await redisService.SetAsync(InvitesKey(guildId), invites.Select(RedisInvite.FromIInvite));
private static string InvitesKey(Snowflake guildId) => $"guild-invites:{guildId}";
}
internal record RedisInvite(
string Code,
RedisPartialGuild? Guild,
RedisPartialChannel? Channel,
RedisUser? Inviter,
DateTimeOffset? ExpiresAt)
{
public static RedisInvite FromIInvite(IInvite invite) => new(invite.Code,
invite.Guild.Map(RedisPartialGuild.FromIPartialGuild).OrDefault(),
invite.Channel != null ? RedisPartialChannel.FromIPartialChannel(invite.Channel) : null,
invite.Inviter.Map(RedisUser.FromIUser).OrDefault(), invite.ExpiresAt.OrDefault());
public Invite ToRemoraInvite() => new(Code, Guild?.ToRemoraPartialGuild() ?? new Optional<IPartialGuild>(),
Channel?.ToRemoraPartialChannel(), Inviter?.ToRemoraUser() ?? new Optional<IUser>(), ExpiresAt: ExpiresAt);
}
internal record RedisPartialGuild(ulong Id, string? Name)
{
public static RedisPartialGuild FromIPartialGuild(IPartialGuild guild) =>
new(guild.ID.Value.Value, guild.Name.OrDefault(null));
public PartialGuild ToRemoraPartialGuild() => new(DiscordSnowflake.New(Id), Name ?? new Optional<string>());
}
internal record RedisPartialChannel(ulong Id, string? Name)
{
public static RedisPartialChannel FromIPartialChannel(IPartialChannel channel) =>
new(channel.ID.Value.Value, channel.Name.OrDefault(null));
public PartialChannel ToRemoraPartialChannel() => new(DiscordSnowflake.New(Id), Name: Name);
}

View file

@ -0,0 +1,85 @@
using Catalogger.Backend.Database.Redis;
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Objects;
using Remora.Rest.Core;
namespace Catalogger.Backend.Cache.RedisCache;
public class RedisMemberCache(RedisService redisService) : IMemberCache
{
public async Task<IGuildMember?> TryGetAsync(Snowflake guildId, Snowflake userId)
{
var redisMember = await redisService.GetHashAsync<RedisMember>(GuildMembersKey(guildId), userId.ToString());
return redisMember?.ToRemoraMember();
}
public async Task SetAsync(Snowflake guildId, IGuildMember member)
{
if (!member.User.IsDefined())
throw new CataloggerError("Member with undefined User passed to RedisMemberCache.SetAsync");
await redisService.SetHashAsync(GuildMembersKey(guildId), member.User.Value.ID.ToString(),
RedisMember.FromIGuildMember(member));
}
public async Task SetManyAsync(Snowflake guildId, IReadOnlyList<IGuildMember> members)
{
if (members.Any(m => !m.User.IsDefined()))
throw new CataloggerError("Member with undefined User passed to RedisMemberCache.SetAsync");
var redisMembers = members.Select(RedisMember.FromIGuildMember).ToList();
await redisService.SetHashAsync(GuildMembersKey(guildId), redisMembers, m => m.User.Id.ToString());
}
public async Task<bool> IsGuildCachedAsync(Snowflake guildId) =>
await redisService.GetDatabase().SetContainsAsync(GuildCacheKey, guildId.ToString());
public async Task MarkAsCachedAsync(Snowflake guildId) =>
await redisService.GetDatabase().SetAddAsync(GuildCacheKey, guildId.ToString());
public async Task MarkAsUncachedAsync(Snowflake guildId) =>
await redisService.GetDatabase().SetRemoveAsync(GuildCacheKey, guildId.ToString());
private const string GuildCacheKey = "cached-guilds";
private static string GuildMembersKey(Snowflake guildId) => $"guild-members:{guildId}";
}
internal record RedisMember(
RedisUser User,
string? Nickname,
string? Avatar,
Snowflake[] Roles,
DateTimeOffset JoinedAt,
DateTimeOffset? PremiumSince,
GuildMemberFlags Flags,
bool? IsPending,
DateTimeOffset? CommunicationDisabledUntil)
{
public static RedisMember FromIGuildMember(IGuildMember member) => new(
RedisUser.FromIUser(member.User.Value), member.Nickname.OrDefault(null), member.Avatar.OrDefault(null)?.Value,
member.Roles.ToArray(), member.JoinedAt, member.PremiumSince.OrDefault(null), member.Flags,
member.IsPending.OrDefault(null), member.CommunicationDisabledUntil.OrDefault(null));
public GuildMember ToRemoraMember() => new(User.ToRemoraUser(), Nickname,
Avatar != null ? new ImageHash(Avatar) : null, Roles, JoinedAt, PremiumSince, false, false, Flags,
IsPending, default, CommunicationDisabledUntil);
}
internal record RedisUser(
ulong Id,
string Username,
ushort Discriminator,
string? GlobalName,
string? Avatar,
bool IsBot,
bool IsSystem,
string? Banner)
{
public static RedisUser FromIUser(IUser user) => new(user.ID.Value, user.Username, user.Discriminator,
user.GlobalName.OrDefault(null), user.Avatar?.Value, user.IsBot.OrDefault(false),
user.IsSystem.OrDefault(false), user.Banner.OrDefault(null)?.Value);
public User ToRemoraUser() => new(DiscordSnowflake.New(Id), Username, Discriminator, GlobalName,
Avatar != null ? new ImageHash(Avatar) : null, IsBot, IsSystem,
Banner: Banner != null ? new ImageHash(Banner) : null);
}

View file

@ -1,7 +1,7 @@
using Catalogger.Backend.Database.Redis; using Catalogger.Backend.Database.Redis;
using Humanizer; using Humanizer;
namespace Catalogger.Backend.Services; namespace Catalogger.Backend.Cache.RedisCache;
public class RedisWebhookCache(RedisService redisService) : IWebhookCache public class RedisWebhookCache(RedisService redisService) : IWebhookCache
{ {

View file

@ -30,6 +30,20 @@
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
<PackageReference Include="StackExchange.Redis" Version="2.8.0"/> <PackageReference Include="StackExchange.Redis" Version="2.8.0"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
<!-- <ProjectReference Include="..\..\Remora.Discord\Remora.Discord\Remora.Discord.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\Backend\Remora.Discord.Rest\Remora.Discord.Rest.csproj"/>-->
<!-- <ProjectReference Include="..\..\Remora.Discord\Backend\Remora.Discord.API\Remora.Discord.API.csproj"/>-->
<!-- <ProjectReference Include="..\..\Remora.Discord\Backend\Remora.Discord.Caching.Abstractions\Remora.Discord.Caching.Abstractions.csproj"/>-->
<!-- <ProjectReference Include="..\..\Remora.Discord\Backend\Remora.Discord.Gateway\Remora.Discord.Gateway.csproj"/>-->
<!-- <ProjectReference Include="..\..\Remora.Discord\Backend\Remora.Discord.Unstable\Remora.Discord.Unstable.csproj"/>-->
<!-- <ProjectReference Include="..\..\Remora.Discord\Backend\Remora.Discord.API.Abstractions\Remora.Discord.API.Abstractions.csproj"/>-->
<!-- <ProjectReference Include="..\..\Remora.Discord\Backend\Remora.Discord.Caching\Remora.Discord.Caching.csproj"/>-->
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,5 +1,8 @@
using System.Text.Json; using System.Text.Json;
using Humanizer; using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Objects;
using Remora.Rest.Json;
using StackExchange.Redis; using StackExchange.Redis;
namespace Catalogger.Backend.Database.Redis; namespace Catalogger.Backend.Database.Redis;
@ -8,17 +11,42 @@ public class RedisService(Config config)
{ {
private readonly ConnectionMultiplexer _multiplexer = ConnectionMultiplexer.Connect(config.Database.Redis!); private readonly ConnectionMultiplexer _multiplexer = ConnectionMultiplexer.Connect(config.Database.Redis!);
private readonly JsonSerializerOptions _options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
public IDatabase GetDatabase(int db = -1) => _multiplexer.GetDatabase(db); public IDatabase GetDatabase(int db = -1) => _multiplexer.GetDatabase(db);
public async Task SetAsync<T>(string key, T value, TimeSpan? expiry = null) public async Task SetAsync<T>(string key, T value, TimeSpan? expiry = null)
{ {
var json = JsonSerializer.Serialize(value); var json = JsonSerializer.Serialize(value, _options);
await GetDatabase().StringSetAsync(key, json, expiry); await GetDatabase().StringSetAsync(key, json, expiry);
} }
public async Task<T?> GetAsync<T>(string key) public async Task<T?> GetAsync<T>(string key)
{ {
var value = await GetDatabase().StringGetAsync(key); var value = await GetDatabase().StringGetAsync(key);
return value.IsNull ? default : JsonSerializer.Deserialize<T>(value!); return value.IsNull ? default : JsonSerializer.Deserialize<T>(value!, _options);
}
public async Task SetHashAsync<T>(string hashKey, string fieldKey, T value)
{
var json = JsonSerializer.Serialize(value, _options);
await GetDatabase().HashSetAsync(hashKey, fieldKey, json);
}
public async Task SetHashAsync<T>(string hashKey, IEnumerable<T> values, Func<T, string> keySelector)
{
var hashEntries = values
.Select(v => new { Key = keySelector(v), Value = JsonSerializer.Serialize(v, _options) })
.Select(v => new HashEntry(v.Key, v.Value));
await GetDatabase().HashSetAsync(hashKey, hashEntries.ToArray());
}
public async Task<T?> GetHashAsync<T>(string hashKey, string fieldKey)
{
var value = await GetDatabase().HashGetAsync(hashKey, fieldKey);
return value.IsNull ? default : JsonSerializer.Deserialize<T>(value!, _options);
} }
} }

View file

@ -1,6 +1,8 @@
using Catalogger.Backend.Bot.Commands; using Catalogger.Backend.Bot.Commands;
using Catalogger.Backend.Bot.Responders; using Catalogger.Backend.Bot.Responders;
using Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Cache.RedisCache;
using Catalogger.Backend.Database; using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Queries; using Catalogger.Backend.Database.Queries;
using Catalogger.Backend.Database.Redis; using Catalogger.Backend.Database.Redis;
@ -65,27 +67,34 @@ public static class StartupExtensions
public static IServiceCollection AddCustomServices(this IServiceCollection services) => services public static IServiceCollection AddCustomServices(this IServiceCollection services) => services
.AddSingleton<IClock>(SystemClock.Instance) .AddSingleton<IClock>(SystemClock.Instance)
.AddSingleton<GuildCacheService>() .AddSingleton<GuildCache>()
.AddSingleton<RoleCacheService>() .AddSingleton<RoleCache>()
.AddSingleton<ChannelCacheService>() .AddSingleton<ChannelCache>()
.AddSingleton<UserCacheService>() .AddSingleton<UserCache>()
.AddSingleton<PluralkitApiService>() .AddSingleton<PluralkitApiService>()
.AddScoped<IEncryptionService, EncryptionService>() .AddScoped<IEncryptionService, EncryptionService>()
.AddScoped<MessageRepository>() .AddScoped<MessageRepository>()
.AddSingleton<WebhookExecutorService>() .AddSingleton<WebhookExecutorService>()
.AddSingleton<PkMessageHandler>() .AddSingleton<PkMessageHandler>()
.AddSingleton(InMemoryDataService<Snowflake, ChannelCommandData>.Instance) .AddSingleton(InMemoryDataService<Snowflake, ChannelCommandData>.Instance)
.AddHostedService<MetricsCollectionService>(); .AddHostedService<MetricsCollectionService>()
.AddSingleton<GuildFetchService>()
.AddHostedService(serviceProvider => serviceProvider.GetRequiredService<GuildFetchService>());
public static IServiceCollection MaybeAddRedisCaches(this IServiceCollection services, Config config) public static IServiceCollection MaybeAddRedisCaches(this IServiceCollection services, Config config)
{ {
if (config.Database.Redis == null) if (config.Database.Redis == null)
{ {
return services.AddSingleton<IWebhookCache, InMemoryWebhookCache>(); return services
.AddSingleton<IWebhookCache, InMemoryWebhookCache>()
.AddSingleton<IMemberCache, InMemoryMemberCache>()
.AddSingleton<IInviteCache, InMemoryInviteCache>();
} }
return services.AddSingleton<RedisService>() return services.AddSingleton<RedisService>()
.AddScoped<IWebhookCache, RedisWebhookCache>(); .AddSingleton<IWebhookCache, RedisWebhookCache>()
.AddSingleton<IMemberCache, RedisMemberCache>()
.AddSingleton<IInviteCache, RedisInviteCache>();
} }
public static async Task Initialize(this WebApplication app) public static async Task Initialize(this WebApplication app)

View file

@ -0,0 +1,47 @@
using System.Collections.Concurrent;
using Catalogger.Backend.Cache;
using Humanizer;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Gateway.Commands;
using Remora.Discord.Gateway;
using Remora.Rest.Core;
namespace Catalogger.Backend.Services;
public class GuildFetchService(
ILogger logger,
DiscordGatewayClient gatewayClient,
IDiscordRestGuildAPI guildApi,
IInviteCache inviteCache) : BackgroundService
{
private readonly ILogger _logger = logger.ForContext<GuildFetchService>();
private readonly ConcurrentQueue<Snowflake> _guilds = new();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(500.Milliseconds());
while (await timer.WaitForNextTickAsync(stoppingToken))
{
if (!_guilds.TryPeek(out var guildId)) continue;
_logger.Debug("Fetching members and invites for guild {GuildId}", guildId);
gatewayClient.SubmitCommand(new RequestGuildMembers(guildId, "", 0));
var res = await guildApi.GetGuildInvitesAsync(guildId, stoppingToken);
if (res.Error != null)
{
_logger.Error("Fetching invites for guild {GuildId}: {Error}", guildId, res.Error);
}
else
{
await inviteCache.SetAsync(guildId, res.Entity);
}
_guilds.TryDequeue(out _);
}
}
public void EnqueueGuild(Snowflake guildId)
{
if (!_guilds.Contains(guildId)) _guilds.Enqueue(guildId);
}
}

View file

@ -1,6 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using App.Metrics; using App.Metrics;
using Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database; using Catalogger.Backend.Database;
using Humanizer; using Humanizer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -10,9 +11,9 @@ namespace Catalogger.Backend.Services;
public class MetricsCollectionService( public class MetricsCollectionService(
ILogger logger, ILogger logger,
GuildCacheService guildCache, GuildCache guildCache,
ChannelCacheService channelCache, ChannelCache channelCache,
UserCacheService userCache, UserCache userCache,
IMetrics metrics, IMetrics metrics,
IServiceProvider services) : BackgroundService IServiceProvider services) : BackgroundService
{ {

View file

@ -1,5 +1,6 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
using Remora.Discord.API; using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -14,7 +15,7 @@ public class WebhookExecutorService(
Config config, Config config,
ILogger logger, ILogger logger,
IWebhookCache webhookCache, IWebhookCache webhookCache,
ChannelCacheService channelCache, ChannelCache channelCache,
IDiscordRestWebhookAPI webhookApi) IDiscordRestWebhookAPI webhookApi)
{ {
private readonly ILogger _logger = logger.ForContext<WebhookExecutorService>(); private readonly ILogger _logger = logger.ForContext<WebhookExecutorService>();

View file

@ -1,2 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=pluralkit/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary> <s:Boolean x:Key="/Default/UserDictionary/Words/=pluralkit/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=remora/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>