using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
using OneOf;
using OneOf.Types;
using CommandInfo = Discord.Commands.CommandInfo;
namespace EllieBot.Modules.Patronage;
///
public sealed class PatronageService
: IPatronageService,
IReadyExecutor,
IExecPreCommand,
IEService
{
public event Func OnNewPatronPayment = static delegate { return Task.CompletedTask; };
public event Func OnPatronUpdated = static delegate { return Task.CompletedTask; };
public event Func OnPatronRefunded = static delegate { return Task.CompletedTask; };
// this has to run right before the command
public int Priority
=> int.MinValue;
private static readonly PatronTier[] _tiers = Enum.GetValues();
private readonly PatronageConfig _pConf;
private readonly DbService _db;
private readonly DiscordSocketClient _client;
private readonly ISubscriptionHandler _subsHandler;
private static readonly TypedKey _quotaKey
= new($"quota:last_hourly_reset");
private readonly IBotCache _cache;
private readonly IBotCredsProvider _creds;
private readonly IMessageSenderService _sender;
public PatronageService(
PatronageConfig pConf,
DbService db,
DiscordSocketClient client,
ISubscriptionHandler subsHandler,
IBotCache cache,
IBotCredsProvider creds,
IMessageSenderService sender)
{
_pConf = pConf;
_db = db;
_client = client;
_subsHandler = subsHandler;
_sender = sender;
_cache = cache;
_creds = creds;
}
public Task OnReadyAsync()
{
if (_client.ShardId != 0)
return Task.CompletedTask;
return Task.WhenAll(ResetLoopAsync(), LoadSubscribersLoopAsync());
}
private async Task LoadSubscribersLoopAsync()
{
var timer = new PeriodicTimer(TimeSpan.FromSeconds(60));
while (await timer.WaitForNextTickAsync())
{
try
{
if (!_pConf.Data.IsEnabled)
continue;
await foreach (var batch in _subsHandler.GetPatronsAsync())
{
await ProcesssPatronsAsync(batch);
}
}
catch (Exception ex)
{
Log.Error(ex, "Error processing patrons");
}
}
}
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
var subscribers = subscribersEnum.Where(x => x.UserId != 0).ToArray();
if (subscribers.Length == 0)
return;
var todayDate = DateTime.UtcNow.Date;
await using var ctx = _db.GetDbContext();
// handle paid users
foreach (var subscriber in subscribers.Where(x => x.ChargeStatus == SubscriptionChargeStatus.Paid))
{
if (subscriber.LastCharge is null)
continue;
var lastChargeUtc = subscriber.LastCharge.Value.ToUniversalTime();
var dateInOneMonth = lastChargeUtc.Date.AddMonths(1);
try
{
var dbPatron = await ctx.GetTable()
.FirstOrDefaultAsync(x
=> x.UniquePlatformUserId == subscriber.UniquePlatformUserId);
if (dbPatron is null)
{
// if the user is not in the database alrady
dbPatron = await ctx.GetTable()
.InsertWithOutputAsync(() => new()
{
UniquePlatformUserId = subscriber.UniquePlatformUserId,
UserId = subscriber.UserId,
AmountCents = subscriber.Cents,
LastCharge = lastChargeUtc,
ValidThru = dateInOneMonth,
});
// await tran.CommitAsync();
var newPatron = PatronUserToPatron(dbPatron);
_ = SendWelcomeMessage(newPatron);
await OnNewPatronPayment(newPatron);
}
else
{
if (dbPatron.LastCharge.Month < lastChargeUtc.Month
|| dbPatron.LastCharge.Year < lastChargeUtc.Year)
{
// user is charged again for this 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
var count = await ctx.GetTable()
.Where(x => x.UniquePlatformUserId == subscriber.UniquePlatformUserId)
.UpdateAsync(old => new()
{
UserId = subscriber.UserId,
AmountCents = subscriber.Cents,
LastCharge = lastChargeUtc,
ValidThru = old.ValidThru >= todayDate
// ? Sql.DateAdd(Sql.DateParts.Month, 1, old.ValidThru).Value
? old.ValidThru.AddMonths(1)
: dateInOneMonth,
});
// this should never happen
if (count == 0)
{
// await tran.RollbackAsync();
continue;
}
// await tran.CommitAsync();
await OnNewPatronPayment(PatronUserToPatron(dbPatron));
}
else if (dbPatron.AmountCents != subscriber.Cents // if user changed the amount
|| dbPatron.UserId != subscriber.UserId) // if user updated user id)
{
var cents = subscriber.Cents;
// the user updated the pledge or changed the connected discord account
await ctx.GetTable()
.Where(x => x.UniquePlatformUserId == subscriber.UniquePlatformUserId)
.UpdateAsync(old => new()
{
UserId = subscriber.UserId,
AmountCents = cents,
LastCharge = lastChargeUtc,
ValidThru = old.ValidThru,
});
var newPatron = dbPatron.Clone();
newPatron.AmountCents = cents;
newPatron.UserId = subscriber.UserId;
// idk what's going on but UpdateWithOutputAsync doesn't work properly here
// nor does firstordefault after update. I'm not seeing something obvious
await OnPatronUpdated(
PatronUserToPatron(dbPatron),
PatronUserToPatron(newPatron));
}
}
}
catch (Exception ex)
{
Log.Error(ex,
"Unexpected error occured while processing rewards for patron {UserId}",
subscriber.UserId);
}
}
var expiredDate = DateTime.MinValue;
foreach (var patron in subscribers.Where(x => x.ChargeStatus == SubscriptionChargeStatus.Refunded))
{
// if the subscription is refunded, Disable user's valid thru
var changedCount = await ctx.GetTable()
.Where(x => x.UniquePlatformUserId == patron.UniquePlatformUserId
&& x.ValidThru != expiredDate)
.UpdateAsync(old => new()
{
ValidThru = expiredDate
});
if (changedCount == 0)
continue;
var updated = await ctx.GetTable()
.Where(x => x.UniquePlatformUserId == patron.UniquePlatformUserId)
.FirstAsync();
await OnPatronRefunded(PatronUserToPatron(updated));
}
}
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)
{
await using var ctx = _db.GetDbContext();
// this can potentially return multiple users if the user
// is subscribed on multiple platforms
// or if there are multiple users on the same platform who connected the same discord account?!
var users = await ctx.GetTable()
.Where(x => x.UserId == userId)
.ToListAsync();
// first find all active subscriptions
// and return the one with the highest amount
var maxActive = users.Where(x => !x.ValidThru.IsBeforeToday()).MaxBy(x => x.AmountCents);
if (maxActive is not null)
return PatronUserToPatron(maxActive);
// if there are no active subs, return the one with the highest amount
var max = users.MaxBy(x => x.AmountCents);
if (max is null)
return default; // no patron with that name
return PatronUserToPatron(max);
}
public async Task GetUserQuotaStatistic(ulong userId)
{
var pConfData = _pConf.Data;
if (!pConfData.IsEnabled)
return new();
var patron = await GetPatronAsync(userId);
await using var ctx = _db.GetDbContext();
var allPatronQuotas = await ctx.GetTable()
.Where(x => x.UserId == userId)
.ToListAsync();
var allQuotasDict = allPatronQuotas
.GroupBy(static x => x.FeatureType)
.ToDictionary(static x => x.Key, static x => x.ToDictionary(static y => y.Feature));
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,
};
}
private IReadOnlyDictionary GetFeatureQuotaStats(
PatronTier patronTier,
IReadOnlyDictionary? allQuotasDict,
Dictionary?>> commands)
{
var userCommandQuotaStats = new Dictionary();
foreach (var (key, quotaData) in commands)
{
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)
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)
? (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 userCommandQuotaStats;
}
public async Task TryGetFeatureLimitAsync(FeatureLimitKey key, ulong userId, int? defaultValue)
{
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()
{
Name = key.PrettyName,
Quota = limit,
IsPatronLimit = true
};
}
// public async Task GiftPatronAsync(IUser user, int amount)
// {
// if (amount < 1)
// throw new ArgumentOutOfRangeException(nameof(amount));
//
//
// }
private Patron PatronUserToPatron(PatronUser user)
=> new Patron()
{
UniquePlatformUserId = user.UniquePlatformUserId,
UserId = user.UserId,
Amount = user.AmountCents,
Tier = CalculateTier(user),
PaidAt = user.LastCharge,
ValidThru = user.ValidThru,
};
private PatronTier CalculateTier(PatronUser user)
{
if (user.ValidThru.IsBeforeToday())
return PatronTier.None;
return user.AmountCents switch
{
>= 10_000 => PatronTier.C,
>= 5000 => PatronTier.L,
>= 2000 => PatronTier.XX,
>= 1000 => PatronTier.X,
>= 500 => PatronTier.V,
>= 100 => PatronTier.I,
_ => PatronTier.None
};
}
private async Task SendWelcomeMessage(Patron patron)
{
try
{
var user = (IUser)_client.GetUser(patron.UserId) ?? await _client.Rest.GetUserAsync(patron.UserId);
if (user is null)
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}");
await _sender.Response(user).Embed(eb).SendAsync();
}
catch
{
Log.Warning("Unable to send a \"Welcome\" message to the patron {UserId}", patron.UserId);
}
}
public async Task<(int Success, int Failed)> SendMessageToPatronsAsync(PatronTier tierAndHigher, string message)
{
await using var ctx = _db.GetDbContext();
var patrons = await ctx.GetTable()
.Where(x => x.ValidThru > DateTime.UtcNow)
.ToArrayAsync();
var text = SmartText.CreateFrom(message);
var succ = 0;
var fail = 0;
foreach (var patron in patrons)
{
try
{
var user = await _client.GetUserAsync(patron.UserId);
await _sender.Response(user).Text(text).SendAsync();
++succ;
}
catch
{
++fail;
}
await Task.Delay(1000);
}
return (succ, fail);
}
public PatronConfigData GetConfig()
=> _pConf.Data;
}