diff --git a/src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs b/src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs new file mode 100644 index 0000000..254c3db --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs @@ -0,0 +1,45 @@ +using EllieBot.Common.Configs; + +namespace EllieBot.Modules.Patronage; + +public class PatronageConfig : ConfigServiceBase +{ + public override string Name + => "patron"; + + private static readonly TypedKey _changeKey + = new("config.patron.updated"); + + private const string FILE_PATH = "data/patron.yml"; + + public PatronageConfig(IConfigSeria serializer, IPubSub pubSub) : base(FILE_PATH, serializer, pubSub, _changeKey) + { + AddParsedProp("enabled", + x => x.IsEnabled, + bool.TryParse, + ConfigPrinters.ToString); + + Migrate(); + } + + private void Migrate() + { + ModifyConfig(c => + { + if (c.Version == 1) + { + c.Version = 2; + 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 new file mode 100644 index 0000000..b61c646 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/CurrencyRewardService.cs @@ -0,0 +1,194 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Modules.Gambling.Services; +using EllieBot.Modules.Patronage; +using EllieBot.Services.Currency; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility; + +public sealed class CurrencyRewardService : IEService, IReadyExecutor +{ + private readonly ICurrencyService _cs; + private readonly IPatronageService _ps; + private readonly DbService _db; + private readonly IMessageSenderService _sender; + private readonly GamblingConfigService _config; + private readonly DiscordSocketClient _client; + + public CurrencyRewardService( + ICurrencyService cs, + IPatronageService ps, + DbService db, + IMessageSenderService sender, + GamblingConfigService config, + DiscordSocketClient client) + { + _cs = cs; + _ps = ps; + _db = db; + _sender = sender; + _config = config; + _client = client; + + } + + public Task OnReadyAsync() + { + _ps.OnNewPatronPayment += OnNewPayment; + _ps.OnPatronRefunded += OnPatronRefund; + _ps.OnPatronUpdated += OnPatronUpdate; + return Task.CompletedTask; + } + + private async Task OnPatronUpdate(Patron oldPatron, Patron newPatron) + { + // if pledge was increased + if (oldPatron.Amount < newPatron.Amount) + { + var conf = _config.Data; + var newAmount = (long)(newPatron.Amount * conf.PatreonCurrencyPerCent); + + RewardedUser old; + await using (var ctx = _db.GetDbContext()) + { + 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() + { + PlatformUserId = newPatron.UniquePlatformUserId, + UserId = newPatron.UserId, + // amount before bonuses + AmountRewardedThisMonth = newAmount, + LastReward = newPatron.PaidAt + }); + + // shouldn't ever happen + if (count == 0) + return; + } + + var oldAmount = old.AmountRewardedThisMonth; + + var realNewAmount = GetRealCurrencyReward( + (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")); + + _ = SendMessageToUser(newPatron.UserId, + $"You've received an additional **{diff}**{_config.Data.Currency.Sign} as a currency reward (+{percentBonus}%)!"); + } + } + + private long GetRealCurrencyReward(int pledgeCents, long modifiedAmount, out int percentBonus) + { + // needs at least 5$ to be eligible for a bonus + if (pledgeCents < 500) + { + percentBonus = 0; + return modifiedAmount; + } + + var dollarValue = pledgeCents / 100; + percentBonus = dollarValue switch + { + >= 100 => 100, + >= 50 => 50, + >= 20 => 20, + >= 10 => 10, + >= 5 => 5, + _ => 0 + }; + return (long)(modifiedAmount * (1 + (percentBonus / 100.0f))); + } + + // on a new payment, always give the full amount. + private async Task OnNewPayment(Patron patron) + { + var amount = (long)(patron.Amount * _config.Data.PatreonCurrencyPerCent); + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + PlatformUserId = patron.UniquePlatformUserId, + UserId = patron.UserId, + AmountRewardedThisMonth = amount, + LastReward = patron.PaidAt, + }, + old => new() + { + AmountRewardedThisMonth = amount, + UserId = patron.UserId, + LastReward = patron.PaidAt + }, + () => new() + { + PlatformUserId = patron.UniquePlatformUserId + }); + + var realAmount = GetRealCurrencyReward(patron.Amount, amount, out var percentBonus); + await _cs.AddAsync(patron.UserId, realAmount, new("patron", "new")); + _ = SendMessageToUser(patron.UserId, + $"You've received **{realAmount}**{_config.Data.Currency.Sign} as a currency reward (**+{percentBonus}%**)!"); + } + + private async Task SendMessageToUser(ulong userId, string message) + { + try + { + var user = (IUser)_client.GetUser(userId) ?? await _client.Rest.GetUserAsync(userId); + if (user is null) + return; + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithDescription(message); + + await _sender.Response(user).Embed(eb).SendAsync(); + } + catch + { + Log.Warning("Unable to send a \"Currency Reward\" message to the patron {UserId}", userId); + } + } + + private async Task OnPatronRefund(Patron patron) + { + await using var ctx = _db.GetDbContext(); + _ = await ctx.GetTable() + .UpdateAsync(old => new() + { + AmountRewardedThisMonth = old.AmountRewardedThisMonth * 2 + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs new file mode 100644 index 0000000..2ac5820 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs @@ -0,0 +1,149 @@ +#nullable disable +using OneOf; +using OneOf.Types; +using System.Net.Http.Json; +using System.Text.Json; + +namespace EllieBot.Modules.Patronage; + +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) + { + _clientId = clientId; + _clientSecret = clientSecret; + this.refreshToken = refreshToken; + + _http = new(); + } + + public void Dispose() + => _http.Dispose(); + + public PatreonCredentials GetCredentials() + => new PatreonCredentials() + { + AccessToken = accessToken, + ClientId = _clientId, + ClientSecret = _clientSecret, + RefreshToken = refreshToken, + }; + + public async Task>> RefreshTokenAsync(bool force) + { + if (!force && IsTokenValid()) + return new Success(); + + var res = await _http.PostAsync("https://www.patreon.com/api/oauth2/token" + + "?grant_type=refresh_token" + + $"&refresh_token={refreshToken}" + + $"&client_id={_clientId}" + + $"&client_secret={_clientSecret}", + null); + + if (!res.IsSuccessStatusCode) + return new Error($"Request did not return a sucess status code. Status code: {res.StatusCode}"); + + try + { + var data = await res.Content.ReadFromJsonAsync(); + + if (data is null) + return new Error($"Invalid data retrieved from Patreon."); + + refreshToken = data.RefreshToken; + accessToken = data.AccessToken; + + refreshAt = DateTime.UtcNow.AddSeconds(data.ExpiresIn - 5.Minutes().TotalSeconds); + return new Success(); + } + catch (Exception ex) + { + return new Error($"Error during deserialization: {ex.Message}"); + } + } + + private async ValueTask EnsureTokenValidAsync() + { + if (!IsTokenValid()) + { + var res = await RefreshTokenAsync(true); + return res.Match( + static _ => true, + static err => + { + Log.Warning("Error getting token: {ErrorMessage}", err.Value); + return false; + }); + } + + return true; + } + + private bool IsTokenValid() + => refreshAt > DateTime.UtcNow && !string.IsNullOrWhiteSpace(accessToken); + + public async Task>, Error>> GetMembersAsync(string campaignId) + { + if (!await EnsureTokenValidAsync()) + return new Error("Unable to get patreon token"); + + return OneOf>, Error>.FromT0( + GetMembersInternalAsync(campaignId)); + } + + private async IAsyncEnumerable> GetMembersInternalAsync(string campaignId) + { + _http.DefaultRequestHeaders.Clear(); + _http.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", + $"Bearer {accessToken}"); + + var page = + $"https://www.patreon.com/api/oauth2/v2/campaigns/{campaignId}/members" + + $"?fields%5Bmember%5D=full_name,currently_entitled_amount_cents,last_charge_date,last_charge_status" + + $"&fields%5Buser%5D=social_connections" + + $"&include=user" + + $"&sort=-last_charge_date"; + PatreonMembersResponse data; + + do + { + var res = await _http.GetStreamAsync(page); + data = await JsonSerializer.DeserializeAsync(res); + + if (data is null) + break; + + var userData = data.Data + .Join(data.Included, + static m => m.Relationships.User.Data.Id, + static u => u.Id, + static (m, u) => new PatreonMemberData() + { + PatreonUserId = m.Relationships.User.Data.Id, + UserId = ulong.TryParse( + u.Attributes?.SocialConnections?.Discord?.UserId ?? string.Empty, + out var userId) + ? userId + : 0, + EntitledToCents = m.Attributes.CurrentlyEntitledAmountCents, + LastChargeDate = m.Attributes.LastChargeDate, + LastChargeStatus = m.Attributes.LastChargeStatus + }) + .ToArray(); + + yield return userData; + + } while (!string.IsNullOrWhiteSpace(page = data.Links?.Next)); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs new file mode 100644 index 0000000..768a1f6 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Modules.Patronage; + +public readonly struct PatreonCredentials +{ + public string ClientId { get; init; } + 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 new file mode 100644 index 0000000..6b33a80 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonData.cs @@ -0,0 +1,134 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Patronage; + +public sealed class Attributes +{ + [JsonPropertyName("full_name")] + public string FullName { get; set; } + + [JsonPropertyName("is_follower")] + public bool IsFollower { get; set; } + + [JsonPropertyName("last_charge_date")] + public DateTime? LastChargeDate { get; set; } + + [JsonPropertyName("last_charge_status")] + public string LastChargeStatus { get; set; } + + [JsonPropertyName("lifetime_support_cents")] + public int LifetimeSupportCents { get; set; } + + [JsonPropertyName("currently_entitled_amount_cents")] + public int CurrentlyEntitledAmountCents { get; set; } + + [JsonPropertyName("patron_status")] + public string PatronStatus { get; set; } +} + +public sealed class Data +{ + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } +} + +public sealed class Address +{ + [JsonPropertyName("data")] + public Data Data { get; set; } +} + +// public sealed class CurrentlyEntitledTiers +// { +// [JsonPropertyName("data")] +// public List Data { get; set; } +// } + +// public sealed class Relationships +// { +// [JsonPropertyName("address")] +// public Address Address { get; set; } +// +// // [JsonPropertyName("currently_entitled_tiers")] +// // public CurrentlyEntitledTiers CurrentlyEntitledTiers { get; set; } +// } + +public sealed class PatreonMembersResponse +{ + [JsonPropertyName("data")] + public List Data { get; set; } + + [JsonPropertyName("included")] + public List Included { get; set; } + + [JsonPropertyName("links")] + public PatreonLinks Links { get; set; } +} + +public sealed class PatreonLinks +{ + [JsonPropertyName("next")] + public string Next { get; set; } +} + +public sealed class PatreonUser +{ + [JsonPropertyName("attributes")] + public PatreonUserAttributes Attributes { get; set; } + + [JsonPropertyName("id")] + public string Id { get; set; } + // public string Type { get; set; } +} + +public sealed class PatreonUserAttributes +{ + [JsonPropertyName("social_connections")] + public PatreonSocials SocialConnections { get; set; } +} + +public sealed class PatreonSocials +{ + [JsonPropertyName("discord")] + public DiscordSocial Discord { get; set; } +} + +public sealed class DiscordSocial +{ + [JsonPropertyName("user_id")] + public string UserId { get; set; } +} + +public sealed class PatreonMember +{ + [JsonPropertyName("attributes")] + public Attributes Attributes { get; set; } + + [JsonPropertyName("relationships")] + public Relationships Relationships { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } +} + +public sealed class Relationships +{ + [JsonPropertyName("user")] + public PatreonRelationshipUser User { get; set; } +} + +public sealed class PatreonRelationshipUser +{ + [JsonPropertyName("data")] + public PatreonUserData Data { get; set; } +} + +public sealed class PatreonUserData +{ + [JsonPropertyName("id")] + public string Id { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs new file mode 100644 index 0000000..4698b43 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs @@ -0,0 +1,28 @@ +#nullable disable +namespace EllieBot.Modules.Patronage; + +public sealed class PatreonMemberData : ISubscriberData +{ + public string PatreonUserId { get; init; } + public ulong UserId { get; init; } + public DateTime? LastChargeDate { get; init; } + public string LastChargeStatus { get; init; } + public int EntitledToCents { get; init; } + + public string UniquePlatformUserId + => PatreonUserId; + ulong ISubscriberData.UserId + => UserId; + public int Cents + => EntitledToCents; + public DateTime? LastCharge + => LastChargeDate; + public SubscriptionChargeStatus ChargeStatus + => LastChargeStatus switch + { + "Paid" => SubscriptionChargeStatus.Paid, + "Fraud" or "Refunded" => SubscriptionChargeStatus.Refunded, + "Declined" or "Pending" => SubscriptionChargeStatus.Unpaid, + _ => SubscriptionChargeStatus.Other, + }; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonRefreshData.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonRefreshData.cs new file mode 100644 index 0000000..2b6d154 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonRefreshData.cs @@ -0,0 +1,22 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Patronage; + +public sealed class PatreonRefreshData +{ + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } + + [JsonPropertyName("expires_in")] + public long ExpiresIn { get; set; } + + [JsonPropertyName("scope")] + public string Scope { get; set; } + + [JsonPropertyName("token_type")] + public string TokenType { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonSubscriptionHandler.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonSubscriptionHandler.cs new file mode 100644 index 0000000..edc0f08 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonSubscriptionHandler.cs @@ -0,0 +1,79 @@ +#nullable disable +namespace EllieBot.Modules.Patronage; + +/// +/// Service tasked with handling pledges on patreon +/// +public sealed class PatreonSubscriptionHandler : ISubscriptionHandler, IEService +{ + private readonly IBotCredsProvider _credsProvider; + private readonly PatreonClient _patreonClient; + + public PatreonSubscriptionHandler(IBotCredsProvider credsProvider) + { + _credsProvider = credsProvider; + 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; + c.Patreon.RefreshToken = patreonCreds.RefreshToken; + }); + + IAsyncEnumerable> data; + try + { + var maybeUserData = await _patreonClient.GetMembersAsync(botCreds.Patreon.CampaignId); + data = maybeUserData.Match( + static userData => userData, + static err => + { + Log.Warning("Error while getting patreon members: {ErrorMessage}", err.Value); + return AsyncEnumerable.Empty>(); + }); + } + catch (Exception ex) + { + Log.Warning(ex, + "Unexpected error while refreshing patreon members: {ErroMessage}", + ex.Message); + + yield break; + } + + var now = DateTime.UtcNow; + var firstOfThisMonth = new DateOnly(now.Year, now.Month, 1); + await foreach (var batch in data) + { + // send only active patrons + var toReturn = batch.Where(x => x.Cents > 0 + && x.LastCharge is { } lc + && lc.ToUniversalTime().ToDateOnly() >= firstOfThisMonth) + .ToArray(); + + if (toReturn.Length > 0) + yield return toReturn; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/PatronageCommands.cs b/src/EllieBot/Modules/Patronage/PatronageCommands.cs new file mode 100644 index 0000000..fee2c33 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/PatronageCommands.cs @@ -0,0 +1,140 @@ +using EllieBot.Modules.Patronage; + +namespace EllieBot.Modules.Help; + +public partial class Help +{ + [OnlyPublicBot] + public partial class Patronage : EllieModule + { + private readonly PatronageService _service; + private readonly PatronageConfig _pConf; + + public Patronage(PatronageService service, PatronageConfig pConf) + { + _service = service; + _pConf = pConf; + } + + [Cmd] + [Priority(2)] + public Task Patron() + => InternalPatron(ctx.User); + + [Cmd] + [Priority(0)] + [OwnerOnly] + public Task Patron(IUser user) + => InternalPatron(user); + + [Cmd] + [Priority(0)] + [OwnerOnly] + public async Task PatronMessage(PatronTier tierAndHigher, string message) + { + _ = ctx.Channel.TriggerTypingAsync(); + var result = await _service.SendMessageToPatronsAsync(tierAndHigher, message); + + await Response() + .Confirm(strs.patron_msg_sent( + Format.Code(tierAndHigher.ToString()), + Format.Bold(result.Success.ToString()), + Format.Bold(result.Failed.ToString()))) + .SendAsync(); + } + + // [OwnerOnly] + // public async Task PatronGift(IUser user, int amount) + // { + // // i can't figure out a good way to gift more than one month at the moment. + // + // if (amount < 1) + // return; + // + // var patron = _service.GiftPatronAsync(user, amount); + // + // var eb = _sender.CreateEmbed(); + // + // await Response().Embed(eb.WithDescription($"Added **{days}** days of Patron benefits to {user.Mention}!") + // .AddField("Tier", Format.Bold(patron.Tier.ToString()), true) + // .AddField("Amount", $"**{patron.Amount / 100.0f:N1}$**", true) + // .AddField("Until", TimestampTag.FromDateTime(patron.ValidThru.AddDays(1)))).SendAsync(); + // + // + // } + + private async Task InternalPatron(IUser user) + { + if (!_pConf.Data.IsEnabled) + { + await Response().Error(strs.patron_not_enabled).SendAsync(); + return; + } + + 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.Count == 0 || maybePatron is not { } patron) + { + eb.WithDescription(GetText(strs.no_quota_found)); + } + else + { + eb.AddField(GetText(strs.tier), Format.Bold(patron.Tier.ToFullName()), true) + .AddField(GetText(strs.pledge), $"**{patron.Amount / 100.0f:N1}$**", true); + + if (patron.Tier != PatronTier.None) + eb.AddField(GetText(strs.expires), + patron.ValidThru.AddDays(1).ToShortAndRelativeTimestampTag(), + true); + + eb.AddField(GetText(strs.quotas), "⁣", false); + + var text = GetQuotaList(quotaStats); + if (!string.IsNullOrWhiteSpace(text)) + eb.AddField(GetText(strs.modules), text, true); + } + + + try + { + await Response().User(ctx.User).Embed(eb).SendAsync(); + _ = ctx.OkAsync(); + } + catch + { + await Response().Error(strs.cant_dm).SendAsync(); + } + } + + private string GetQuotaList( + IReadOnlyDictionary featureQuotaStats) + { + var text = string.Empty; + foreach (var (key, (cur, quota)) in featureQuotaStats) + { + text += $"\n⁣\t`{key}`\n"; + if (quota.QuotaPeriod == QuotaPer.PerHour) + text += $"⁣ ⁣ {cur}/{(quota.Quota == -1 ? "∞" : quota.Quota)} {QuotaPeriodToString(quota.QuotaPeriod)}\n"; + } + + return text; + } + + 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 new file mode 100644 index 0000000..b33fc1e --- /dev/null +++ b/src/EllieBot/Modules/Patronage/PatronageService.cs @@ -0,0 +1,486 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Patronage; + +/// +public sealed class PatronageService + : IPatronageService, + IReadyExecutor, + 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 readonly PatronageConfig _pConf; + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly ISubscriptionHandler _subsHandler; + + 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(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"); + } + } + } + + 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 + 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, + }); + + + 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)); + } + 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 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 LimitHitAsync(LimitedFeatureName key, ulong userId, int amount = 1) + { + if (_creds.GetCreds().IsOwner(userId)) + return true; + + if (!_pConf.Data.IsEnabled) + return true; + + var userLimit = await GetUserLimit(key, userId); + + if (userLimit.Quota == 0) + return false; + + if (userLimit.Quota == -1) + return true; + + return await TryAddLimit(key, userLimit, userId, amount); + } + + public async Task LimitForceHit(LimitedFeatureName key, ulong userId, int amount) + { + 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) + { + 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 (value.TryGetValue(name, out var quotaLimit)) + { + return quotaLimit; + } + + break; + } + } + + return _emptyQuota; + } + + public async Task> LimitStats(ulong userId) + { + var dict = new Dictionary(); + foreach (var featureName in Enum.GetValues()) + { + 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; + } + + + 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 + }; + } + + 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 + { + 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; +} \ No newline at end of file