Updated Patronage module

This commit is contained in:
Toastie (DCS Team) 2024-06-27 20:22:54 +12:00
parent a976df5549
commit fbff028271
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
10 changed files with 230 additions and 604 deletions

View file

@ -7,7 +7,8 @@ public class PatronageConfig : ConfigServiceBase<PatronConfigData>
public override string Name public override string Name
=> "patron"; => "patron";
private static readonly TypedKey<PatronConfigData> _changeKey; private static readonly TypedKey<PatronConfigData> _changeKey
= new("config.patron.updated");
private const string FILE_PATH = "data/patron.yml"; private const string FILE_PATH = "data/patron.yml";
@ -31,5 +32,14 @@ public class PatronageConfig : ConfigServiceBase<PatronConfigData>
c.IsEnabled = false; c.IsEnabled = false;
} }
}); });
ModifyConfig(c =>
{
if (c.Version == 2)
{
c.Version = 3;
}
});
} }
} }

View file

@ -1,6 +1,7 @@
#nullable disable #nullable disable
using LinqToDB; using LinqToDB;
using LinqToDB.EntityFrameworkCore; using LinqToDB.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Modules.Gambling.Services; using EllieBot.Modules.Gambling.Services;
using EllieBot.Modules.Patronage; using EllieBot.Modules.Patronage;
using EllieBot.Services.Currency; using EllieBot.Services.Currency;
@ -8,7 +9,7 @@ using EllieBot.Db.Models;
namespace EllieBot.Modules.Utility; namespace EllieBot.Modules.Utility;
public sealed class CurrencyRewardService : IEService, IDisposable public sealed class CurrencyRewardService : IEService, IReadyExecutor
{ {
private readonly ICurrencyService _cs; private readonly ICurrencyService _cs;
private readonly IPatronageService _ps; private readonly IPatronageService _ps;
@ -32,16 +33,14 @@ public sealed class CurrencyRewardService : IEService, IDisposable
_config = config; _config = config;
_client = client; _client = client;
}
public Task OnReadyAsync()
{
_ps.OnNewPatronPayment += OnNewPayment; _ps.OnNewPatronPayment += OnNewPayment;
_ps.OnPatronRefunded += OnPatronRefund; _ps.OnPatronRefunded += OnPatronRefund;
_ps.OnPatronUpdated += OnPatronUpdate; _ps.OnPatronUpdated += OnPatronUpdate;
} return Task.CompletedTask;
public void Dispose()
{
_ps.OnNewPatronPayment -= OnNewPayment;
_ps.OnPatronRefunded -= OnPatronRefund;
_ps.OnPatronUpdated -= OnPatronUpdate;
} }
private async Task OnPatronUpdate(Patron oldPatron, Patron newPatron) private async Task OnPatronUpdate(Patron oldPatron, Patron newPatron)
@ -104,7 +103,7 @@ public sealed class CurrencyRewardService : IEService, IDisposable
// if the user pledges 5$ or more, they will get X % more flowers where X is amount in dollars, // if the user pledges 5$ or more, they will get X % more flowers where X is amount in dollars,
// up to 100% // 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, _ = SendMessageToUser(newPatron.UserId,
$"You've received an additional **{diff}**{_config.Data.Currency.Sign} as a currency reward (+{percentBonus}%)!"); $"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 using var ctx = _db.GetDbContext();
await ctx.GetTable<RewardedUser>() await ctx.GetTable<RewardedUser>()
.InsertOrUpdateAsync(() => new() .InsertOrUpdateAsync(() => new()
{ {
PlatformUserId = patron.UniquePlatformUserId, PlatformUserId = patron.UniquePlatformUserId,
UserId = patron.UserId, UserId = patron.UserId,
AmountRewardedThisMonth = amount, AmountRewardedThisMonth = amount,
LastReward = patron.PaidAt, LastReward = patron.PaidAt,
}, },
old => new() old => new()
{ {
AmountRewardedThisMonth = amount, AmountRewardedThisMonth = amount,

View file

@ -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; }
}

View file

@ -140,7 +140,6 @@ public class PatreonClient : IDisposable
LastChargeDate = m.Attributes.LastChargeDate, LastChargeDate = m.Attributes.LastChargeDate,
LastChargeStatus = m.Attributes.LastChargeStatus LastChargeStatus = m.Attributes.LastChargeStatus
}) })
.Where(x => x.UserId == 140788173885276160)
.ToArray(); .ToArray();
yield return userData; yield return userData;

View file

@ -1,4 +1,4 @@
#nullable disable #nullable disable
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace EllieBot.Modules.Patronage; namespace EllieBot.Modules.Patronage;

View file

