From c2094e3b7a20ba3e375d1d34f166d89e8ba17408 Mon Sep 17 00:00:00 2001 From: spiral Date: Wed, 13 Apr 2022 08:44:53 -0400 Subject: [PATCH] feat(bot): add Redis cache --- Myriad/Cache/RedisDiscordCache.cs | 346 +++++++++++++++++++++++++++++ Myriad/Myriad.csproj | 6 + Myriad/packages.lock.json | 144 ++++++++++++ PluralKit.Bot/BotConfig.cs | 1 + PluralKit.Bot/Init.cs | 5 + PluralKit.Bot/Modules.cs | 8 +- PluralKit.Bot/packages.lock.json | 2 + PluralKit.Tests/packages.lock.json | 2 + proto/discord_cache.proto | 47 ++++ 9 files changed, 560 insertions(+), 1 deletion(-) create mode 100644 Myriad/Cache/RedisDiscordCache.cs create mode 100644 proto/discord_cache.proto diff --git a/Myriad/Cache/RedisDiscordCache.cs b/Myriad/Cache/RedisDiscordCache.cs new file mode 100644 index 00000000..040432d6 --- /dev/null +++ b/Myriad/Cache/RedisDiscordCache.cs @@ -0,0 +1,346 @@ +using Google.Protobuf; + +using StackExchange.Redis; +using StackExchange.Redis.KeyspaceIsolation; + +using Serilog; + +using Myriad.Types; + +namespace Myriad.Cache; + +#pragma warning disable 4014 +public class RedisDiscordCache : IDiscordCache +{ + private readonly ILogger _logger; + public RedisDiscordCache(ILogger logger) + { + _logger = logger; + } + + private ConnectionMultiplexer _redis { get; set; } + private ulong _ownUserId { get; set; } + + public async Task InitAsync(string addr, ulong ownUserId) + { + _redis = await ConnectionMultiplexer.ConnectAsync(addr); + _ownUserId = ownUserId; + } + + private IDatabase db => _redis.GetDatabase().WithKeyPrefix("discord:"); + + public async ValueTask SaveGuild(Guild guild) + { + _logger.Verbose("Saving guild {GuildId} to redis", guild.Id); + + var g = new CachedGuild(); + g.Id = guild.Id; + g.Name = guild.Name; + g.OwnerId = guild.OwnerId; + g.PremiumTier = (int) guild.PremiumTier; + + var tr = db.CreateTransaction(); + + tr.HashSetAsync("guilds", guild.Id.HashWrapper(g)); + + foreach (var role in guild.Roles) + { + // Don't call SaveRole because that updates guild state + // and we just got a brand new one :) + // actually with redis it doesn't update guild state, but we're still doing it here because transaction + tr.HashSetAsync("roles", role.Id.HashWrapper(new CachedRole() + { + Id = role.Id, + Name = role.Name, + Position = role.Position, + Permissions = (ulong) role.Permissions, + Mentionable = role.Mentionable, + })); + + tr.HashSetAsync($"guild_roles:{guild.Id}", role.Id, true, When.NotExists); + } + + await tr.ExecuteAsync(); + } + + public async ValueTask SaveChannel(Channel channel) + { + _logger.Verbose("Saving channel {ChannelId} to redis", channel.Id); + + await db.HashSetAsync("channels", channel.Id.HashWrapper(channel.ToProtobuf())); + + if (channel.GuildId != null) + await db.HashSetAsync($"guild_channels:{channel.GuildId.Value}", channel.Id, true, When.NotExists); + + // todo: use a transaction for this? + if (channel.Recipients != null) + foreach (var recipient in channel.Recipients) + await SaveUser(recipient); + } + + public ValueTask SaveOwnUser(ulong userId) + { + // we get the own user ID in InitAsync, so no need to save it here + return default; + } + + public async ValueTask SaveUser(User user) + { + _logger.Verbose("Saving user {UserId} to redis", user.Id); + + var u = new CachedUser() + { + Id = user.Id, + Username = user.Username, + Discriminator = user.Discriminator, + Bot = user.Bot, + }; + + if (user.Avatar != null) + u.Avatar = user.Avatar; + + await db.HashSetAsync("users", user.Id.HashWrapper(u)); + } + + public async ValueTask SaveSelfMember(ulong guildId, GuildMemberPartial member) + { + _logger.Verbose("Saving self member for guild {GuildId} to redis", guildId); + + var gm = new CachedGuildMember(); + foreach (var role in member.Roles) + gm.Roles.Add(role); + + await db.HashSetAsync("members", guildId.HashWrapper(gm)); + } + + public async ValueTask SaveRole(ulong guildId, Myriad.Types.Role role) + { + _logger.Verbose("Saving role {RoleId} in {GuildId} to redis", role.Id, guildId); + + await db.HashSetAsync("roles", role.Id.HashWrapper(new CachedRole() + { + Id = role.Id, + Mentionable = role.Mentionable, + Name = role.Name, + Permissions = (ulong) role.Permissions, + Position = role.Position, + })); + + await db.HashSetAsync($"guild_roles:{guildId}", role.Id, true, When.NotExists); + } + + public async ValueTask SaveDmChannelStub(ulong channelId) + { + // Use existing channel object if present, otherwise add a stub + // We may get a message create before channel create and we want to have it saved + + if (await TryGetChannel(channelId) == null) + await db.HashSetAsync("channels", channelId.HashWrapper(new CachedChannel() + { + Id = channelId, + Type = (int) Channel.ChannelType.Dm, + })); + } + + public async ValueTask RemoveGuild(ulong guildId) + => await db.HashDeleteAsync("guilds", guildId); + + public async ValueTask RemoveChannel(ulong channelId) + { + var oldChannel = await TryGetChannel(channelId); + + if (oldChannel == null) + return; + + await db.HashDeleteAsync("channels", channelId); + + if (oldChannel.GuildId != null) + await db.HashDeleteAsync($"guild_channels:{oldChannel.GuildId.Value}", oldChannel.Id); + } + + public async ValueTask RemoveUser(ulong userId) + => await db.HashDeleteAsync("users", userId); + + // todo: try getting this from redis if we don't have it yet + public Task GetOwnUser() => Task.FromResult(_ownUserId); + + public async ValueTask RemoveRole(ulong guildId, ulong roleId) + { + await db.HashDeleteAsync("roles", roleId); + await db.HashDeleteAsync($"guild_roles:{guildId}", roleId); + } + + public async Task TryGetGuild(ulong guildId) + { + var redisGuild = await db.HashGetAsync("guilds", guildId); + if (redisGuild.IsNullOrEmpty) + return null; + + var guild = ((byte[])redisGuild).Unmarshal(); + + var redisRoles = await db.HashGetAllAsync($"guild_roles:{guildId}"); + + // todo: put this in a transaction or something + var roles = await Task.WhenAll(redisRoles.Select(r => TryGetRole((ulong)r.Name))); + +#pragma warning disable 8619 + return guild.FromProtobuf() with { Roles = roles } ; +#pragma warning restore 8619 + } + + public async Task TryGetChannel(ulong channelId) + { + var redisChannel = await db.HashGetAsync("channels", channelId); + if (redisChannel.IsNullOrEmpty) + return null; + + return ((byte[])redisChannel).Unmarshal().FromProtobuf(); + } + + public async Task TryGetUser(ulong userId) + { + var redisUser = await db.HashGetAsync("users", userId); + if (redisUser.IsNullOrEmpty) + return null; + + return ((byte[])redisUser).Unmarshal().FromProtobuf(); + } + + public async Task TryGetSelfMember(ulong guildId) + { + var redisMember = await db.HashGetAsync("members", guildId); + if (redisMember.IsNullOrEmpty) + return null; + + return new GuildMemberPartial() + { + Roles = ((byte[])redisMember).Unmarshal().Roles.ToArray() + }; + } + + public async Task TryGetRole(ulong roleId) + { + var redisRole = await db.HashGetAsync("roles", roleId); + if (redisRole.IsNullOrEmpty) + return null; + + var role = ((byte[])redisRole).Unmarshal(); + + return new Myriad.Types.Role() + { + Id = role.Id, + Name = role.Name, + Position = role.Position, + Permissions = (PermissionSet) role.Permissions, + Mentionable = role.Mentionable, + }; + } + + public IAsyncEnumerable GetAllGuilds() + { + // return _guilds.Values + // .Select(g => g.Guild) + // .ToAsyncEnumerable(); + return new Guild[] {}.ToAsyncEnumerable(); + } + + public async Task> GetGuildChannels(ulong guildId) + { + var redisChannels = await db.HashGetAllAsync($"guild_channels:{guildId}"); + if (redisChannels.Length == 0) + throw new ArgumentException("Guild not found", nameof(guildId)); + +#pragma warning disable 8619 + return await Task.WhenAll(redisChannels.Select(c => TryGetChannel((ulong) c.Name))); +#pragma warning restore 8619 + } +} + +internal static class CacheProtoExt +{ + public static Guild FromProtobuf(this CachedGuild guild) + => new Guild() + { + Id = guild.Id, + Name = guild.Name, + OwnerId = guild.OwnerId, + PremiumTier = (PremiumTier) guild.PremiumTier, + }; + + public static CachedChannel ToProtobuf(this Channel channel) + { + var c = new CachedChannel(); + c.Id = channel.Id; + c.Type = (int) channel.Type; + if (channel.Position != null) + c.Position = channel.Position.Value; + c.Name = channel.Name; + if (channel.PermissionOverwrites != null) + foreach (var overwrite in channel.PermissionOverwrites) + c.PermissionOverwrites.Add(new Overwrite() { + Id = overwrite.Id, + Type = (int) overwrite.Type, + Allow = (ulong) overwrite.Allow, + Deny = (ulong) overwrite.Deny, + }); + if (channel.GuildId != null) + c.GuildId = channel.GuildId.Value; + + return c; + } + + public static Channel FromProtobuf(this CachedChannel channel) + => new Channel() + { + Id = channel.Id, + Type = (Channel.ChannelType) channel.Type, + Position = channel.Position, + Name = channel.Name, + PermissionOverwrites = channel.PermissionOverwrites + .Select(x => new Channel.Overwrite() + { + Id = x.Id, + Type = (Channel.OverwriteType) x.Type, + Allow = (PermissionSet) x.Allow, + Deny = (PermissionSet) x.Deny, + }).ToArray(), + GuildId = channel.HasGuildId ? channel.GuildId : null, + ParentId = channel.HasParentId ? channel.ParentId : null, + }; + + public static User FromProtobuf(this CachedUser user) + => new User() + { + Id = user.Id, + Username = user.Username, + Discriminator = user.Discriminator, + Avatar = user.HasAvatar ? user.Avatar : null, + Bot = user.Bot, + }; +} + +internal static class RedisExt +{ + // convenience method + public static HashEntry[] HashWrapper(this ulong key, T value) where T : IMessage + => new[] { new HashEntry(key, value.ToByteArray()) }; +} + +public static class ProtobufExt +{ + private static Dictionary _parser = new(); + + public static byte[] Marshal(this IMessage message) => message.ToByteArray(); + + public static T Unmarshal(this byte[] message) where T : IMessage, new() + { + var type = typeof(T).ToString(); + if (_parser.ContainsKey(type)) + return (T)_parser[type].ParseFrom(message); + else + { + _parser.Add(type, new MessageParser(() => new T())); + return Unmarshal(message); + } + } +} \ No newline at end of file diff --git a/Myriad/Myriad.csproj b/Myriad/Myriad.csproj index 9302dc8b..62bbe267 100644 --- a/Myriad/Myriad.csproj +++ b/Myriad/Myriad.csproj @@ -21,6 +21,9 @@ + + + @@ -28,4 +31,7 @@ + + + diff --git a/Myriad/packages.lock.json b/Myriad/packages.lock.json index 76d05ffc..a0c90f89 100644 --- a/Myriad/packages.lock.json +++ b/Myriad/packages.lock.json @@ -2,6 +2,32 @@ "version": 1, "dependencies": { "net6.0": { + "Google.Protobuf": { + "type": "Direct", + "requested": "[3.13.0, )", + "resolved": "3.13.0", + "contentHash": "/6VgKCh0P59x/rYsBkCvkUanF0TeUYzwV9hzLIWgt23QRBaKHoxaaMkidEWhKibLR88c3PVCXyyrx9Xlb+Ne6w==", + "dependencies": { + "System.Memory": "4.5.2", + "System.Runtime.CompilerServices.Unsafe": "4.5.2" + } + }, + "Grpc.Net.ClientFactory": { + "type": "Direct", + "requested": "[2.32.0, )", + "resolved": "2.32.0", + "contentHash": "ixqSWxPK49P+5z6M2dDBHca0k+sXFe2KHHTJK3P+YXp6QOTHv5CHxNdaW8GrFF34Eh1FJ56Q2ADe383+FEAp6Q==", + "dependencies": { + "Grpc.Net.Client": "2.32.0", + "Microsoft.Extensions.Http": "3.0.3" + } + }, + "Grpc.Tools": { + "type": "Direct", + "requested": "[2.37.0, )", + "resolved": "2.37.0", + "contentHash": "cud/urkbw3QoQ8+kNeCy2YI0sHrh7td/1cZkVbH6hDLIXX7zzmJbV/KjYSiqiYtflQf+S5mJPLzDQWScN/QdDg==" + }, "Polly": { "type": "Direct", "requested": "[7.2.1, )", @@ -36,6 +62,109 @@ "resolved": "5.0.0", "contentHash": "cPtIuuH8TIjVHSi2ewwReWGW1PfChPE0LxPIDlfwVcLuTM9GANFTXiMB7k3aC4sk3f0cQU25LNKzx+jZMxijqw==" }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.32.0", + "contentHash": "t9H6P/oYA4ZQI4fWq4eEwq2GmMNqmOSRfz5+YIat7pQuFmz1hRC2Vq/fL9ZVV1mjd5kHqBlhupMdlsBOsaxeEw==", + "dependencies": { + "System.Memory": "4.5.3" + } + }, + "Grpc.Net.Client": { + "type": "Transitive", + "resolved": "2.32.0", + "contentHash": "T4lKl51ahaSprLcgoZvgn8zYwh834DpaPnrDs6jBRdipL2NHIAC0rPeE7UyzDp/lzv4Xll2tw1u65Fg9ckvErg==", + "dependencies": { + "Grpc.Net.Common": "2.32.0", + "Microsoft.Extensions.Logging.Abstractions": "3.0.3", + "System.Diagnostics.DiagnosticSource": "4.5.1" + } + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.32.0", + "contentHash": "vDsgy6fs+DlsylppjK9FBGTMMUe8vfAmaURV7ZTurM27itr8qBwymgqmwnVB2hcP1q35NqKx2NvPGe5S2IEnDw==", + "dependencies": { + "Grpc.Core.Api": "2.32.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "0F/MLd7yOSjhswQSFO6tkTREHxBffE/AS9gnvtx1jVFaKNdJPEj+2KOdREmYZ4Orpvf4nwXGwbRpX5SLlwIPEw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "3.0.3" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "ND+ka7njp3HgVJCVn0YgpuxbFWBOCkcQaK+UBGJNseDhjz6I/qpXmCqLK+nXSzxU7cdscFWnUJS6wjEEEkMvSQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "3.0.3" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "hRmuReZgWqWqko4RXaGd/DP9L7380+HafHgbR5CMc7AZYmoLpUmeV8O8sgZqJONCbzg1q0Sz8U8Gy99eETpGPA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "3.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "Vu59TuHl3zoRI8vwK6gQL2EbWI2Qf/uBHFkSJXb4pgNvW7g8yK6Gn3v1bXDIKbMKEneTApriHfCVde0O314K+g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "Wb1ejBzHhCvp7VMr+M7vlHoXb68mJ89IHj4L+TzL8yA+X7Iz2UTAEkl8aIbhRloroYJw5zvlIPtKF5uA4wFlxw==" + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "dcyB8szIcSynjVZRuFgqkZpPgTc5zeRSj1HMXSmNqWbHYKiPYJl8ZQgBHz6wmZNSUUNGpCs5uxUg8DZHHDC1Ew==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.0.3", + "Microsoft.Extensions.Logging": "3.0.3", + "Microsoft.Extensions.Options": "3.0.3" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "uAZppu6kWIgS+VtVjqmhh+k3bMztwWQR5HYxI++Cn5Kz5m099g0KJ+krUrckaZP9NqIplQu63tPR5YpNWnjLuw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "3.0.3", + "Microsoft.Extensions.DependencyInjection": "3.0.3", + "Microsoft.Extensions.Logging.Abstractions": "3.0.3", + "Microsoft.Extensions.Options": "3.0.3" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "m2Jyi/MEn043WMI1I6J1ALuCThktZ93rd7eqzYeLmMcA0bdZC+TBVl0LuEbEWM01dWeeBjOoagjNwQTzOi2r6A==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "cvP/w0kyT9TmrDPMY2ZHJBWx+gRH0jHKaJPkzN47UBpLLC4KbqVU5AoCMK47+ZChlINhqJX2WTflbLe5KufD/A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.0.3", + "Microsoft.Extensions.Primitives": "3.0.3" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "eJuAFVIH9zUZ7j7tCSbJD+tS0dueENIerwoGxFL8RYqCmbEqQ7wVOG+mt2mZAbEpnMPsGl1Fc/HIhWpB9ftwhg==" + }, "Microsoft.NETCore.Platforms": { "type": "Transitive", "resolved": "5.0.0", @@ -75,6 +204,11 @@ "System.Security.Permissions": "5.0.0" } }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "zCno/m44ymWhgLFh7tELDG9587q0l/EynPM0m4KgLaWQbz/TEKvNRX2YT5ip2qXW/uayifQ2ZqbnErsKJ4lYrQ==" + }, "System.Diagnostics.PerformanceCounter": { "type": "Transitive", "resolved": "5.0.0", @@ -99,6 +233,16 @@ "resolved": "5.0.0", "contentHash": "irMYm3vhVgRsYvHTU5b2gsT2CwT/SMM6LZFzuJjpIvT5Z4CshxNsaoBC1X/LltwuR3Opp8d6jOS/60WwOb7Q2Q==" }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "4.5.2", + "contentHash": "wprSFgext8cwqymChhrBLu62LMg/1u92bU+VOwyfBimSPVFXtsNqEWC92Pf9ofzJFlk4IHmJA75EDJn1b2goAQ==" + }, "System.Security.AccessControl": { "type": "Transitive", "resolved": "5.0.0", diff --git a/PluralKit.Bot/BotConfig.cs b/PluralKit.Bot/BotConfig.cs index e9da9887..8c42ed7f 100644 --- a/PluralKit.Bot/BotConfig.cs +++ b/PluralKit.Bot/BotConfig.cs @@ -20,6 +20,7 @@ public class BotConfig public string? GatewayQueueUrl { get; set; } public bool UseRedisRatelimiter { get; set; } = false; + public bool UseRedisCache { get; set; } = false; public string? RedisGatewayUrl { get; set; } diff --git a/PluralKit.Bot/Init.cs b/PluralKit.Bot/Init.cs index c7a05f1f..80a73dca 100644 --- a/PluralKit.Bot/Init.cs +++ b/PluralKit.Bot/Init.cs @@ -2,6 +2,7 @@ using Autofac; using Microsoft.Extensions.Configuration; +using Myriad.Cache; using Myriad.Gateway; using Myriad.Types; using Myriad.Rest; @@ -51,6 +52,10 @@ public class Init if (config.UseRedisRatelimiter) await redis.InitAsync(coreConfig); + var cache = services.Resolve(); + if (cache is RedisDiscordCache) + await (cache as RedisDiscordCache).InitAsync(coreConfig.RedisAddr, config.ClientId!.Value); + if (config.Cluster == null) { // "Connect to the database" (ie. set off database migrations and ensure state) diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 4ea60e8d..c26f1bd0 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -44,7 +44,13 @@ public class BotModule: Module }).AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); - builder.Register(c => { return new MemoryDiscordCache(); }).AsSelf().As().SingleInstance(); + builder.Register(c => { + var botConfig = c.Resolve(); + + if (botConfig.UseRedisCache) + return new RedisDiscordCache(c.Resolve()); + return new MemoryDiscordCache(); + }).AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); builder.Register(c => diff --git a/PluralKit.Bot/packages.lock.json b/PluralKit.Bot/packages.lock.json index b8f7eefb..33b4bd77 100644 --- a/PluralKit.Bot/packages.lock.json +++ b/PluralKit.Bot/packages.lock.json @@ -1520,6 +1520,8 @@ "myriad": { "type": "Project", "dependencies": { + "Google.Protobuf": "3.13.0", + "Grpc.Net.ClientFactory": "2.32.0", "Polly": "7.2.1", "Polly.Contrib.WaitAndRetry": "1.1.1", "Serilog": "2.10.0", diff --git a/PluralKit.Tests/packages.lock.json b/PluralKit.Tests/packages.lock.json index a99e3760..6aab4f1d 100644 --- a/PluralKit.Tests/packages.lock.json +++ b/PluralKit.Tests/packages.lock.json @@ -2063,6 +2063,8 @@ "myriad": { "type": "Project", "dependencies": { + "Google.Protobuf": "3.13.0", + "Grpc.Net.ClientFactory": "2.32.0", "Polly": "7.2.1", "Polly.Contrib.WaitAndRetry": "1.1.1", "Serilog": "2.10.0", diff --git a/proto/discord_cache.proto b/proto/discord_cache.proto new file mode 100644 index 00000000..fa11fde3 --- /dev/null +++ b/proto/discord_cache.proto @@ -0,0 +1,47 @@ +syntax = "proto3"; + +package myriad.cache; + +message Overwrite { + uint64 id = 1; + int32 type = 2; + uint64 allow = 3; + uint64 deny = 4; +} + +message CachedChannel { + uint64 id = 1; + int32 type = 2; + int32 position = 3; + string name = 4; + repeated Overwrite permission_overwrites = 5; + optional uint64 guild_id = 6; + optional uint64 parent_id = 7; +} + +message CachedGuildMember { + repeated uint64 roles = 1; +} + +message CachedGuild { + uint64 id = 1; + string name = 2; + uint64 owner_id = 3; + int32 premium_tier = 4; +} + +message CachedRole { + uint64 id = 1; + string name = 2; + int32 position = 3; + uint64 permissions = 4; + bool mentionable = 5; +} + +message CachedUser { + uint64 id = 1; + string username = 2; + string discriminator = 3; + optional string avatar = 4; + bool bot = 5; +} \ No newline at end of file