Updated Patronage module
This commit is contained in:
parent
a976df5549
commit
fbff028271
10 changed files with 230 additions and 604 deletions
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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; }
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -26,8 +26,3 @@ public sealed class PatreonMemberData : ISubscriberData
|
||||||
_ => SubscriptionChargeStatus.Other,
|
_ => SubscriptionChargeStatus.Other,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class PatreonPledgeData
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
|
@ -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,27 +96,10 @@ 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);
|
||||||
{
|
|
||||||
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))
|
if (!string.IsNullOrWhiteSpace(text))
|
||||||
eb.AddField(GetText(strs.modules), text, true);
|
eb.AddField(GetText(strs.modules), text, true);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
try
|
try
|
||||||
|
@ -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)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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 (TryGetTierDataOrLower(quotaData, patronTier, out var data))
|
|
||||||
{
|
|
||||||
// if data is null that means the quota for the user's tier is unlimited
|
|
||||||
// no point in returning it?
|
|
||||||
|
|
||||||
if (data is null)
|
if (!_pConf.Data.IsEnabled)
|
||||||
continue;
|
return true;
|
||||||
|
|
||||||
var (daily, hourly, monthly) = default((uint, uint, uint));
|
var userLimit = await GetUserLimit(key, userId);
|
||||||
// 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()
|
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)
|
||||||
{
|
{
|
||||||
Hourly = data.TryGetValue(QuotaPer.PerHour, out var hourD)
|
var cacheKey = CreateKey(key, userId);
|
||||||
? (hourly, hourD)
|
var cur = await _cache.GetOrAddAsync(cacheKey, () => Task.FromResult(0), GetExpiry(userLimit));
|
||||||
: default,
|
|
||||||
Daily = data.TryGetValue(QuotaPer.PerDay, out var maxD)
|
if (cur + amount < userLimit.Quota)
|
||||||
? (daily, maxD)
|
{
|
||||||
: default,
|
await _cache.AddAsync(cacheKey, cur + amount);
|
||||||
Monthly = data.TryGetValue(QuotaPer.PerMonth, out var maxM)
|
return true;
|
||||||
? (monthly, maxM)
|
}
|
||||||
: default,
|
|
||||||
};
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return userCommandQuotaStats;
|
private TypedKey<int> CreateKey(LimitedFeatureName key, ulong userId)
|
||||||
}
|
=> new($"limited_feature:{key}:{userId}");
|
||||||
|
|
||||||
public async Task<FeatureLimit> TryGetFeatureLimitAsync(FeatureLimitKey key, ulong userId, int? defaultValue)
|
private readonly QuotaLimit _emptyQuota = new QuotaLimit()
|
||||||
{
|
{
|
||||||
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,
|
Quota = 0,
|
||||||
IsPatronLimit = true,
|
QuotaPeriod = QuotaPer.PerDay,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new()
|
private readonly QuotaLimit _infiniteQuota = new QuotaLimit()
|
||||||
{
|
{
|
||||||
Name = key.PrettyName,
|
Quota = -1,
|
||||||
Quota = limit,
|
QuotaPeriod = QuotaPer.PerDay,
|
||||||
IsPatronLimit = true
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (value.TryGetValue(name, out var quotaLimit))
|
||||||
|
{
|
||||||
|
return quotaLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _emptyQuota;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<LimitedFeatureName, (int, QuotaLimit)>> LimitStats(ulong userId)
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<LimitedFeatureName, (int, QuotaLimit)>();
|
||||||
|
foreach (var featureName in Enum.GetValues<LimitedFeatureName>())
|
||||||
|
{
|
||||||
|
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<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
|
||||||
|
@ -794,7 +444,7 @@ public sealed class PatronageService
|
||||||
*- **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}");
|
||||||
|
|
Loading…
Reference in a new issue