@ -26,8 +26,3 @@ public sealed class PatreonMemberData : ISubscriberData
_ => SubscriptionChargeStatus.Other, _ => SubscriptionChargeStatus.Other,
}; };
} }
public sealed class PatreonPledgeData
{
}

View file

@ -6,8 +6,8 @@ namespace EllieBot.Modules.Patronage;
/// </summary> /// </summary>
public sealed class PatreonSubscriptionHandler : ISubscriptionHandler, IEService public sealed class PatreonSubscriptionHandler : ISubscriptionHandler, IEService
{ {
private readonly IBotCredsProvider _credsProvider; private readonly IBotCredsProvider _credsProvider;
private readonly PatreonClient _patreonClient; private readonly PatreonClient _patreonClient;
public PatreonSubscriptionHandler(IBotCredsProvider credsProvider) public PatreonSubscriptionHandler(IBotCredsProvider credsProvider)
{ {

View file

@ -71,17 +71,16 @@ public partial class Help
return; return;
} }
var patron = await _service.GetPatronAsync(user.Id); var maybePatron = await _service.GetPatronAsync(user.Id);
var quotaStats = await _service.GetUserQuotaStatistic(user.Id);
var quotaStats = await _service.LimitStats(user.Id);
var eb = _sender.CreateEmbed() var eb = _sender.CreateEmbed()
.WithAuthor(user) .WithAuthor(user)
.WithTitle(GetText(strs.patron_info)) .WithTitle(GetText(strs.patron_info))
.WithOkColor(); .WithOkColor();
if (quotaStats.Commands.Count == 0 if (quotaStats.Count == 0 || maybePatron is not { } patron)
&& quotaStats.Groups.Count == 0
&& quotaStats.Modules.Count == 0)
{ {
eb.WithDescription(GetText(strs.no_quota_found)); eb.WithDescription(GetText(strs.no_quota_found));
} }
@ -97,26 +96,9 @@ public partial class Help
eb.AddField(GetText(strs.quotas), "", false); eb.AddField(GetText(strs.quotas), "", false);
if (quotaStats.Commands.Count > 0) var text = GetQuotaList(quotaStats);
{ if (!string.IsNullOrWhiteSpace(text))
var text = GetQuotaList(quotaStats.Commands); eb.AddField(GetText(strs.modules), text, true);
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);
}
} }
@ -131,26 +113,28 @@ public partial class Help
} }
} }
private string GetQuotaList(IReadOnlyDictionary<string, FeatureQuotaStats> featureQuotaStats) private string GetQuotaList(
IReadOnlyDictionary<LimitedFeatureName, (int Cur, QuotaLimit Quota)> featureQuotaStats)
{ {
var text = string.Empty; var text = string.Empty;
foreach (var (key, q) in featureQuotaStats) foreach (var (key, (cur, quota)) in featureQuotaStats)
{ {
text += $"\n\t`{key}`\n"; text += $"\n\t`{key}`\n";
if (q.Hourly != default) if (quota.QuotaPeriod == QuotaPer.PerHour)
text += $" {GetEmoji(q.Hourly)} {q.Hourly.Cur}/{q.Hourly.Max} per hour\n"; text += $" {cur}/{(quota.Quota == -1 ? "" : quota.Quota)} {QuotaPeriodToString(quota.QuotaPeriod)}\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";
} }
return text; return text;
} }
private string GetEmoji((uint Cur, uint Max) limit) public string QuotaPeriodToString(QuotaPer per)
=> limit.Cur < limit.Max => per switch
? "✅" {
: "⚠️"; QuotaPer.PerHour => "per hour",
QuotaPer.PerDay => "per day",
QuotaPer.PerMonth => "per month",
QuotaPer.Total => "total",
_ => throw new ArgumentOutOfRangeException(nameof(per), per, null)
};
} }
} }

View file

