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