Added Patronage module
This commit is contained in:
parent
eb9ed57547
commit
fdd13aa087
10 changed files with 1287 additions and 0 deletions
45
src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs
Normal file
45
src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs
Normal file
|
@ -0,0 +1,45 @@
|
|||
using EllieBot.Common.Configs;
|
||||
|
||||
namespace EllieBot.Modules.Patronage;
|
||||
|
||||
public class PatronageConfig : ConfigServiceBase<PatronConfigData>
|
||||
{
|
||||
public override string Name
|
||||
=> "patron";
|
||||
|
||||
private static readonly TypedKey<PatronConfigData> _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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
194
src/EllieBot/Modules/Patronage/CurrencyRewardService.cs
Normal file
194
src/EllieBot/Modules/Patronage/CurrencyRewardService.cs
Normal file
|
@ -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<RewardedUser>()
|
||||
.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<RewardedUser>()
|
||||
.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<RewardedUser>()
|
||||
.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<RewardedUser>()
|
||||
.UpdateAsync(old => new()
|
||||
{
|
||||
AmountRewardedThisMonth = old.AmountRewardedThisMonth * 2
|
||||
});
|
||||
}
|
||||
}
|
149
src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs
Normal file
149
src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs
Normal file
|
@ -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<OneOf<Success, Error<string>>> 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<string>($"Request did not return a sucess status code. Status code: {res.StatusCode}");
|
||||
|
||||
try
|
||||
{
|
||||
var data = await res.Content.ReadFromJsonAsync<PatreonRefreshData>();
|
||||
|
||||
if (data is null)
|
||||
return new Error<string>($"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<string>($"Error during deserialization: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<bool> 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<OneOf<IAsyncEnumerable<IReadOnlyCollection<PatreonMemberData>>, Error<string>>> GetMembersAsync(string campaignId)
|
||||
{
|
||||
if (!await EnsureTokenValidAsync())
|
||||
return new Error<string>("Unable to get patreon token");
|
||||
|
||||
return OneOf<IAsyncEnumerable<IReadOnlyCollection<PatreonMemberData>>, Error<string>>.FromT0(
|
||||
GetMembersInternalAsync(campaignId));
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<IReadOnlyCollection<PatreonMemberData>> 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<PatreonMembersResponse>(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));
|
||||
}
|
||||
}
|
10
src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs
Normal file
10
src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs
Normal file
|
@ -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; }
|
||||
}
|
134
src/EllieBot/Modules/Patronage/Patreon/PatreonData.cs
Normal file
134
src/EllieBot/Modules/Patronage/Patreon/PatreonData.cs
Normal file
|
@ -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<Datum> 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<PatreonMember> Data { get; set; }
|
||||
|
||||
[JsonPropertyName("included")]
|
||||
public List<PatreonUser> 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; }
|
||||
}
|
28
src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs
Normal file
28
src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs
Normal file
|
@ -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,
|
||||
};
|
||||
}
|
22
src/EllieBot/Modules/Patronage/Patreon/PatreonRefreshData.cs
Normal file
22
src/EllieBot/Modules/Patronage/Patreon/PatreonRefreshData.cs
Normal file
|
@ -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; }
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Modules.Patronage;
|
||||
|
||||
/// <summary>
|
||||
/// Service tasked with handling pledges on patreon
|
||||
/// </summary>
|
||||
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<IReadOnlyCollection<ISubscriberData>> 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<IEnumerable<ISubscriberData>> 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<IReadOnlyCollection<ISubscriberData>>();
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
140
src/EllieBot/Modules/Patronage/PatronageCommands.cs
Normal file
140
src/EllieBot/Modules/Patronage/PatronageCommands.cs
Normal file
|
@ -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<LimitedFeatureName, (int Cur, QuotaLimit Quota)> 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)
|
||||
};
|
||||
}
|
||||
}
|
486
src/EllieBot/Modules/Patronage/PatronageService.cs
Normal file
486
src/EllieBot/Modules/Patronage/PatronageService.cs
Normal file
|
@ -0,0 +1,486 @@
|
|||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using EllieBot.Common.ModuleBehaviors;
|
||||
using EllieBot.Db.Models;
|
||||
|
||||
namespace EllieBot.Modules.Patronage;
|
||||
|
||||
/// <inheritdoc cref="IPatronageService"/>
|
||||
public sealed class PatronageService
|
||||
: IPatronageService,
|
||||
IReadyExecutor,
|
||||
IEService
|
||||
{
|
||||
public event Func<Patron, Task> OnNewPatronPayment = static delegate { return Task.CompletedTask; };
|
||||
public event Func<Patron, Patron, Task> OnPatronUpdated = static delegate { return Task.CompletedTask; };
|
||||
public event Func<Patron, Task> 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<ISubscriberData> 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<PatronUser>()
|
||||
.FirstOrDefaultAsync(x
|
||||
=> x.UniquePlatformUserId == subscriber.UniquePlatformUserId);
|
||||
|
||||
if (dbPatron is null)
|
||||
{
|
||||
// if the user is not in the database alrady
|
||||
dbPatron = await ctx.GetTable<PatronUser>()
|
||||
.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<PatronUser>()
|
||||
.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<PatronUser>()
|
||||
.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<PatronUser>()
|
||||
.Where(x => x.UniquePlatformUserId == patron.UniquePlatformUserId
|
||||
&& x.ValidThru != expiredDate)
|
||||
.UpdateAsync(old => new()
|
||||
{
|
||||
ValidThru = expiredDate
|
||||
});
|
||||
|
||||
if (changedCount == 0)
|
||||
continue;
|
||||
|
||||
var updated = await ctx.GetTable<PatronUser>()
|
||||
.Where(x => x.UniquePlatformUserId == patron.UniquePlatformUserId)
|
||||
.FirstAsync();
|
||||
|
||||
await OnPatronRefunded(PatronUserToPatron(updated));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Patron?> 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<PatronUser>()
|
||||
.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<bool> 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<bool> 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<bool> TryAddLimit(
|
||||
LimitedFeatureName key,
|
||||
QuotaLimit userLimit,
|
||||
ulong userId,
|
||||
int amount)
|
||||
{
|
||||
var cacheKey = CreateKey(key, userId);
|
||||
var cur = await _cache.GetOrAddAsync(cacheKey, () => Task.FromResult(0), GetExpiry(userLimit));
|
||||
|
||||
if (cur + amount < userLimit.Quota)
|
||||
{
|
||||
await _cache.AddAsync(cacheKey, cur + amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private TimeSpan? GetExpiry(QuotaLimit userLimit)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
switch (userLimit.QuotaPeriod)
|
||||
{
|
||||
case QuotaPer.PerHour:
|
||||
return TimeSpan.FromMinutes(60 - now.Minute);
|
||||
case QuotaPer.PerDay:
|
||||
return TimeSpan.FromMinutes((24 * 60) - ((now.Hour * 60) + now.Minute));
|
||||
case QuotaPer.PerMonth:
|
||||
var firstOfNextMonth = now.FirstOfNextMonth();
|
||||
return firstOfNextMonth - now;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private TypedKey<int> CreateKey(LimitedFeatureName key, ulong userId)
|
||||
=> new($"limited_feature:{key}:{userId}");
|
||||
|
||||
private readonly QuotaLimit _emptyQuota = new QuotaLimit()
|
||||
{
|
||||
Quota = 0,
|
||||
QuotaPeriod = QuotaPer.PerDay,
|
||||
};
|
||||
|
||||
private readonly QuotaLimit _infiniteQuota = new QuotaLimit()
|
||||
{
|
||||
Quota = -1,
|
||||
QuotaPeriod = QuotaPer.PerDay,
|
||||
};
|
||||
|
||||
public async Task<QuotaLimit> GetUserLimit(LimitedFeatureName name, ulong userId)
|
||||
{
|
||||
if (!_pConf.Data.IsEnabled)
|
||||
return _infiniteQuota;
|
||||
|
||||
var maybePatron = await GetPatronAsync(userId);
|
||||
|
||||
if (maybePatron is not { } patron)
|
||||
return _emptyQuota;
|
||||
|
||||
if (patron.ValidThru < DateTime.UtcNow)
|
||||
return _emptyQuota;
|
||||
|
||||
foreach (var (key, value) in _pConf.Data.Limits)
|
||||
{
|
||||
if (patron.Amount >= key)
|
||||
{
|
||||
if (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;
|
||||
}
|
||||
|
||||
|
||||
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 <https://www.patreon.com/join/elliebot> 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 <https://www.patreon.com/join/elliebot>*
|
||||
*- You can use the `.patron` command in this chat to check your current quota usage for the Patron-only commands*
|
||||
*- **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: <https://docs.elliebot.net/ellie/features/permissions-system/>*
|
||||
""",
|
||||
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<PatronUser>()
|
||||
.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;
|
||||
}
|
Loading…
Reference in a new issue