From fbff0282715ab34303ea901870eaa61db14ee3db Mon Sep 17 00:00:00 2001 From: Toastie Date: Thu, 27 Jun 2024 20:22:54 +1200 Subject: [PATCH] Updated Patronage module --- .../Patronage/Config/PatronageConfig.cs | 14 +- .../Patronage/CurrencyRewardService.cs | 47 +- .../Modules/Patronage/InsufficientTier.cs | 11 - .../Patronage/Patreon/PatreonClient.cs | 11 +- .../Patronage/Patreon/PatreonCredentials.cs | 2 +- .../Modules/Patronage/Patreon/PatreonData.cs | 2 +- .../Patronage/Patreon/PatreonMemberData.cs | 5 - .../Patreon/PatreonSubscriptionHandler.cs | 18 +- .../Modules/Patronage/PatronageCommands.cs | 58 +- .../Modules/Patronage/PatronageService.cs | 666 +++++------------- 10 files changed, 230 insertions(+), 604 deletions(-) delete mode 100644 src/EllieBot/Modules/Patronage/InsufficientTier.cs diff --git a/src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs b/src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs index 56f166f..254c3db 100644 --- a/src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs +++ b/src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs @@ -4,10 +4,11 @@ namespace EllieBot.Modules.Patronage; public class PatronageConfig : ConfigServiceBase { - public override string Name + public override string Name => "patron"; - private static readonly TypedKey _changeKey; + private static readonly TypedKey _changeKey + = new("config.patron.updated"); private const string FILE_PATH = "data/patron.yml"; @@ -31,5 +32,14 @@ public class PatronageConfig : ConfigServiceBase c.IsEnabled = false; } }); + + + ModifyConfig(c => + { + if (c.Version == 2) + { + c.Version = 3; + } + }); } } \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/CurrencyRewardService.cs b/src/EllieBot/Modules/Patronage/CurrencyRewardService.cs index 336fe9c..b61c646 100644 --- a/src/EllieBot/Modules/Patronage/CurrencyRewardService.cs +++ b/src/EllieBot/Modules/Patronage/CurrencyRewardService.cs @@ -1,6 +1,7 @@ #nullable disable using LinqToDB; using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; using EllieBot.Modules.Gambling.Services; using EllieBot.Modules.Patronage; using EllieBot.Services.Currency; @@ -8,7 +9,7 @@ using EllieBot.Db.Models; namespace EllieBot.Modules.Utility; -public sealed class CurrencyRewardService : IEService, IDisposable +public sealed class CurrencyRewardService : IEService, IReadyExecutor { private readonly ICurrencyService _cs; private readonly IPatronageService _ps; @@ -32,16 +33,14 @@ public sealed class CurrencyRewardService : IEService, IDisposable _config = config; _client = client; + } + + public Task OnReadyAsync() + { _ps.OnNewPatronPayment += OnNewPayment; _ps.OnPatronRefunded += OnPatronRefund; _ps.OnPatronUpdated += OnPatronUpdate; - } - - public void Dispose() - { - _ps.OnNewPatronPayment -= OnNewPayment; - _ps.OnPatronRefunded -= OnPatronRefund; - _ps.OnPatronUpdated -= OnPatronUpdate; + return Task.CompletedTask; } private async Task OnPatronUpdate(Patron oldPatron, Patron newPatron) @@ -58,17 +57,17 @@ public sealed class CurrencyRewardService : IEService, IDisposable old = await ctx.GetTable() .Where(x => x.PlatformUserId == newPatron.UniquePlatformUserId) .FirstOrDefaultAsync(); - + if (old is null) { await OnNewPayment(newPatron); return; } - + // no action as the amount is the same or lower if (old.AmountRewardedThisMonth >= newAmount) return; - + var count = await ctx.GetTable() .Where(x => x.PlatformUserId == newPatron.UniquePlatformUserId) .UpdateAsync(_ => new() @@ -91,21 +90,21 @@ public sealed class CurrencyRewardService : IEService, IDisposable (int)(newAmount / conf.PatreonCurrencyPerCent), newAmount, out var percentBonus); - + var realOldAmount = GetRealCurrencyReward( (int)(oldAmount / conf.PatreonCurrencyPerCent), oldAmount, out _); - + var diff = realNewAmount - realOldAmount; if (diff <= 0) return; // no action if new is lower // if the user pledges 5$ or more, they will get X % more flowers where X is amount in dollars, // up to 100% - - await _cs.AddAsync(newPatron.UserId, diff, new TxData("patron", "update")); - + + await _cs.AddAsync(newPatron.UserId, diff, new TxData("patron","update")); + _ = SendMessageToUser(newPatron.UserId, $"You've received an additional **{diff}**{_config.Data.Currency.Sign} as a currency reward (+{percentBonus}%)!"); } @@ -140,12 +139,12 @@ public sealed class CurrencyRewardService : IEService, IDisposable await using var ctx = _db.GetDbContext(); await ctx.GetTable() .InsertOrUpdateAsync(() => new() - { - PlatformUserId = patron.UniquePlatformUserId, - UserId = patron.UserId, - AmountRewardedThisMonth = amount, - LastReward = patron.PaidAt, - }, + { + PlatformUserId = patron.UniquePlatformUserId, + UserId = patron.UserId, + AmountRewardedThisMonth = amount, + LastReward = patron.PaidAt, + }, old => new() { AmountRewardedThisMonth = amount, @@ -156,7 +155,7 @@ public sealed class CurrencyRewardService : IEService, IDisposable { PlatformUserId = patron.UniquePlatformUserId }); - + var realAmount = GetRealCurrencyReward(patron.Amount, amount, out var percentBonus); await _cs.AddAsync(patron.UserId, realAmount, new("patron", "new")); _ = SendMessageToUser(patron.UserId, @@ -174,7 +173,7 @@ public sealed class CurrencyRewardService : IEService, IDisposable var eb = _sender.CreateEmbed() .WithOkColor() .WithDescription(message); - + await _sender.Response(user).Embed(eb).SendAsync(); } catch diff --git a/src/EllieBot/Modules/Patronage/InsufficientTier.cs b/src/EllieBot/Modules/Patronage/InsufficientTier.cs deleted file mode 100644 index 26a0675..0000000 --- a/src/EllieBot/Modules/Patronage/InsufficientTier.cs +++ /dev/null @@ -1,11 +0,0 @@ -using EllieBot.Db.Models; - -namespace EllieBot.Modules.Patronage; - -public readonly struct InsufficientTier -{ - public FeatureType FeatureType { get; init; } - public string Feature { get; init; } - public PatronTier RequiredTier { get; init; } - public PatronTier UserTier { get; init; } -} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs index ad65448..2ac5820 100644 --- a/src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs @@ -11,11 +11,11 @@ public class PatreonClient : IDisposable private readonly string _clientId; private readonly string _clientSecret; private string refreshToken; - - + + private string accessToken = string.Empty; private readonly HttpClient _http; - + private DateTime refreshAt = DateTime.UtcNow; public PatreonClient(string clientId, string clientSecret, string refreshToken) @@ -101,7 +101,7 @@ public class PatreonClient : IDisposable return OneOf>, Error>.FromT0( GetMembersInternalAsync(campaignId)); } - + private async IAsyncEnumerable> GetMembersInternalAsync(string campaignId) { _http.DefaultRequestHeaders.Clear(); @@ -140,9 +140,8 @@ public class PatreonClient : IDisposable LastChargeDate = m.Attributes.LastChargeDate, LastChargeStatus = m.Attributes.LastChargeStatus }) - .Where(x => x.UserId == 140788173885276160) .ToArray(); - + yield return userData; } while (!string.IsNullOrWhiteSpace(page = data.Links?.Next)); diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs index 5eb6f1f..768a1f6 100644 --- a/src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs @@ -7,4 +7,4 @@ public readonly struct PatreonCredentials public string ClientSecret { get; init; } public string AccessToken { get; init; } public string RefreshToken { get; init; } -} +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonData.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonData.cs index f5d120e..6b33a80 100644 --- a/src/EllieBot/Modules/Patronage/Patreon/PatreonData.cs +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonData.cs @@ -1,4 +1,4 @@ -#nullable disable +#nullable disable using System.Text.Json.Serialization; namespace EllieBot.Modules.Patronage; diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs index 58656b9..4698b43 100644 --- a/src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs @@ -25,9 +25,4 @@ public sealed class PatreonMemberData : ISubscriberData "Declined" or "Pending" => SubscriptionChargeStatus.Unpaid, _ => SubscriptionChargeStatus.Other, }; -} - -public sealed class PatreonPledgeData -{ - } \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonSubscriptionHandler.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonSubscriptionHandler.cs index 1fd170e..edc0f08 100644 --- a/src/EllieBot/Modules/Patronage/Patreon/PatreonSubscriptionHandler.cs +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonSubscriptionHandler.cs @@ -6,8 +6,8 @@ namespace EllieBot.Modules.Patronage; /// public sealed class PatreonSubscriptionHandler : ISubscriptionHandler, IEService { - private readonly IBotCredsProvider _credsProvider; - private readonly PatreonClient _patreonClient; + private readonly IBotCredsProvider _credsProvider; + private readonly PatreonClient _patreonClient; public PatreonSubscriptionHandler(IBotCredsProvider credsProvider) { @@ -15,26 +15,26 @@ public sealed class PatreonSubscriptionHandler : ISubscriptionHandler, IEService var botCreds = credsProvider.GetCreds(); _patreonClient = new PatreonClient(botCreds.Patreon.ClientId, botCreds.Patreon.ClientSecret, botCreds.Patreon.RefreshToken); } - + public async IAsyncEnumerable> GetPatronsAsync() { var botCreds = _credsProvider.GetCreds(); - + if (string.IsNullOrWhiteSpace(botCreds.Patreon.CampaignId) || string.IsNullOrWhiteSpace(botCreds.Patreon.ClientId) || string.IsNullOrWhiteSpace(botCreds.Patreon.ClientSecret) || string.IsNullOrWhiteSpace(botCreds.Patreon.RefreshToken)) yield break; - + var result = await _patreonClient.RefreshTokenAsync(false); if (!result.TryPickT0(out _, out var error)) { Log.Warning("Unable to refresh patreon token: {ErrorMessage}", error.Value); yield break; } - + var patreonCreds = _patreonClient.GetCredentials(); - + _credsProvider.ModifyCredsFile(c => { c.Patreon.AccessToken = patreonCreds.AccessToken; @@ -58,7 +58,7 @@ public sealed class PatreonSubscriptionHandler : ISubscriptionHandler, IEService Log.Warning(ex, "Unexpected error while refreshing patreon members: {ErroMessage}", ex.Message); - + yield break; } @@ -71,7 +71,7 @@ public sealed class PatreonSubscriptionHandler : ISubscriptionHandler, IEService && x.LastCharge is { } lc && lc.ToUniversalTime().ToDateOnly() >= firstOfThisMonth) .ToArray(); - + if (toReturn.Length > 0) yield return toReturn; } diff --git a/src/EllieBot/Modules/Patronage/PatronageCommands.cs b/src/EllieBot/Modules/Patronage/PatronageCommands.cs index 8d0a38a..fee2c33 100644 --- a/src/EllieBot/Modules/Patronage/PatronageCommands.cs +++ b/src/EllieBot/Modules/Patronage/PatronageCommands.cs @@ -71,17 +71,16 @@ public partial class Help return; } - var patron = await _service.GetPatronAsync(user.Id); - var quotaStats = await _service.GetUserQuotaStatistic(user.Id); + var maybePatron = await _service.GetPatronAsync(user.Id); + + var quotaStats = await _service.LimitStats(user.Id); var eb = _sender.CreateEmbed() .WithAuthor(user) .WithTitle(GetText(strs.patron_info)) .WithOkColor(); - if (quotaStats.Commands.Count == 0 - && quotaStats.Groups.Count == 0 - && quotaStats.Modules.Count == 0) + if (quotaStats.Count == 0 || maybePatron is not { } patron) { eb.WithDescription(GetText(strs.no_quota_found)); } @@ -97,26 +96,9 @@ public partial class Help eb.AddField(GetText(strs.quotas), "⁣", false); - if (quotaStats.Commands.Count > 0) - { - var text = GetQuotaList(quotaStats.Commands); - if (!string.IsNullOrWhiteSpace(text)) - eb.AddField(GetText(strs.commands), text, true); - } - - if (quotaStats.Groups.Count > 0) - { - var text = GetQuotaList(quotaStats.Groups); - if (!string.IsNullOrWhiteSpace(text)) - eb.AddField(GetText(strs.groups), text, true); - } - - if (quotaStats.Modules.Count > 0) - { - var text = GetQuotaList(quotaStats.Modules); - if (!string.IsNullOrWhiteSpace(text)) - eb.AddField(GetText(strs.modules), text, true); - } + var text = GetQuotaList(quotaStats); + if (!string.IsNullOrWhiteSpace(text)) + eb.AddField(GetText(strs.modules), text, true); } @@ -131,26 +113,28 @@ public partial class Help } } - private string GetQuotaList(IReadOnlyDictionary featureQuotaStats) + private string GetQuotaList( + IReadOnlyDictionary featureQuotaStats) { var text = string.Empty; - foreach (var (key, q) in featureQuotaStats) + foreach (var (key, (cur, quota)) in featureQuotaStats) { text += $"\n⁣\t`{key}`\n"; - if (q.Hourly != default) - text += $"⁣ ⁣ {GetEmoji(q.Hourly)} {q.Hourly.Cur}/{q.Hourly.Max} per hour\n"; - if (q.Daily != default) - text += $"⁣ ⁣ {GetEmoji(q.Daily)} {q.Daily.Cur}/{q.Daily.Max} per day\n"; - if (q.Monthly != default) - text += $"⁣ ⁣ {GetEmoji(q.Monthly)} {q.Monthly.Cur}/{q.Monthly.Max} per month\n"; + if (quota.QuotaPeriod == QuotaPer.PerHour) + text += $"⁣ ⁣ {cur}/{(quota.Quota == -1 ? "∞" : quota.Quota)} {QuotaPeriodToString(quota.QuotaPeriod)}\n"; } return text; } - private string GetEmoji((uint Cur, uint Max) limit) - => limit.Cur < limit.Max - ? "✅" - : "⚠️"; + public string QuotaPeriodToString(QuotaPer per) + => per switch + { + QuotaPer.PerHour => "per hour", + QuotaPer.PerDay => "per day", + QuotaPer.PerMonth => "per month", + QuotaPer.Total => "total", + _ => throw new ArgumentOutOfRangeException(nameof(per), per, null) + }; } } \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/PatronageService.cs b/src/EllieBot/Modules/Patronage/PatronageService.cs index 3d5f62c..e6d7899 100644 --- a/src/EllieBot/Modules/Patronage/PatronageService.cs +++ b/src/EllieBot/Modules/Patronage/PatronageService.cs @@ -2,9 +2,8 @@ using LinqToDB.EntityFrameworkCore; using EllieBot.Common.ModuleBehaviors; using EllieBot.Db.Models; -using OneOf; -using OneOf.Types; -using CommandInfo = Discord.Commands.CommandInfo; +using StackExchange.Redis; +using System.Diagnostics; namespace EllieBot.Modules.Patronage; @@ -12,7 +11,6 @@ namespace EllieBot.Modules.Patronage; public sealed class PatronageService : IPatronageService, IReadyExecutor, - IExecPreCommand, IEService { public event Func OnNewPatronPayment = static delegate { return Task.CompletedTask; }; @@ -60,7 +58,7 @@ public sealed class PatronageService if (_client.ShardId != 0) return Task.CompletedTask; - return Task.WhenAll(ResetLoopAsync(), LoadSubscribersLoopAsync()); + return Task.WhenAll(LoadSubscribersLoopAsync()); } private async Task LoadSubscribersLoopAsync() @@ -85,71 +83,6 @@ public sealed class PatronageService } } - public async Task ResetLoopAsync() - { - await Task.Delay(1.Minutes()); - while (true) - { - try - { - if (!_pConf.Data.IsEnabled) - { - await Task.Delay(1.Minutes()); - continue; - } - - var now = DateTime.UtcNow; - var lastRun = DateTime.MinValue; - - var result = await _cache.GetAsync(_quotaKey); - if (result.TryGetValue(out var lastVal) && lastVal != default) - { - lastRun = DateTime.FromBinary(lastVal); - } - - var nowDate = now.ToDateOnly(); - var lastDate = lastRun.ToDateOnly(); - - await using var ctx = _db.GetDbContext(); - - if ((lastDate.Day == 1 || (lastDate.Month != nowDate.Month)) && nowDate.Day > 1) - { - // assumes bot won't be offline for a year - await ctx.GetTable() - .TruncateAsync(); - } - else if (nowDate.DayNumber != lastDate.DayNumber) - { - // day is different, means hour is different. - // reset both hourly and daily quota counts. - await ctx.GetTable() - .UpdateAsync((old) => new() - { - HourlyCount = 0, - DailyCount = 0, - }); - } - else if (now.Hour != lastRun.Hour) // if it's not, just reset hourly quotas - { - await ctx.GetTable() - .UpdateAsync((old) => new() - { - HourlyCount = 0 - }); - } - - // assumes that the code above runs in less than an hour - await _cache.AddAsync(_quotaKey, now.ToBinary()); - } - catch (Exception ex) - { - Log.Error(ex, "Error in quota reset loop. Message: {ErrorMessage}", ex.Message); - } - - await Task.Delay(TimeSpan.FromHours(1).Add(TimeSpan.FromMinutes(1))); - } - } - private async Task ProcesssPatronsAsync(IReadOnlyCollection subscribersEnum) { // process only users who have discord accounts connected @@ -203,7 +136,8 @@ public sealed class PatronageService // if his sub would end in teh future, extend it by one month. // if it's not, just add 1 month to the last charge date var count = await ctx.GetTable() - .Where(x => x.UniquePlatformUserId == subscriber.UniquePlatformUserId) + .Where(x => x.UniquePlatformUserId + == subscriber.UniquePlatformUserId) .UpdateAsync(old => new() { UserId = subscriber.UserId, @@ -215,14 +149,13 @@ public sealed class PatronageService : dateInOneMonth, }); - // this should never happen - if (count == 0) - { - // await tran.RollbackAsync(); - continue; - } - // await tran.CommitAsync(); + dbPatron.UserId = subscriber.UserId; + dbPatron.AmountCents = subscriber.Cents; + dbPatron.LastCharge = lastChargeUtc; + dbPatron.ValidThru = dbPatron.ValidThru >= todayDate + ? dbPatron.ValidThru.AddMonths(1) + : dateInOneMonth; await OnNewPatronPayment(PatronUserToPatron(dbPatron)); } @@ -284,313 +217,7 @@ public sealed class PatronageService } } - public async Task ExecPreCommandAsync( - ICommandContext ctx, - string moduleName, - CommandInfo command) - { - var ownerId = ctx.Guild?.OwnerId ?? 0; - - var result = await AttemptRunCommand( - ctx.User.Id, - ownerId: ownerId, - command.Aliases.First().ToLowerInvariant(), - command.Module.Parent == null ? string.Empty : command.Module.GetGroupName().ToLowerInvariant(), - moduleName.ToLowerInvariant() - ); - - return result.Match( - _ => false, - ins => - { - var eb = _sender.CreateEmbed() - .WithPendingColor() - .WithTitle("Insufficient Patron Tier") - .AddField("For", $"{ins.FeatureType}: `{ins.Feature}`", true) - .AddField("Required Tier", - $"[{ins.RequiredTier.ToFullName()}](https://patreon.com/join/elliebot)", - true); - - if (ctx.Guild is null || ctx.Guild?.OwnerId == ctx.User.Id) - eb.WithDescription("You don't have the sufficent Patron Tier to run this command.") - .WithFooter("You can use '.patron' and '.donate' commands for more info"); - else - eb.WithDescription( - "Neither you nor the server owner have the sufficent Patron Tier to run this command.") - .WithFooter("You can use '.patron' and '.donate' commands for more info"); - - _ = ctx.WarningAsync(); - - if (ctx.Guild?.OwnerId == ctx.User.Id) - _ = _sender.Response(ctx) - .Context(ctx) - .Embed(eb) - .SendAsync(); - else - _ = _sender.Response(ctx).User(ctx.User).Embed(eb).SendAsync(); - - return true; - }, - quota => - { - var eb = _sender.CreateEmbed() - .WithPendingColor() - .WithTitle("Quota Limit Reached"); - - if (quota.IsOwnQuota || ctx.User.Id == ownerId) - { - eb.WithDescription($"You've reached your quota of `{quota.Quota} {quota.QuotaPeriod.ToFullName()}`") - .WithFooter("You may want to check your quota by using the '.patron' command."); - } - else - { - eb.WithDescription( - $"This server reached the quota of {quota.Quota} `{quota.QuotaPeriod.ToFullName()}`") - .WithFooter("You may contact the server owner about this issue.\n" - + "Alternatively, you can become patron yourself by using the '.donate' command.\n" - + "If you're already a patron, it means you've reached your quota.\n" - + "You can use '.patron' command to check your quota status."); - } - - eb.AddField("For", $"{quota.FeatureType}: `{quota.Feature}`", true) - .AddField("Resets At", quota.ResetsAt.ToShortAndRelativeTimestampTag(), true); - - _ = ctx.WarningAsync(); - - // send the message in the server in case it's the owner - if (ctx.Guild?.OwnerId == ctx.User.Id) - _ = _sender.Response(ctx) - .Embed(eb) - .SendAsync(); - else - _ = _sender.Response(ctx).User(ctx.User).Embed(eb).SendAsync(); - - return true; - }); - } - - private async ValueTask> AttemptRunCommand( - ulong userId, - ulong ownerId, - string commandName, - string groupName, - string moduleName) - { - // try to run as a user - var res = await AttemptRunCommand(userId, commandName, groupName, moduleName, true); - - // if it fails, try to run as an owner - // but only if the command is ran in a server - // and if the owner is not the user - if (!res.IsT0 && ownerId != 0 && ownerId != userId) - res = await AttemptRunCommand(ownerId, commandName, groupName, moduleName, false); - - return res; - } - - /// - /// Returns either the current usage counter if limit wasn't reached, or QuotaLimit if it is. - /// - public async ValueTask> TryIncrementQuotaCounterAsync( - ulong userId, - bool isSelf, - FeatureType featureType, - string featureName, - uint? maybeHourly, - uint? maybeDaily, - uint? maybeMonthly) - { - await using var ctx = _db.GetDbContext(); - - var now = DateTime.UtcNow; - await using var tran = await ctx.Database.BeginTransactionAsync(); - - var userQuotaData = await ctx.GetTable() - .FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId - && x.Feature == featureName) - ?? new PatronQuota(); - - // if hourly exists, if daily exists, etc... - if (maybeHourly is uint hourly && userQuotaData.HourlyCount >= hourly) - { - return new QuotaLimit() - { - QuotaPeriod = QuotaPer.PerHour, - Quota = hourly, - // quite a neat trick. https://stackoverflow.com/a/5733560 - ResetsAt = now.Date.AddHours(now.Hour + 1), - Feature = featureName, - FeatureType = featureType, - IsOwnQuota = isSelf - }; - } - - if (maybeDaily is uint daily - && userQuotaData.DailyCount >= daily) - { - return new QuotaLimit() - { - QuotaPeriod = QuotaPer.PerDay, - Quota = daily, - ResetsAt = now.Date.AddDays(1), - Feature = featureName, - FeatureType = featureType, - IsOwnQuota = isSelf - }; - } - - if (maybeMonthly is uint monthly && userQuotaData.MonthlyCount >= monthly) - { - return new QuotaLimit() - { - QuotaPeriod = QuotaPer.PerMonth, - Quota = monthly, - ResetsAt = now.Date.SecondOfNextMonth(), - Feature = featureName, - FeatureType = featureType, - IsOwnQuota = isSelf - }; - } - - await ctx.GetTable() - .InsertOrUpdateAsync(() => new() - { - UserId = userId, - FeatureType = featureType, - Feature = featureName, - DailyCount = 1, - MonthlyCount = 1, - HourlyCount = 1, - }, - (old) => new() - { - HourlyCount = old.HourlyCount + 1, - DailyCount = old.DailyCount + 1, - MonthlyCount = old.MonthlyCount + 1, - }, - () => new() - { - UserId = userId, - FeatureType = featureType, - Feature = featureName, - }); - - await tran.CommitAsync(); - - return (userQuotaData.HourlyCount + 1, userQuotaData.DailyCount + 1, userQuotaData.MonthlyCount + 1); - } - - /// - /// Attempts to add 1 to user's quota for the command, group and module. - /// Input MUST BE lowercase - /// - /// Id of the user who is attempting to run the command - /// Name of the command the user is trying to run - /// Name of the command's group - /// Name of the command's top level module - /// Whether this is check is for the user himself. False if it's someone else's id (owner) - /// Either a succcess (user can run the command) or one of the error values. - private async ValueTask> AttemptRunCommand( - ulong userId, - string commandName, - string groupName, - string moduleName, - bool isSelf) - { - var confData = _pConf.Data; - - if (!confData.IsEnabled) - return default; - - if (_creds.GetCreds().IsOwner(userId)) - return default; - - // get user tier - var patron = await GetPatronAsync(userId); - FeatureType quotaForFeatureType; - - if (confData.Quotas.Commands.TryGetValue(commandName, out var quotaData)) - { - quotaForFeatureType = FeatureType.Command; - } - else if (confData.Quotas.Groups.TryGetValue(groupName, out quotaData)) - { - quotaForFeatureType = FeatureType.Group; - } - else if (confData.Quotas.Modules.TryGetValue(moduleName, out quotaData)) - { - quotaForFeatureType = FeatureType.Module; - } - else - { - return default; - } - - var featureName = quotaForFeatureType switch - { - FeatureType.Command => commandName, - FeatureType.Group => groupName, - FeatureType.Module => moduleName, - _ => throw new ArgumentOutOfRangeException(nameof(quotaForFeatureType)) - }; - - if (!TryGetTierDataOrLower(quotaData, patron.Tier, out var data)) - { - return new InsufficientTier() - { - Feature = featureName, - FeatureType = quotaForFeatureType, - RequiredTier = quotaData.Count == 0 - ? PatronTier.ComingSoon - : quotaData.Keys.First(), - UserTier = patron.Tier, - }; - } - - // no quota limits for this tier - if (data is null) - return default; - - var quotaCheckResult = await TryIncrementQuotaCounterAsync(userId, - isSelf, - quotaForFeatureType, - featureName, - data.TryGetValue(QuotaPer.PerHour, out var hourly) ? hourly : null, - data.TryGetValue(QuotaPer.PerDay, out var daily) ? daily : null, - data.TryGetValue(QuotaPer.PerMonth, out var monthly) ? monthly : null - ); - - return quotaCheckResult.Match>( - _ => new Success(), - x => x); - } - - private bool TryGetTierDataOrLower( - IReadOnlyDictionary data, - PatronTier tier, - out T? o) - { - // check for quotas on this tier - if (data.TryGetValue(tier, out o)) - return true; - - // if there are none, get the quota first tier below this one - // which has quotas specified - for (var i = _tiers.Length - 1; i >= 0; i--) - { - var lowerTier = _tiers[i]; - if (lowerTier < tier && data.TryGetValue(lowerTier, out o)) - return true; - } - - // if there are none, that means the feature is intended - // to be patron-only but the quotas haven't been specified yet - // so it will be marked as "Coming Soon" - o = default; - return false; - } - - public async Task GetPatronAsync(ulong userId) + public async Task GetPatronAsync(ulong userId) { await using var ctx = _db.GetDbContext(); @@ -616,128 +243,135 @@ public sealed class PatronageService return PatronUserToPatron(max); } - public async Task GetUserQuotaStatistic(ulong userId) + public async Task LimitHitAsync(LimitedFeatureName key, ulong userId, int amount = 1) { - var pConfData = _pConf.Data; + if (_creds.GetCreds().IsOwner(userId)) + return true; - if (!pConfData.IsEnabled) - return new(); + if (!_pConf.Data.IsEnabled) + return true; - var patron = await GetPatronAsync(userId); + var userLimit = await GetUserLimit(key, userId); - await using var ctx = _db.GetDbContext(); - var allPatronQuotas = await ctx.GetTable() - .Where(x => x.UserId == userId) - .ToListAsync(); + if (userLimit.Quota == 0) + return false; - var allQuotasDict = allPatronQuotas - .GroupBy(static x => x.FeatureType) - .ToDictionary(static x => x.Key, static x => x.ToDictionary(static y => y.Feature)); + if (userLimit.Quota == -1) + return true; - allQuotasDict.TryGetValue(FeatureType.Command, out var data); - var userCommandQuotaStats = GetFeatureQuotaStats(patron.Tier, data, pConfData.Quotas.Commands); - - allQuotasDict.TryGetValue(FeatureType.Group, out data); - var userGroupQuotaStats = GetFeatureQuotaStats(patron.Tier, data, pConfData.Quotas.Groups); - - allQuotasDict.TryGetValue(FeatureType.Module, out data); - var userModuleQuotaStats = GetFeatureQuotaStats(patron.Tier, data, pConfData.Quotas.Modules); - - return new UserQuotaStats() - { - Tier = patron.Tier, - Commands = userCommandQuotaStats, - Groups = userGroupQuotaStats, - Modules = userModuleQuotaStats, - }; + return await TryAddLimit(key, userLimit, userId, amount); } - private IReadOnlyDictionary GetFeatureQuotaStats( - PatronTier patronTier, - IReadOnlyDictionary? allQuotasDict, - Dictionary?>> commands) + public async Task LimitForceHit(LimitedFeatureName key, ulong userId, int amount) { - var userCommandQuotaStats = new Dictionary(); - foreach (var (key, quotaData) in commands) + if (_creds.GetCreds().IsOwner(userId)) + return true; + + if (!_pConf.Data.IsEnabled) + return true; + + var userLimit = await GetUserLimit(key, userId); + + var cacheKey = CreateKey(key, userId); + await _cache.GetOrAddAsync(cacheKey, () => Task.FromResult(0), GetExpiry(userLimit)); + + return await TryAddLimit(key, userLimit, userId, amount); + } + + private async Task TryAddLimit( + LimitedFeatureName key, + QuotaLimit userLimit, + ulong userId, + int amount) + { + var cacheKey = CreateKey(key, userId); + var cur = await _cache.GetOrAddAsync(cacheKey, () => Task.FromResult(0), GetExpiry(userLimit)); + + if (cur + amount < userLimit.Quota) { - if (TryGetTierDataOrLower(quotaData, patronTier, out var data)) + await _cache.AddAsync(cacheKey, cur + amount); + return true; + } + + return false; + } + + private TimeSpan? GetExpiry(QuotaLimit userLimit) + { + var now = DateTime.UtcNow; + switch (userLimit.QuotaPeriod) + { + case QuotaPer.PerHour: + return TimeSpan.FromMinutes(60 - now.Minute); + case QuotaPer.PerDay: + return TimeSpan.FromMinutes((24 * 60) - ((now.Hour * 60) + now.Minute)); + case QuotaPer.PerMonth: + var firstOfNextMonth = now.FirstOfNextMonth(); + return firstOfNextMonth - now; + default: + return null; + } + } + + private TypedKey CreateKey(LimitedFeatureName key, ulong userId) + => new($"limited_feature:{key}:{userId}"); + + private readonly QuotaLimit _emptyQuota = new QuotaLimit() + { + Quota = 0, + QuotaPeriod = QuotaPer.PerDay, + }; + + private readonly QuotaLimit _infiniteQuota = new QuotaLimit() + { + Quota = -1, + QuotaPeriod = QuotaPer.PerDay, + }; + + public async Task GetUserLimit(LimitedFeatureName name, ulong userId) + { + if (!_pConf.Data.IsEnabled) + return _infiniteQuota; + + var maybePatron = await GetPatronAsync(userId); + + if (maybePatron is not { } patron) + return _emptyQuota; + + if (patron.ValidThru < DateTime.UtcNow) + return _emptyQuota; + + foreach (var (key, value) in _pConf.Data.Limits) + { + if (patron.Amount >= key) { - // if data is null that means the quota for the user's tier is unlimited - // no point in returning it? - - if (data is null) - continue; - - var (daily, hourly, monthly) = default((uint, uint, uint)); - // try to get users stats for this feature - // if it fails just leave them at 0 - if (allQuotasDict?.TryGetValue(key, out var quota) ?? false) - (daily, hourly, monthly) = (quota.DailyCount, quota.HourlyCount, quota.MonthlyCount); - - userCommandQuotaStats[key] = new FeatureQuotaStats() + if (value.TryGetValue(name, out var quotaLimit)) { - Hourly = data.TryGetValue(QuotaPer.PerHour, out var hourD) - ? (hourly, hourD) - : default, - Daily = data.TryGetValue(QuotaPer.PerDay, out var maxD) - ? (daily, maxD) - : default, - Monthly = data.TryGetValue(QuotaPer.PerMonth, out var maxM) - ? (monthly, maxM) - : default, - }; + return quotaLimit; + } + + break; } } - return userCommandQuotaStats; + return _emptyQuota; } - public async Task TryGetFeatureLimitAsync(FeatureLimitKey key, ulong userId, int? defaultValue) + public async Task> LimitStats(ulong userId) { - var conf = _pConf.Data; - - // if patron system is disabled, the quota is just default - if (!conf.IsEnabled) - return new() - { - Name = key.PrettyName, - Quota = defaultValue, - IsPatronLimit = false - }; - - - if (!conf.Quotas.Features.TryGetValue(key.Key, out var data)) - return new() - { - Name = key.PrettyName, - Quota = defaultValue, - IsPatronLimit = false, - }; - - var patron = await GetPatronAsync(userId); - if (!TryGetTierDataOrLower(data, patron.Tier, out var limit)) - return new() - { - Name = key.PrettyName, - Quota = 0, - IsPatronLimit = true, - }; - - return new() + var dict = new Dictionary(); + foreach (var featureName in Enum.GetValues()) { - Name = key.PrettyName, - Quota = limit, - IsPatronLimit = true - }; + var cacheKey = CreateKey(featureName, userId); + var userLimit = await GetUserLimit(featureName, userId); + var cur = await _cache.GetOrAddAsync(cacheKey, () => Task.FromResult(0), GetExpiry(userLimit)); + + dict[featureName] = (cur, userLimit); + } + + return dict; } - // public async Task GiftPatronAsync(IUser user, int amount) - // { - // if (amount < 1) - // throw new ArgumentOutOfRangeException(nameof(amount)); - // - // - // } private Patron PatronUserToPatron(PatronUser user) => new Patron() @@ -767,6 +401,22 @@ public sealed class PatronageService }; } + public int PercentBonus(Patron? maybePatron) + => maybePatron is { } user && user.ValidThru > DateTime.UtcNow + ? PercentBonus(user.Amount) + : 0; + + public int PercentBonus(long amount) + => amount switch + { + >= 10_000 => 100, + >= 5000 => 50, + >= 2000 => 20, + >= 1000 => 10, + >= 500 => 5, + _ => 0 + }; + private async Task SendWelcomeMessage(Patron patron) { try @@ -776,28 +426,28 @@ public sealed class PatronageService return; var eb = _sender.CreateEmbed() - .WithOkColor() - .WithTitle("❤️ Thank you for supporting EllieBot! ❤️") - .WithDescription( - "Your donation has been processed and you will receive the rewards shortly.\n" - + "You can visit to see rewards for your tier. 🎉") - .AddField("Tier", Format.Bold(patron.Tier.ToString()), true) - .AddField("Pledge", $"**{patron.Amount / 100.0f:N1}$**", true) - .AddField("Expires", - patron.ValidThru.AddDays(1).ToShortAndRelativeTimestampTag(), - true) - .AddField("Instructions", - """ - *- Within the next **1-2 minutes** you will have all of the benefits of the Tier you've subscribed to.* - *- You can check your benefits on * - *- You can use the `.patron` command in this chat to check your current quota usage for the Patron-only commands* - *- **ALL** of the servers that you **own** will enjoy your Patron benefits.* - *- You can use any of the commands available in your tier on any server (assuming you have sufficient permissions to run those commands)* - *- Any user in any of your servers can use Patron-only commands, but they will spend **your quota**, which is why it's recommended to use Ellie's command cooldown system (.h .cmdcd) or permission system to limit the command usage for your server members.* - *- Permission guide can be found here if you're not familiar with it: * - """, - inline: false) - .WithFooter($"platform id: {patron.UniquePlatformUserId}"); + .WithOkColor() + .WithTitle("❤️ Thank you for supporting EllieBot! ❤️") + .WithDescription( + "Your donation has been processed and you will receive the rewards shortly.\n" + + "You can visit to see rewards for your tier. 🎉") + .AddField("Tier", Format.Bold(patron.Tier.ToString()), true) + .AddField("Pledge", $"**{patron.Amount / 100.0f:N1}$**", true) + .AddField("Expires", + patron.ValidThru.AddDays(1).ToShortAndRelativeTimestampTag(), + true) + .AddField("Instructions", + """ + *- Within the next **1-2 minutes** you will have all of the benefits of the Tier you've subscribed to.* + *- You can check your benefits on * + *- You can use the `.patron` command in this chat to check your current quota usage for the Patron-only commands* + *- **ALL** of the servers that you **own** will enjoy your Patron benefits.* + *- You can use any of the commands available in your tier on any server (assuming you have sufficient permissions to run those commands)* + *- Any user in any of your servers can use Patron-only commands, but they will spend **your quota**, which is why it's recommended to use Ellie's command cooldown system (.h .cmdcd) or permission system to limit the command usage for your server members.* + *- Permission guide can be found here if you're not familiar with it: * + """, + inline: false) + .WithFooter($"platform id: {patron.UniquePlatformUserId}"); await _sender.Response(user).Embed(eb).SendAsync(); }