// 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,
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
);
}