diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 083f51e1..a0584db6 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -194,8 +194,6 @@ namespace PluralKit.Bot private Task HandleEvent(Func handler) { - _logger.Debug("Received event"); - // Inner function so we can await the handler without stalling the entire pipeline async Task Inner() { @@ -245,6 +243,7 @@ namespace PluralKit.Bot private ILifetimeScope _services; private CommandTree _tree; private Scope _sentryScope; + private ProxyCache _cache; // We're defining in the Autofac module that this class is instantiated with one instance per event // This means that the HandleMessage function will either be called once, or not at all @@ -252,7 +251,7 @@ namespace PluralKit.Bot // hence, we just store it in a local variable, ignoring it entirely if it's null. private IUserMessage _msg = null; - public PKEventHandler(ProxyService proxy, ILogger logger, IMetrics metrics, DiscordShardedClient client, DbConnectionFactory connectionFactory, ILifetimeScope services, CommandTree tree, Scope sentryScope) + public PKEventHandler(ProxyService proxy, ILogger logger, IMetrics metrics, DiscordShardedClient client, DbConnectionFactory connectionFactory, ILifetimeScope services, CommandTree tree, Scope sentryScope, ProxyCache cache) { _proxy = proxy; _logger = logger; @@ -262,6 +261,7 @@ namespace PluralKit.Bot _services = services; _tree = tree; _sentryScope = sentryScope; + _cache = cache; } public async Task HandleMessage(SocketMessage arg) @@ -288,6 +288,12 @@ namespace PluralKit.Bot {"message", msg.Id.ToString()}, }); + // We fetch information about the sending account *and* guild from the cache + GuildConfig cachedGuild = default; // todo: is this default correct? + if (msg.Channel is ITextChannel textChannel) cachedGuild = await _cache.GetGuildDataCached(textChannel.GuildId); + var cachedAccount = await _cache.GetAccountDataCached(msg.Author.Id); + // this ^ may be null, do remember that down the line + int argPos = -1; // Check if message starts with the command prefix if (msg.Content.StartsWith("pk;", StringComparison.InvariantCultureIgnoreCase)) argPos = 3; @@ -306,23 +312,16 @@ namespace PluralKit.Bot msg.Content.Substring(argPos).TrimStart().Length; argPos += trimStartLengthDiff; - // If it does, fetch the sender's system (because most commands need that) into the context, - // and start command execution - // Note system may be null if user has no system, hence `OrDefault` - PKSystem system; - using (var conn = await _connectionFactory.Obtain()) - system = await conn.QueryFirstOrDefaultAsync( - "select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", - new {Id = msg.Author.Id}); - - await _tree.ExecuteCommand(new Context(_services, msg, argPos, system)); + await _tree.ExecuteCommand(new Context(_services, msg, argPos, cachedAccount?.System)); } - else + else if (cachedAccount != null) { // If not, try proxying anyway + // but only if the account data we got before is present + // no data = no account = no system = no proxy! try { - await _proxy.HandleMessageAsync(msg); + await _proxy.HandleMessageAsync(cachedGuild, cachedAccount, msg); } catch (PKError e) { diff --git a/PluralKit.Bot/Commands/Autoproxy.cs b/PluralKit.Bot/Commands/Autoproxy.cs index e8966d36..7de40c6a 100644 --- a/PluralKit.Bot/Commands/Autoproxy.cs +++ b/PluralKit.Bot/Commands/Autoproxy.cs @@ -11,12 +11,10 @@ namespace PluralKit.Bot.Commands public class Autoproxy { private IDataStore _data; - private AutoproxyCacheService _cache; - public Autoproxy(IDataStore data, AutoproxyCacheService cache) + public Autoproxy(IDataStore data) { _data = data; - _cache = cache; } public async Task AutoproxyRoot(Context ctx) @@ -51,7 +49,6 @@ namespace PluralKit.Bot.Commands settings.AutoproxyMode = AutoproxyMode.Off; settings.AutoproxyMember = null; await _data.SetSystemGuildSettings(ctx.System, ctx.Guild.Id, settings); - await _cache.FlushCacheForSystem(ctx.System, ctx.Guild.Id); await ctx.Reply($"{Emojis.Success} Autoproxy turned off in this server."); } } @@ -68,7 +65,6 @@ namespace PluralKit.Bot.Commands settings.AutoproxyMode = AutoproxyMode.Latch; settings.AutoproxyMember = null; await _data.SetSystemGuildSettings(ctx.System, ctx.Guild.Id, settings); - await _cache.FlushCacheForSystem(ctx.System, ctx.Guild.Id); await ctx.Reply($"{Emojis.Success} Autoproxy set to latch mode in this server. Messages will now be autoproxied using the *last-proxied member* in this server."); } } @@ -85,7 +81,6 @@ namespace PluralKit.Bot.Commands settings.AutoproxyMode = AutoproxyMode.Front; settings.AutoproxyMember = null; await _data.SetSystemGuildSettings(ctx.System, ctx.Guild.Id, settings); - await _cache.FlushCacheForSystem(ctx.System, ctx.Guild.Id); await ctx.Reply($"{Emojis.Success} Autoproxy set to front mode in this server. Messages will now be autoproxied using the *current first fronter*, if any."); } } @@ -98,7 +93,6 @@ namespace PluralKit.Bot.Commands settings.AutoproxyMode = AutoproxyMode.Member; settings.AutoproxyMember = member.Id; await _data.SetSystemGuildSettings(ctx.System, ctx.Guild.Id, settings); - await _cache.FlushCacheForSystem(ctx.System, ctx.Guild.Id); await ctx.Reply($"{Emojis.Success} Autoproxy set to **{member.Name}** in this server."); } diff --git a/PluralKit.Bot/Commands/Member.cs b/PluralKit.Bot/Commands/Member.cs index 7f729877..b8748a7c 100644 --- a/PluralKit.Bot/Commands/Member.cs +++ b/PluralKit.Bot/Commands/Member.cs @@ -10,14 +10,11 @@ namespace PluralKit.Bot.Commands { private IDataStore _data; private EmbedService _embeds; - - private ProxyCacheService _proxyCache; - - public Member(IDataStore data, EmbedService embeds, ProxyCacheService proxyCache) + + public Member(IDataStore data, EmbedService embeds) { _data = data; _embeds = embeds; - _proxyCache = proxyCache; } public async Task NewMember(Context ctx) { @@ -51,8 +48,6 @@ namespace PluralKit.Bot.Commands await ctx.Reply($"{Emojis.Warn} You have reached the per-system member limit ({Limits.MaxMemberCount}). You will be unable to create additional members until existing members are deleted."); else if (memberCount >= Limits.MaxMembersWarnThreshold) await ctx.Reply($"{Emojis.Warn} You are approaching the per-system member limit ({memberCount} / {Limits.MaxMemberCount} members). Please review your member list for unused or duplicate members."); - - await _proxyCache.InvalidateResultsForSystem(ctx.System); } public async Task MemberRandom(Context ctx) @@ -68,11 +63,8 @@ namespace PluralKit.Bot.Commands throw Errors.NoMembersError; var randInt = randGen.Next(members.Count); await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild, ctx.LookupContextFor(ctx.System))); - } - - public async Task ViewMember(Context ctx, PKMember target) { var system = await _data.GetSystemById(target.System); diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs index 0cae2487..c137fbda 100644 --- a/PluralKit.Bot/Commands/MemberAvatar.cs +++ b/PluralKit.Bot/Commands/MemberAvatar.cs @@ -10,12 +10,10 @@ namespace PluralKit.Bot.Commands public class MemberAvatar { private IDataStore _data; - private ProxyCacheService _proxyCache; - public MemberAvatar(IDataStore data, ProxyCacheService proxyCache) + public MemberAvatar(IDataStore data) { _data = data; - _proxyCache = proxyCache; } public async Task Avatar(Context ctx, PKMember target) @@ -80,8 +78,6 @@ namespace PluralKit.Bot.Commands await ctx.Reply($"{Emojis.Success} Member avatar changed to attached image. Please note that if you delete the message containing the attachment, the avatar will stop working."); } // No-arguments no-attachment case covered by conditional at the very top - - await _proxyCache.InvalidateResultsForSystem(ctx.System); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs index 54d00d9e..5973dbba 100644 --- a/PluralKit.Bot/Commands/MemberEdit.cs +++ b/PluralKit.Bot/Commands/MemberEdit.cs @@ -11,12 +11,10 @@ namespace PluralKit.Bot.Commands public class MemberEdit { private IDataStore _data; - private ProxyCacheService _proxyCache; - public MemberEdit(IDataStore data, ProxyCacheService proxyCache) + public MemberEdit(IDataStore data) { _data = data; - _proxyCache = proxyCache; } public async Task Name(Context ctx, PKMember target) { @@ -50,8 +48,6 @@ namespace PluralKit.Bot.Commands if (memberGuildConfig.DisplayName != null) await ctx.Reply($"{Emojis.Note} Note that this member has a server name set ({memberGuildConfig.DisplayName.SanitizeMentions()}) in this server ({ctx.Guild.Name.SanitizeMentions()}), and will be proxied using that name here."); } - - await _proxyCache.InvalidateResultsForSystem(ctx.System); } public async Task Description(Context ctx, PKMember target) { @@ -141,8 +137,6 @@ namespace PluralKit.Bot.Commands } await ctx.Reply(successStr); - - await _proxyCache.InvalidateResultsForSystem(ctx.System); } public async Task ServerName(Context ctx, PKMember target) @@ -168,8 +162,6 @@ namespace PluralKit.Bot.Commands successStr += $"Member server name cleared. This member will now be proxied using their member name \"{target.Name.SanitizeMentions()}\" in this server ({ctx.Guild.Name.SanitizeMentions()})."; await ctx.Reply(successStr); - - await _proxyCache.InvalidateResultsForSystem(ctx.System); } public async Task KeepProxy(Context ctx, PKMember target) @@ -190,7 +182,6 @@ namespace PluralKit.Bot.Commands await ctx.Reply($"{Emojis.Success} Member proxy tags will now be included in the resulting message when proxying."); else await ctx.Reply($"{Emojis.Success} Member proxy tags will now not be included in the resulting message when proxying."); - await _proxyCache.InvalidateResultsForSystem(ctx.System); } public async Task Privacy(Context ctx, PKMember target) @@ -222,8 +213,6 @@ namespace PluralKit.Bot.Commands if (!await ctx.ConfirmWithReply(target.Hid)) throw Errors.MemberDeleteCancelled; await _data.DeleteMember(target); await ctx.Reply($"{Emojis.Success} Member deleted."); - - await _proxyCache.InvalidateResultsForSystem(ctx.System); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/MemberProxy.cs b/PluralKit.Bot/Commands/MemberProxy.cs index 0539f55a..81a13237 100644 --- a/PluralKit.Bot/Commands/MemberProxy.cs +++ b/PluralKit.Bot/Commands/MemberProxy.cs @@ -8,12 +8,10 @@ namespace PluralKit.Bot.Commands public class MemberProxy { private IDataStore _data; - private ProxyCacheService _proxyCache; - public MemberProxy(IDataStore data, ProxyCacheService proxyCache) + public MemberProxy(IDataStore data) { _data = data; - _proxyCache = proxyCache; } public async Task Proxy(Context ctx, PKMember target) @@ -117,9 +115,6 @@ namespace PluralKit.Bot.Commands await _data.SaveMember(target); await ctx.Reply($"{Emojis.Success} Member proxy tags set to `{requestedTag.ProxyString.SanitizeMentions()}`."); } - - await _proxyCache.InvalidateResultsForSystem(ctx.System); } - } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs index f31ae5e2..1a5d2a38 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -17,13 +17,11 @@ namespace PluralKit.Bot.Commands { private IDataStore _data; private EmbedService _embeds; - private ProxyCacheService _proxyCache; - public SystemEdit(IDataStore data, EmbedService embeds, ProxyCacheService proxyCache) + public SystemEdit(IDataStore data, EmbedService embeds) { _data = data; _embeds = embeds; - _proxyCache = proxyCache; } public async Task Name(Context ctx) @@ -62,8 +60,6 @@ namespace PluralKit.Bot.Commands await _data.SaveSystem(ctx.System); await ctx.Reply($"{Emojis.Success} System tag {(newTag != null ? $"changed. Member names will now end with `{newTag.SanitizeMentions()}` when proxied" : "cleared")}."); - - await _proxyCache.InvalidateResultsForSystem(ctx.System); } public async Task Avatar(Context ctx) @@ -115,8 +111,6 @@ namespace PluralKit.Bot.Commands var embed = url != null ? new EmbedBuilder().WithImageUrl(url).Build() : null; await ctx.Reply($"{Emojis.Success} System avatar changed.", embed: embed); } - - await _proxyCache.InvalidateResultsForSystem(ctx.System); } public async Task Delete(Context ctx) { @@ -128,8 +122,6 @@ namespace PluralKit.Bot.Commands await _data.DeleteSystem(ctx.System); await ctx.Reply($"{Emojis.Success} System deleted."); - - await _proxyCache.InvalidateResultsForSystem(ctx.System); } public async Task SystemProxy(Context ctx) diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 039c063f..e73ef05e 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -63,9 +63,7 @@ namespace PluralKit.Bot builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); - builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); - builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); @@ -73,9 +71,6 @@ namespace PluralKit.Bot // Sentry stuff builder.Register(_ => new Scope(null)).AsSelf().InstancePerLifetimeScope(); - // .NET stuff - builder.Populate(new ServiceCollection() - .AddMemoryCache()); // Utils builder.Register(c => new HttpClient diff --git a/PluralKit.Bot/PluralKit.Bot.csproj b/PluralKit.Bot/PluralKit.Bot.csproj index 50f63445..1f9aed1f 100644 --- a/PluralKit.Bot/PluralKit.Bot.csproj +++ b/PluralKit.Bot/PluralKit.Bot.csproj @@ -12,7 +12,6 @@ - diff --git a/PluralKit.Bot/Services/AutoproxyCacheService.cs b/PluralKit.Bot/Services/AutoproxyCacheService.cs deleted file mode 100644 index d3f043a0..00000000 --- a/PluralKit.Bot/Services/AutoproxyCacheService.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; - -using Dapper; - -using Microsoft.Extensions.Caching.Memory; - -namespace PluralKit.Bot -{ - public class AutoproxyCacheResult - { - public SystemGuildSettings GuildSettings; - public PKSystem System; - public PKMember AutoproxyMember; - } - public class AutoproxyCacheService - { - private IMemoryCache _cache; - private IDataStore _data; - private DbConnectionFactory _conn; - - public AutoproxyCacheService(IMemoryCache cache, DbConnectionFactory conn, IDataStore data) - { - _cache = cache; - _conn = conn; - _data = data; - } - - public async Task GetGuildSettings(ulong account, ulong guild) => - await _cache.GetOrCreateAsync(GetKey(account, guild), entry => FetchSettings(account, guild, entry)); - - public async Task FlushCacheForSystem(PKSystem system, ulong guild) - { - foreach (var account in await _data.GetSystemAccounts(system)) - FlushCacheFor(account, guild); - } - - public void FlushCacheFor(ulong account, ulong guild) => - _cache.Remove(GetKey(account, guild)); - - private async Task FetchSettings(ulong account, ulong guild, ICacheEntry entry) - { - using var conn = await _conn.Obtain(); - var data = (await conn.QueryAsync( - "select system_guild.*, systems.*, members.* from accounts inner join systems on systems.id = accounts.system inner join system_guild on system_guild.system = systems.id left join members on system_guild.autoproxy_member = members.id where accounts.uid = @Uid and system_guild.guild = @Guild", - (guildSettings, system, autoproxyMember) => new AutoproxyCacheResult - { - GuildSettings = guildSettings, - System = system, - AutoproxyMember = autoproxyMember - }, - new {Uid = account, Guild = guild})).FirstOrDefault(); - - if (data != null) - { - // Long expiry for accounts with no system/settings registered - entry.SetSlidingExpiration(TimeSpan.FromMinutes(5)); - entry.SetAbsoluteExpiration(TimeSpan.FromHours(1)); - } - else - { - // Shorter expiry if they already have settings - entry.SetSlidingExpiration(TimeSpan.FromMinutes(1)); - entry.SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); - } - - return data; - } - - private string GetKey(ulong account, ulong guild) => $"_system_guild_{account}_{guild}"; - } -} \ No newline at end of file diff --git a/PluralKit.Bot/Services/ProxyCacheService.cs b/PluralKit.Bot/Services/ProxyCacheService.cs deleted file mode 100644 index 5277cc36..00000000 --- a/PluralKit.Bot/Services/ProxyCacheService.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Dapper; -using Microsoft.Extensions.Caching.Memory; -using Serilog; - -namespace PluralKit.Bot -{ - public class ProxyCacheService - { - public class ProxyDatabaseResult - { - public PKSystem System; - public PKMember Member; - } - - private DbConnectionFactory _conn; - private IMemoryCache _cache; - private ILogger _logger; - - public ProxyCacheService(DbConnectionFactory conn, IMemoryCache cache, ILogger logger) - { - _conn = conn; - _cache = cache; - _logger = logger; - } - - public Task> GetResultsFor(ulong account) - { - _logger.Verbose("Looking up members for account {Account} in cache...", account); - return _cache.GetOrCreateAsync(GetKey(account), (entry) => FetchResults(account, entry)); - } - - public void InvalidateResultsFor(ulong account) - { - _logger.Information("Invalidating proxy cache for account {Account}", account); - _cache.Remove(GetKey(account)); - } - - public async Task InvalidateResultsForSystem(PKSystem system) - { - _logger.Debug("Invalidating proxy cache for system {System}", system.Id); - using (var conn = await _conn.Obtain()) - foreach (var accountId in await conn.QueryAsync("select uid from accounts where system = @Id", system)) - _cache.Remove(GetKey(accountId)); - } - - private async Task> FetchResults(ulong account, ICacheEntry entry) - { - _logger.Debug("Members for account {Account} not in cache, fetching", account); - using (var conn = await _conn.Obtain()) - { - var results = (await conn.QueryAsync( - "select members.*, systems.* from members, systems, accounts where members.system = systems.id and accounts.system = systems.id and accounts.uid = @Uid", - (member, system) => - new ProxyDatabaseResult {Member = member, System = system}, new {Uid = account})).ToList(); - - if (results.Count == 0) - { - // Long expiry for accounts with no system registered - entry.SetSlidingExpiration(TimeSpan.FromMinutes(5)); - entry.SetAbsoluteExpiration(TimeSpan.FromHours(1)); - } - else - { - // Shorter expiry if they already have a system - entry.SetSlidingExpiration(TimeSpan.FromMinutes(1)); - entry.SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); - } - - return results; - } - } - - private object GetKey(ulong account) - { - return $"_proxy_account_{account}"; - } - } -} \ No newline at end of file diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index 8c06303c..1fea6cbb 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -33,23 +33,21 @@ namespace PluralKit.Bot private EmbedService _embeds; private ILogger _logger; private WebhookExecutorService _webhookExecutor; - private ProxyCacheService _cache; - private AutoproxyCacheService _autoproxyCache; + private ProxyCache _cache; - public ProxyService(IDiscordClient client, LogChannelService logChannel, IDataStore data, EmbedService embeds, ILogger logger, ProxyCacheService cache, WebhookExecutorService webhookExecutor, DbConnectionFactory conn, AutoproxyCacheService autoproxyCache) + public ProxyService(IDiscordClient client, LogChannelService logChannel, IDataStore data, EmbedService embeds, ILogger logger, WebhookExecutorService webhookExecutor, DbConnectionFactory conn, ProxyCache cache) { _client = client; _logChannel = logChannel; _data = data; _embeds = embeds; - _cache = cache; _webhookExecutor = webhookExecutor; _conn = conn; - _autoproxyCache = autoproxyCache; + _cache = cache; _logger = logger.ForContext(); } - private ProxyMatch GetProxyTagMatch(string message, IEnumerable potentialMembers) + private ProxyMatch GetProxyTagMatch(string message, PKSystem system, IEnumerable potentialMembers) { // If the message starts with a @mention, and then proceeds to have proxy tags, // extract the mention and place it inside the inner message @@ -63,7 +61,7 @@ namespace PluralKit.Bot } // Flatten and sort by specificity (ProxyString length desc = prefix+suffix length desc = inner message asc = more specific proxy first!) - var ordered = potentialMembers.SelectMany(m => m.Member.ProxyTags.Select(tag => (tag, m))).OrderByDescending(p => p.Item1.ProxyString.Length); + var ordered = potentialMembers.SelectMany(m => m.ProxyTags.Select(tag => (tag, m))).OrderByDescending(p => p.Item1.ProxyString.Length); foreach (var (tag, match) in ordered) { if (tag.Prefix == null && tag.Suffix == null) continue; @@ -84,36 +82,35 @@ namespace PluralKit.Bot if (isMatch) { var inner = message.Substring(prefix.Length, message.Length - prefix.Length - suffix.Length); if (leadingMention != null) inner = $"{leadingMention} {inner}"; - return new ProxyMatch { Member = match.Member, System = match.System, InnerText = inner, ProxyTags = tag}; + return new ProxyMatch { Member = match, System = system, InnerText = inner, ProxyTags = tag}; } } return null; } - public async Task HandleMessageAsync(IMessage message) + public async Task HandleMessageAsync(GuildConfig guild, CachedAccount account, IMessage message) { // Bail early if this isn't in a guild channel if (!(message.Channel is ITextChannel channel)) return; // Find a member with proxy tags matching the message - var results = (await _cache.GetResultsFor(message.Author.Id)).ToList(); - var match = GetProxyTagMatch(message.Content, results); + var match = GetProxyTagMatch(message.Content, account.System, account.Members); + + // O(n) lookup since n is small (max ~100 in prod) and we're more constrained by memory (for a dictionary) here + var systemSettingsForGuild = account.SettingsForGuild(channel.GuildId); // If we didn't get a match by proxy tags, try to get one by autoproxy - if (match == null) match = await GetAutoproxyMatch(message, channel); + if (match == null) match = await GetAutoproxyMatch(account, systemSettingsForGuild, message, channel); // If we still haven't found any, just yeet if (match == null) return; - - // Gather all "extra" data from DB at once - var aux = await _data.GetAuxillaryProxyInformation(channel.GuildId, match.System, match.Member); // And make sure the channel's not blacklisted from proxying. - if (aux.Guild.Blacklist.Contains(channel.Id)) return; + if (guild.Blacklist.Contains(channel.Id)) return; // Make sure the system hasn't blacklisted the guild either - if (!aux.SystemGuild.ProxyEnabled) return; + if (!systemSettingsForGuild.ProxyEnabled) return; // We know message.Channel can only be ITextChannel as PK doesn't work in DMs/groups // Afterwards we ensure the bot has the right permissions, otherwise bail early @@ -122,9 +119,11 @@ namespace PluralKit.Bot // Can't proxy a message with no content and no attachment if (match.InnerText.Trim().Length == 0 && message.Attachments.Count == 0) return; + + var memberSettingsForGuild = account.SettingsForMemberGuild(match.Member.Id, channel.GuildId); // Get variables in order and all - var proxyName = match.Member.ProxyName(match.System.Tag, aux.MemberGuild.DisplayName); + var proxyName = match.Member.ProxyName(match.System.Tag, memberSettingsForGuild.DisplayName); var avatarUrl = match.Member.AvatarUrl ?? match.System.AvatarUrl; // If the name's too long (or short), bail @@ -150,7 +149,7 @@ namespace PluralKit.Bot // Store the message in the database, and log it in the log channel (if applicable) await _data.AddMessage(message.Author.Id, hookMessageId, channel.GuildId, message.Channel.Id, message.Id, match.Member); - await _logChannel.LogMessage(match.System, match.Member, hookMessageId, message.Id, message.Channel as IGuildChannel, message.Author, match.InnerText, aux.Guild); + await _logChannel.LogMessage(match.System, match.Member, hookMessageId, message.Id, message.Channel as IGuildChannel, message.Author, match.InnerText, guild); // Wait a second or so before deleting the original message await Task.Delay(1000); @@ -166,25 +165,21 @@ namespace PluralKit.Bot } } - private async Task GetAutoproxyMatch(IMessage message, IGuildChannel channel) + private async Task GetAutoproxyMatch(CachedAccount account, SystemGuildSettings guildSettings, IMessage message, IGuildChannel channel) { // For now we use a backslash as an "escape character", subject to change later if ((message.Content ?? "").TrimStart().StartsWith("\\")) return null; - // Fetch info from the cache, bail if we don't have anything (either no system or no autoproxy settings - AP defaults to off so this works) - var autoproxyCache = await _autoproxyCache.GetGuildSettings(message.Author.Id, channel.GuildId); - if (autoproxyCache == null) return null; - PKMember member = null; // Figure out which member to proxy as - switch (autoproxyCache.GuildSettings.AutoproxyMode) + switch (guildSettings.AutoproxyMode) { case AutoproxyMode.Off: // Autoproxy off, bail return null; case AutoproxyMode.Front: // Front mode: just use the current first fronter - member = await _data.GetFirstFronter(autoproxyCache.System); + member = await _data.GetFirstFronter(account.System); break; case AutoproxyMode.Latch: // Latch mode: find last proxied message, use *that* member @@ -201,7 +196,8 @@ namespace PluralKit.Bot break; case AutoproxyMode.Member: // Member mode: just use that member - member = autoproxyCache.AutoproxyMember; + // O(n) lookup since n is small (max 1000 de jure) and we're more constrained by memory (for a dictionary) here + member = account.Members.FirstOrDefault(m => m.Id == guildSettings.AutoproxyMember); break; } @@ -209,7 +205,7 @@ namespace PluralKit.Bot if (member == null) return null; return new ProxyMatch { - System = autoproxyCache.System, + System = account.System, Member = member, // Autoproxying members with no proxy tags is possible, return the correct result ProxyTags = member.ProxyTags.Count > 0 ? member.ProxyTags.First() : (ProxyTag?) null, diff --git a/PluralKit.Core/Modules.cs b/PluralKit.Core/Modules.cs index 2bca3638..5624cf24 100644 --- a/PluralKit.Core/Modules.cs +++ b/PluralKit.Core/Modules.cs @@ -3,8 +3,11 @@ using System; using App.Metrics; using Autofac; +using Autofac.Extensions.DependencyInjection; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using NodaTime; @@ -23,6 +26,9 @@ namespace PluralKit.Core builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().As(); builder.RegisterType().AsSelf(); + + builder.Populate(new ServiceCollection().AddMemoryCache()); + builder.RegisterType().AsSelf().SingleInstance(); } } diff --git a/PluralKit.Core/PluralKit.Core.csproj b/PluralKit.Core/PluralKit.Core.csproj index d3f98239..9f19e5a5 100644 --- a/PluralKit.Core/PluralKit.Core.csproj +++ b/PluralKit.Core/PluralKit.Core.csproj @@ -11,6 +11,7 @@ + diff --git a/PluralKit.Core/ProxyCache.cs b/PluralKit.Core/ProxyCache.cs new file mode 100644 index 00000000..deb42c59 --- /dev/null +++ b/PluralKit.Core/ProxyCache.cs @@ -0,0 +1,179 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +using Dapper; + +using Microsoft.Extensions.Caching.Memory; + +using Serilog; + +namespace PluralKit.Core +{ + public class ProxyCache + { + // We can NOT depend on IDataStore as that creates a cycle, since it needs access to call the invalidation methods + private IMemoryCache _cache; + private DbConnectionFactory _db; + private ILogger _logger; + + public ProxyCache(IMemoryCache cache, DbConnectionFactory db, ILogger logger) + { + _cache = cache; + _db = db; + _logger = logger; + } + + public Task InvalidateSystem(PKSystem system) => InvalidateSystem(system.Id); + + public async Task InvalidateSystem(int systemId) + { + if (_cache.TryGetValue(KeyForSystem(systemId), out var systemCache)) + { + // If we have the system cached here, just invalidate for all the accounts we have in the cache + _logger.Debug("Invalidating cache for system {System} and accounts {Accounts}", systemId, systemCache.Accounts); + _cache.Remove(KeyForSystem(systemId)); + foreach (var account in systemCache.Accounts) + _cache.Remove(KeyForAccount(account)); + return; + } + + // If we don't, look up the accounts from the database and invalidate *those* + + _cache.Remove(KeyForSystem(systemId)); + using var conn = await _db.Obtain(); + var accounts = (await conn.QueryAsync("select uid from accounts where system = @System", new {System = systemId})).ToArray(); + _logger.Debug("Invalidating cache for system {System} and accounts {Accounts}", systemId, accounts); + foreach (var account in accounts) + _cache.Remove(KeyForAccount(account)); + } + + public void InvalidateGuild(ulong guild) + { + _logger.Debug("Invalidating cache for guild {Guild}", guild); + _cache.Remove(KeyForGuild(guild)); + } + + public async Task GetGuildDataCached(ulong guild) + { + if (_cache.TryGetValue(KeyForGuild(guild), out var item)) + { + _logger.Verbose("Cache hit for guild {Guild}", guild); + return item; + } + + // When changing this, also see PostgresDataStore::GetOrCreateGuildConfig + using var conn = await _db.Obtain(); + + _logger.Verbose("Cache miss for guild {Guild}", guild); + var guildConfig = (await conn.QuerySingleOrDefaultAsync( + "insert into servers (id) values (@Id) on conflict do nothing; select * from servers where id = @Id", + new {Id = guild})).Into(); + + _cache.CreateEntry(KeyForGuild(guild)) + .SetValue(guildConfig) + .SetSlidingExpiration(TimeSpan.FromMinutes(5)) + .SetAbsoluteExpiration(TimeSpan.FromMinutes(30)) + .Dispose(); // Don't ask, but this *saves* the entry. Somehow. + return guildConfig; + } + + public async Task GetAccountDataCached(ulong account) + { + if (_cache.TryGetValue(KeyForAccount(account), out var item)) + { + _logger.Verbose("Cache hit for account {Account}", account); + return item; + } + + _logger.Verbose("Cache miss for account {Account}", account); + + var data = await GetAccountData(account); + if (data == null) + { + _logger.Debug("Cached data for account {Account} (no system)", account); + + // If we didn't find any value, set a pretty long expiry and the value to null + _cache.CreateEntry(KeyForAccount(account)) + .SetValue(null) + .SetSlidingExpiration(TimeSpan.FromMinutes(5)) + .SetAbsoluteExpiration(TimeSpan.FromHours(1)) + .Dispose(); // Don't ask, but this *saves* the entry. Somehow. + return null; + } + + // If we *did* find the value, cache it for *every account in the system* with a shorter expiry + _logger.Debug("Cached data for system {System} and accounts {Account}", data.System.Id, data.Accounts); + foreach (var linkedAccount in data.Accounts) + { + _cache.CreateEntry(KeyForAccount(linkedAccount)) + .SetValue(data) + .SetSlidingExpiration(TimeSpan.FromMinutes(5)) + .SetAbsoluteExpiration(TimeSpan.FromMinutes(20)) + .Dispose(); // Don't ask, but this *saves* the entry. Somehow. + + // And also do it for the system itself so we can look up by that + _cache.CreateEntry(KeyForSystem(data.System.Id)) + .SetValue(data) + .SetSlidingExpiration(TimeSpan.FromMinutes(5)) + .SetAbsoluteExpiration(TimeSpan.FromMinutes(20)) + .Dispose(); // Don't ask, but this *saves* the entry. Somehow. + } + + return data; + } + + private async Task GetAccountData(ulong account) + { + using var conn = await _db.Obtain(); + + // Doing this as two queries instead of a two-step join to avoid sending duplicate rows for the system over the network for each member + // This *may* be less efficient, haven't done too much stuff about this but having the system ID saved is very useful later on + + var system = await conn.QuerySingleOrDefaultAsync("select systems.* from accounts inner join systems on systems.id = accounts.system where accounts.uid = @Account", new { Account = account }); + if (system == null) return null; // No system = no members = no cache value + + // Fetches: + // - List of accounts in the system + // - List of members in the system + // - List of guild settings for the system (for every guild) + // - List of guild settings for each member (for every guild) + // I'm slightly worried the volume of guild settings will get too much, but for simplicity reasons I decided + // against caching them individually per-guild, since I can't imagine they'll be edited *that* much + var result = await conn.QueryMultipleAsync(@" + select uid from accounts where system = @System; + select * from members where system = @System; + select * from system_guild where system = @System; + select member_guild.* from members inner join member_guild on member_guild.member = members.id where members.system = @System; + ", new {System = system.Id}); + + return new CachedAccount + { + System = system, + Accounts = (await result.ReadAsync()).ToArray(), + Members = (await result.ReadAsync()).ToArray(), + SystemGuild = (await result.ReadAsync()).ToArray(), + MemberGuild = (await result.ReadAsync()).ToArray() + }; + } + + private string KeyForAccount(ulong account) => $"_account_cache_{account}"; + private string KeyForSystem(int system) => $"_system_cache_{system}"; + private string KeyForGuild(ulong guild) => $"_guild_cache_{guild}"; + } + + public class CachedAccount + { + public PKSystem System; + public PKMember[] Members; + public SystemGuildSettings[] SystemGuild; + public MemberGuildSettings[] MemberGuild; + public ulong[] Accounts; + + public SystemGuildSettings SettingsForGuild(ulong guild) => + SystemGuild.FirstOrDefault(s => s.Guild == guild) ?? new SystemGuildSettings(); + + public MemberGuildSettings SettingsForMemberGuild(int memberId, ulong guild) => + MemberGuild.FirstOrDefault(m => m.Member == memberId && m.Guild == guild) ?? new MemberGuildSettings(); + } +} \ No newline at end of file diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index 197c7a64..ef05a8b5 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -6,6 +6,8 @@ using System.Threading.Tasks; using Dapper; using NodaTime; +using PluralKit.Core; + using Serilog; namespace PluralKit { @@ -76,6 +78,7 @@ namespace PluralKit { public class SystemGuildSettings { + public ulong Guild { get; set; } public bool ProxyEnabled { get; set; } = true; public AutoproxyMode AutoproxyMode { get; set; } = AutoproxyMode.Off; @@ -84,6 +87,8 @@ namespace PluralKit { public class MemberGuildSettings { + public int Member { get; set; } + public ulong Guild { get; set; } public string DisplayName { get; set; } } @@ -425,11 +430,13 @@ namespace PluralKit { public class PostgresDataStore: IDataStore { private DbConnectionFactory _conn; private ILogger _logger; + private ProxyCache _cache; - public PostgresDataStore(DbConnectionFactory conn, ILogger logger) + public PostgresDataStore(DbConnectionFactory conn, ILogger logger, ProxyCache cache) { _conn = conn; _logger = logger; + _cache = cache; } public async Task> GetConflictingProxies(PKSystem system, ProxyTag tag) @@ -467,6 +474,7 @@ namespace PluralKit { settings.AutoproxyMode, settings.AutoproxyMember }); + await _cache.InvalidateSystem(system); } public async Task CreateSystem(string systemName = null) { @@ -481,6 +489,7 @@ namespace PluralKit { system = await conn.QuerySingleAsync("insert into systems (hid, name) values (@Hid, @Name) returning *", new { Hid = hid, Name = systemName }); _logger.Information("Created system {System}", system.Id); + // New system has no accounts, therefore nothing gets cached, therefore no need to invalidate caches right here return system; } @@ -491,6 +500,7 @@ namespace PluralKit { await conn.ExecuteAsync("insert into accounts (uid, system) values (@Id, @SystemId) on conflict do nothing", new { Id = accountId, SystemId = system.Id }); _logger.Information("Linked system {System} to account {Account}", system.Id, accountId); + await _cache.InvalidateSystem(system); } public async Task RemoveAccount(PKSystem system, ulong accountId) { @@ -498,6 +508,7 @@ namespace PluralKit { await conn.ExecuteAsync("delete from accounts where uid = @Id and system = @SystemId", new { Id = accountId, SystemId = system.Id }); _logger.Information("Unlinked system {System} from account {Account}", system.Id, accountId); + await _cache.InvalidateSystem(system); } public async Task GetSystemByAccount(ulong accountId) { @@ -526,12 +537,15 @@ namespace PluralKit { await conn.ExecuteAsync("update systems set name = @Name, description = @Description, tag = @Tag, avatar_url = @AvatarUrl, token = @Token, ui_tz = @UiTz, description_privacy = @DescriptionPrivacy, member_list_privacy = @MemberListPrivacy, front_privacy = @FrontPrivacy, front_history_privacy = @FrontHistoryPrivacy where id = @Id", system); _logger.Information("Updated system {@System}", system); + await _cache.InvalidateSystem(system); } public async Task DeleteSystem(PKSystem system) { using (var conn = await _conn.Obtain()) await conn.ExecuteAsync("delete from systems where id = @Id", system); + _logger.Information("Deleted system {System}", system.Id); + await _cache.InvalidateSystem(system); } public async Task> GetSystemAccounts(PKSystem system) @@ -568,6 +582,7 @@ namespace PluralKit { }); _logger.Information("Created member {Member}", member.Id); + await _cache.InvalidateSystem(system); return member; } @@ -598,6 +613,7 @@ namespace PluralKit { tx.Commit(); _logger.Information("Created {MemberCount} members for system {SystemID}", names.Count(), system.Hid); + await _cache.InvalidateSystem(system); return results; } } @@ -630,6 +646,7 @@ namespace PluralKit { await conn.ExecuteAsync("update members set name = @Name, display_name = @DisplayName, description = @Description, color = @Color, avatar_url = @AvatarUrl, birthday = @Birthday, pronouns = @Pronouns, proxy_tags = @ProxyTags, keep_proxy = @KeepProxy, member_privacy = @MemberPrivacy where id = @Id", member); _logger.Information("Updated member {@Member}", member); + await _cache.InvalidateSystem(member.System); } public async Task DeleteMember(PKMember member) { @@ -637,6 +654,7 @@ namespace PluralKit { await conn.ExecuteAsync("delete from members where id = @Id", member); _logger.Information("Deleted member {@Member}", member); + await _cache.InvalidateSystem(member.System); } public async Task GetMemberGuildSettings(PKMember member, ulong guild) @@ -653,6 +671,7 @@ namespace PluralKit { await conn.ExecuteAsync( "insert into member_guild (member, guild, display_name) values (@Member, @Guild, @DisplayName) on conflict (member, guild) do update set display_name = @Displayname", new {Member = member.Id, Guild = guild, DisplayName = settings.DisplayName}); + await _cache.InvalidateSystem(member.System); } public async Task GetMemberMessageCount(PKMember member) @@ -749,7 +768,7 @@ namespace PluralKit { } // Same as GuildConfig, but with ISet as long[] instead. - private struct DatabaseCompatibleGuildConfig + public struct DatabaseCompatibleGuildConfig { public ulong Id { get; set; } public ulong? LogChannel { get; set; } @@ -768,6 +787,7 @@ namespace PluralKit { public async Task GetOrCreateGuildConfig(ulong guild) { + // When changing this, also see ProxyCache::GetGuildDataCached using (var conn = await _conn.Obtain()) { return (await conn.QuerySingleOrDefaultAsync( @@ -787,6 +807,7 @@ namespace PluralKit { Blacklist = cfg.Blacklist.Select(c => (long) c).ToList() }); _logger.Information("Updated guild configuration {@GuildCfg}", cfg); + _cache.InvalidateGuild(cfg.Id); } public async Task GetAuxillaryProxyInformation(ulong guild, PKSystem system, PKMember member)