// Copyright (C) 2021-present sam (starshines.gay) // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . using Catalogger.Backend.Database.Redis; using Catalogger.Backend.Extensions; 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; using StackExchange.Redis; 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) { _logger.Debug( "Updating member {MemberId} in {GuildId}", newMember.User.ID, newMember.GuildID ); 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.Select(id => id.Value).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); } public async Task SetMemberNamesAsync(Snowflake guildId, IEnumerable members) { await redisService .GetDatabase() .HashSetAsync( MemberNamesKey(guildId), members .Select(m => new HashEntry(m.User.Value.Tag(), m.User.Value.ID.ToString())) .ToArray() ); } public async Task UpdateMemberNameAsync( Snowflake guildId, Snowflake userId, string prevName, string newName ) => await Task.WhenAll( redisService.GetDatabase().HashDeleteAsync(MemberNamesKey(guildId), prevName), redisService .GetDatabase() .HashSetAsync(MemberNamesKey(guildId), newName, userId.ToString()) ); public async Task> GetMemberNamesAsync( Snowflake guildId, string prefix ) { var entries = redisService .GetDatabase() .HashScanAsync(MemberNamesKey(guildId), $"{prefix}*", 50); var names = new List<(string Name, string Id)>(); await foreach (var entry in entries) names.Add((entry.Name, entry.Value)!); return names; } public async Task TryRemoveMemberNameAsync(Snowflake guildId, string username) => await redisService.GetDatabase().HashDeleteAsync(MemberNamesKey(guildId), username); public async Task RemoveAllMembersAsync(Snowflake guildId) => await redisService .GetDatabase() .KeyDeleteAsync([GuildMembersKey(guildId), MemberNamesKey(guildId)]); private const string GuildCacheKey = "cached-guilds"; private static string GuildMembersKey(Snowflake guildId) => $"guild-members:{guildId}"; private static string MemberNamesKey(Snowflake guildId) => $"guild-member-names:{guildId}"; } internal record RedisMember( RedisUser User, string? Nickname, string? Avatar, ulong[] 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.Select(id => id.Value).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, Banner: null, Roles.Select(DiscordSnowflake.New).ToList(), 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 ); }