@ -2,9 +2,8 @@
using LinqToDB.EntityFrameworkCore; using LinqToDB.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors; using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models; using EllieBot.Db.Models;
using OneOf; using StackExchange.Redis;
using OneOf.Types; using System.Diagnostics;
using CommandInfo = Discord.Commands.CommandInfo;
namespace EllieBot.Modules.Patronage; namespace EllieBot.Modules.Patronage;
@ -12,7 +11,6 @@ namespace EllieBot.Modules.Patronage;
public sealed class PatronageService public sealed class PatronageService
: IPatronageService, : IPatronageService,
IReadyExecutor, IReadyExecutor,
IExecPreCommand,
IEService IEService
{ {
public event Func<Patron, Task> OnNewPatronPayment = static delegate { return Task.CompletedTask; }; public event Func<Patron, Task> OnNewPatronPayment = static delegate { return Task.CompletedTask; };
@ -60,7 +58,7 @@ public sealed class PatronageService
if (_client.ShardId != 0) if (_client.ShardId != 0)
return Task.CompletedTask; return Task.CompletedTask;
return Task.WhenAll(ResetLoopAsync(), LoadSubscribersLoopAsync()); return Task.WhenAll(LoadSubscribersLoopAsync());
} }
private async Task 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<PatronQuota>()
.TruncateAsync();
}
else if (nowDate.DayNumber != lastDate.DayNumber)
{
// day is different, means hour is different.
// reset both hourly and daily quota counts.
await ctx.GetTable<PatronQuota>()
.UpdateAsync((old) => new()
{
HourlyCount = 0,
DailyCount = 0,
});
}
else if (now.Hour != lastRun.Hour) // if it's not, just reset hourly quotas
{
await ctx.GetTable<PatronQuota>()
.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<ISubscriberData> subscribersEnum) private async Task ProcesssPatronsAsync(IReadOnlyCollection<ISubscriberData> subscribersEnum)
{ {
// process only users who have discord accounts connected // 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 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 // if it's not, just add 1 month to the last charge date
var count = await ctx.GetTable<PatronUser>() var count = await ctx.GetTable<PatronUser>()
.Where(x => x.UniquePlatformUserId == subscriber.UniquePlatformUserId) .Where(x => x.UniquePlatformUserId
== subscriber.UniquePlatformUserId)
.UpdateAsync(old => new() .UpdateAsync(old => new()
{ {
UserId = subscriber.UserId, UserId = subscriber.UserId,
@ -215,14 +149,13 @@ public sealed class PatronageService
: dateInOneMonth, : 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)); await OnNewPatronPayment(PatronUserToPatron(dbPatron));
} }
@ -284,313 +217,7 @@ public sealed class PatronageService
} }
} }
public async Task<bool> ExecPreCommandAsync( public async Task<Patron?> GetPatronAsync(ulong userId)
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<OneOf<OneOf.Types.Success, InsufficientTier, QuotaLimit>> 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;
}
/// <summary>
/// Returns either the current usage counter if limit wasn't reached, or QuotaLimit if it is.
/// </summary>
public async ValueTask<OneOf<(uint Hourly, uint Daily, uint Monthly), QuotaLimit>> 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<PatronQuota>()
.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<PatronQuota>()
.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);
}
/// <summary>
/// Attempts to add 1 to user's quota for the command, group and module.
/// Input MUST BE lowercase
/// </summary>
/// <param name="userId">Id of the user who is attempting to run the command</param>
/// <param name="commandName">Name of the command the user is trying to run</param>
/// <param name="groupName">Name of the command's group</param>
/// <param name="moduleName">Name of the command's top level module</param>
/// <param name="isSelf">Whether this is check is for the user himself. False if it's someone else's id (owner)</param>
/// <returns>Either a succcess (user can run the command) or one of the error values.</returns>
private async ValueTask<OneOf<OneOf.Types.Success, InsufficientTier, QuotaLimit>> 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<OneOf<Success, InsufficientTier, QuotaLimit>>(
_ => new Success(),
x => x);
}
private bool TryGetTierDataOrLower<T>(
IReadOnlyDictionary<PatronTier, T?> 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<Patron> GetPatronAsync(ulong userId)
{ {
await using var ctx = _db.GetDbContext(); await using var ctx = _db.GetDbContext();
@ -616,128 +243,135 @@ public sealed class PatronageService
return PatronUserToPatron(max); return PatronUserToPatron(max);
} }
public async Task<UserQuotaStats> GetUserQuotaStatistic(ulong userId) public async Task<bool> LimitHitAsync(LimitedFeatureName key, ulong userId, int amount = 1)
{ {
var pConfData = _pConf.Data; if (_creds.GetCreds().IsOwner(userId))
return true;
if (!pConfData.IsEnabled) if (!_pConf.Data.IsEnabled)
return new(); return true;
var patron = await GetPatronAsync(userId); var userLimit = await GetUserLimit(key, userId);
await using var ctx = _db.GetDbContext(); if (userLimit.Quota == 0)
var allPatronQuotas = await ctx.GetTable<PatronQuota>() return false;
.Where(x => x.UserId == userId)
.ToListAsync();
var allQuotasDict = allPatronQuotas if (userLimit.Quota == -1)
.GroupBy(static x => x.FeatureType) return true;
.ToDictionary(static x => x.Key, static x => x.ToDictionary(static y => y.Feature));
allQuotasDict.TryGetValue(FeatureType.Command, out var data); return await TryAddLimit(key, userLimit, userId, amount);
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,
};
} }
private IReadOnlyDictionary<string, FeatureQuotaStats> GetFeatureQuotaStats( public async Task<bool> LimitForceHit(LimitedFeatureName key, ulong userId, int amount)
PatronTier patronTier,
IReadOnlyDictionary<string, PatronQuota>? allQuotasDict,
Dictionary<string, Dictionary<PatronTier, Dictionary<QuotaPer, uint>?>> commands)
{ {
var userCommandQuotaStats = new Dictionary<string, FeatureQuotaStats>(); if (_creds.GetCreds().IsOwner(userId))
foreach (var (key, quotaData) in commands) 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<bool> 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<int> 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<QuotaLimit> 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 if (value.TryGetValue(name, out var quotaLimit))
// 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()
{ {
Hourly = data.TryGetValue(QuotaPer.PerHour, out var hourD) return quotaLimit;
? (hourly, hourD) }
: default,
Daily = data.TryGetValue(QuotaPer.PerDay, out var maxD) break;
? (daily, maxD)
: default,
Monthly = data.TryGetValue(QuotaPer.PerMonth, out var maxM)
? (monthly, maxM)
: default,
};
} }
} }
return userCommandQuotaStats; return _emptyQuota;
} }
public async Task<FeatureLimit> TryGetFeatureLimitAsync(FeatureLimitKey key, ulong userId, int? defaultValue) public async Task<Dictionary<LimitedFeatureName, (int, QuotaLimit)>> LimitStats(ulong userId)
{ {
var conf = _pConf.Data; var dict = new Dictionary<LimitedFeatureName, (int, QuotaLimit)>();
foreach (var featureName in Enum.GetValues<LimitedFeatureName>())
// 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()
{ {
Name = key.PrettyName, var cacheKey = CreateKey(featureName, userId);
Quota = limit, var userLimit = await GetUserLimit(featureName, userId);
IsPatronLimit = true var cur = await _cache.GetOrAddAsync(cacheKey, () => Task.FromResult(0), GetExpiry(userLimit));
};
dict[featureName] = (cur, userLimit);
}
return dict;
} }
// public async Task<Patron> GiftPatronAsync(IUser user, int amount)
// {
// if (amount < 1)
// throw new ArgumentOutOfRangeException(nameof(amount));
//
//
// }
private Patron PatronUserToPatron(PatronUser user) private Patron PatronUserToPatron(PatronUser user)
=> new Patron() => 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) private async Task SendWelcomeMessage(Patron patron)
{ {
try try
@ -776,28 +426,28 @@ public sealed class PatronageService
return; return;
var eb = _sender.CreateEmbed() var eb = _sender.CreateEmbed()
.WithOkColor() .WithOkColor()
.WithTitle("❤️ Thank you for supporting EllieBot! ❤️") .WithTitle("❤️ Thank you for supporting EllieBot! ❤️")
.WithDescription( .WithDescription(
"Your donation has been processed and you will receive the rewards shortly.\n" "Your donation has been processed and you will receive the rewards shortly.\n"
+ "You can visit <https://www.patreon.com/join/elliebot> to see rewards for your tier. 🎉") + "You can visit <https://www.patreon.com/join/elliebot> to see rewards for your tier. 🎉")
.AddField("Tier", Format.Bold(patron.Tier.ToString()), true) .AddField("Tier", Format.Bold(patron.Tier.ToString()), true)
.AddField("Pledge", $"**{patron.Amount / 100.0f:N1}$**", true) .AddField("Pledge", $"**{patron.Amount / 100.0f:N1}$**", true)
.AddField("Expires", .AddField("Expires",
patron.ValidThru.AddDays(1).ToShortAndRelativeTimestampTag(), patron.ValidThru.AddDays(1).ToShortAndRelativeTimestampTag(),
true) true)
.AddField("Instructions", .AddField("Instructions",
""" """
*- Within the next **1-2 minutes** you will have all of the benefits of the Tier you've subscribed to.* *- 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 <https://www.patreon.com/join/elliebot>* *- You can check your benefits on <https://www.patreon.com/join/elliebot>*
*- You can use the `.patron` command in this chat to check your current quota usage for the Patron-only commands* *- 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.* *- **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)* *- 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.* *- 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: <https://docs.elliebot.net/ellie/placeholders/>* *- Permission guide can be found here if you're not familiar with it: <https://docs.elliebot.net/ellie/features/permissions-system/>*
""", """,
inline: false) inline: false)
.WithFooter($"platform id: {patron.UniquePlatformUserId}"); .WithFooter($"platform id: {patron.UniquePlatformUserId}");
await _sender.Response(user).Embed(eb).SendAsync(); await _sender.Response(user).Embed(eb).SendAsync();
} }