Added Ellie.Bot.Modules.Patronage
This commit is contained in:
parent
cdaf7c9ff2
commit
e16e4b82a3
11 changed files with 1469 additions and 2 deletions
4
TODO.md
4
TODO.md
|
@ -4,8 +4,8 @@
|
||||||
- Start and finish Ellie.Bot.Modules.Administration
|
- Start and finish Ellie.Bot.Modules.Administration
|
||||||
- Start and finish Ellie.Bot.Modules.Utility
|
- Start and finish Ellie.Bot.Modules.Utility
|
||||||
- ~~Finish Ellie.Bot.Modules.Music~~ - Finished
|
- ~~Finish Ellie.Bot.Modules.Music~~ - Finished
|
||||||
- Finish Ellie.Bot.Modules.Xp - Started
|
- ~~Finish Ellie.Bot.Modules.Xp~~ - Finished
|
||||||
- Start and finish Ellie.Bot.Modules.Patronage
|
- ~~Finish Ellie.Bot.Modules.Patronage~~ - Finished
|
||||||
- Start and finish Ellie.Bot.Modules.Help
|
- Start and finish Ellie.Bot.Modules.Help
|
||||||
- Start amd finish Ellie.Bot.Modules.Permissions
|
- Start amd finish Ellie.Bot.Modules.Permissions
|
||||||
- Fix the numerous bugs
|
- Fix the numerous bugs
|
|
@ -0,0 +1,19 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Ellie.Bot.Common\Ellie.Bot.Common.csproj"/>
|
||||||
|
<ProjectReference Include="..\Ellie.Bot.Modules.Gambling\Ellie.Bot.Modules.Gambling.csproj"/>
|
||||||
|
|
||||||
|
<ProjectReference Include="..\Ellie.Bot.Generators.Cloneable\Ellie.Bot.Generators.Cloneable.csproj" OutputItemType="Analyzer"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</Project>
|
|
@ -0,0 +1,36 @@
|
||||||
|
using Ellie.Common.Configs;
|
||||||
|
|
||||||
|
namespace Ellie.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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,149 @@
|
||||||
|
#nullable disable
|
||||||
|
using OneOf;
|
||||||
|
using OneOf.Types;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Ellie.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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace Ellie.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/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonData.cs
Normal file
134
src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonData.cs
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
#nullable disable
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Ellie.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; }
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace Ellie.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PatreonPledgeData
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
#nullable disable
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Ellie.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 Ellie.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
148
src/Ellie.Bot.Modules.Patronage/Patronage/PatronageCommands.cs
Normal file
148
src/Ellie.Bot.Modules.Patronage/Patronage/PatronageCommands.cs
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
namespace Ellie.Modules.Patronage;
|
||||||
|
|
||||||
|
[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 ReplyConfirmLocalizedAsync(strs.patron_msg_sent(
|
||||||
|
Format.Code(tierAndHigher.ToString()),
|
||||||
|
Format.Bold(result.Success.ToString()),
|
||||||
|
Format.Bold(result.Failed.ToString())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Cmd]
|
||||||
|
// [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 = _eb.Create(ctx);
|
||||||
|
//
|
||||||
|
// await ctx.Channel.EmbedAsync(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))));
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
|
||||||
|
private async Task InternalPatron(IUser user)
|
||||||
|
{
|
||||||
|
if (!_pConf.Data.IsEnabled)
|
||||||
|
{
|
||||||
|
await ReplyErrorLocalizedAsync(strs.patron_not_enabled);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var patron = await _service.GetPatronAsync(user.Id);
|
||||||
|
var quotaStats = await _service.GetUserQuotaStatistic(user.Id);
|
||||||
|
|
||||||
|
var eb = _eb.Create(ctx)
|
||||||
|
.WithAuthor(user)
|
||||||
|
.WithTitle(GetText(strs.patron_info))
|
||||||
|
.WithOkColor();
|
||||||
|
|
||||||
|
if (quotaStats.Commands.Count == 0
|
||||||
|
&& quotaStats.Groups.Count == 0
|
||||||
|
&& quotaStats.Modules.Count == 0)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (quotaStats.Commands.Count > 0)
|
||||||
|
{
|
||||||
|
var text = GetQuotaList(quotaStats.Commands);
|
||||||
|
if (!string.IsNullOrWhiteSpace(text))
|
||||||
|
eb.AddField(GetText(strs.commands), text, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quotaStats.Groups.Count > 0)
|
||||||
|
{
|
||||||
|
var text = GetQuotaList(quotaStats.Groups);
|
||||||
|
if (!string.IsNullOrWhiteSpace(text))
|
||||||
|
eb.AddField(GetText(strs.groups), text, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quotaStats.Modules.Count > 0)
|
||||||
|
{
|
||||||
|
var text = GetQuotaList(quotaStats.Modules);
|
||||||
|
if (!string.IsNullOrWhiteSpace(text))
|
||||||
|
eb.AddField(GetText(strs.modules), text, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ctx.User.EmbedAsync(eb);
|
||||||
|
_ = ctx.OkAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await ReplyErrorLocalizedAsync(strs.cant_dm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetQuotaList(IReadOnlyDictionary<string, FeatureQuotaStats> featureQuotaStats)
|
||||||
|
{
|
||||||
|
var text = string.Empty;
|
||||||
|
foreach (var (key, q) in featureQuotaStats)
|
||||||
|
{
|
||||||
|
text += $"\n\t`{key}`\n";
|
||||||
|
if (q.Hourly != default)
|
||||||
|
text += $" {GetEmoji(q.Hourly)} {q.Hourly.Cur}/{q.Hourly.Max} per hour\n";
|
||||||
|
if (q.Daily != default)
|
||||||
|
text += $" {GetEmoji(q.Daily)} {q.Daily.Cur}/{q.Daily.Max} per day\n";
|
||||||
|
if (q.Monthly != default)
|
||||||
|
text += $" {GetEmoji(q.Monthly)} {q.Monthly.Cur}/{q.Monthly.Max} per month\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetEmoji((uint Cur, uint Max) limit)
|
||||||
|
=> limit.Cur < limit.Max
|
||||||
|
? "✅"
|
||||||
|
: "⚠️";
|
||||||
|
}
|
837
src/Ellie.Bot.Modules.Patronage/Patronage/PatronageService.cs
Normal file
837
src/Ellie.Bot.Modules.Patronage/Patronage/PatronageService.cs
Normal file
|
@ -0,0 +1,837 @@
|
||||||
|
using LinqToDB;
|
||||||
|
using LinqToDB.EntityFrameworkCore;
|
||||||
|
using Ellie.Common.ModuleBehaviors;
|
||||||
|
using Ellie.Db.Models;
|
||||||
|
using OneOf;
|
||||||
|
using OneOf.Types;
|
||||||
|
using CommandInfo = Discord.Commands.CommandInfo;
|
||||||
|
|
||||||
|
namespace Ellie.Modules.Patronage;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="IPatronageService"/>
|
||||||
|
public sealed class PatronageService
|
||||||
|
: IPatronageService,
|
||||||
|
IReadyExecutor,
|
||||||
|
IExecPreCommand,
|
||||||
|
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 static readonly PatronTier[] _tiers = Enum.GetValues<PatronTier>();
|
||||||
|
|
||||||
|
private readonly PatronageConfig _pConf;
|
||||||
|
private readonly DbService _db;
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
private readonly ISubscriptionHandler _subsHandler;
|
||||||
|
private readonly IEmbedBuilderService _eb;
|
||||||
|
private static readonly TypedKey<long> _quotaKey
|
||||||
|
= new($"quota:last_hourly_reset");
|
||||||
|
|
||||||
|
private readonly IBotCache _cache;
|
||||||
|
private readonly IBotCredsProvider _creds;
|
||||||
|
|
||||||
|
public PatronageService(
|
||||||
|
PatronageConfig pConf,
|
||||||
|
DbService db,
|
||||||
|
DiscordSocketClient client,
|
||||||
|
ISubscriptionHandler subsHandler,
|
||||||
|
IEmbedBuilderService eb,
|
||||||
|
IBotCache cache,
|
||||||
|
IBotCredsProvider creds)
|
||||||
|
{
|
||||||
|
_pConf = pConf;
|
||||||
|
_db = db;
|
||||||
|
_client = client;
|
||||||
|
_subsHandler = subsHandler;
|
||||||
|
_eb = eb;
|
||||||
|
_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();
|
||||||
|
await using var tran = await ctx.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
if ((lastDate.Day == 1 || (lastDate.Month != nowDate.Month)) && nowDate.Day > 1)
|
||||||
|
{
|
||||||
|
// assumes bot won't be offline for a year
|
||||||
|
await ctx.GetTable<PatronQuota>()
|
||||||
|
.TruncateAsync();
|
||||||
|
}
|
||||||
|
else if (nowDate.DayNumber != lastDate.DayNumber)
|
||||||
|
{
|
||||||
|
// day is different, means hour is different.
|
||||||
|
// reset both hourly and daily quota counts.
|
||||||
|
await ctx.GetTable<PatronQuota>()
|
||||||
|
.UpdateAsync((old) => new()
|
||||||
|
{
|
||||||
|
HourlyCount = 0,
|
||||||
|
DailyCount = 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (now.Hour != lastRun.Hour) // if it's not, just reset hourly quotas
|
||||||
|
{
|
||||||
|
await ctx.GetTable<PatronQuota>()
|
||||||
|
.UpdateAsync((old) => new()
|
||||||
|
{
|
||||||
|
HourlyCount = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// assumes that the code above runs in less than an hour
|
||||||
|
await _cache.AddAsync(_quotaKey, now.ToBinary());
|
||||||
|
await tran.CommitAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Error in quota reset loop. Message: {ErrorMessage}", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromHours(1).Add(TimeSpan.FromMinutes(1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcesssPatronsAsync(IReadOnlyCollection<ISubscriberData> subscribersEnum)
|
||||||
|
{
|
||||||
|
// 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);
|
||||||
|
// await using var tran = await ctx.Database.BeginTransactionAsync();
|
||||||
|
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
|
||||||
|
var count = 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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<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<bool> 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 = _eb.Create(ctx)
|
||||||
|
.WithPendingColor()
|
||||||
|
.WithTitle("Insufficient Patron Tier")
|
||||||
|
.AddField("For", $"{ins.FeatureType}: `{ins.Feature}`", true)
|
||||||
|
.AddField("Required Tier",
|
||||||
|
$"[{ins.RequiredTier.ToFullName()}](https://patreon.com/join/nadekobot)",
|
||||||
|
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)
|
||||||
|
_ = ctx.Channel.EmbedAsync(eb);
|
||||||
|
else
|
||||||
|
_ = ctx.User.EmbedAsync(eb);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
quota =>
|
||||||
|
{
|
||||||
|
var eb = _eb.Create(ctx)
|
||||||
|
.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)
|
||||||
|
_ = ctx.Channel.EmbedAsync(eb);
|
||||||
|
else
|
||||||
|
_ = ctx.User.EmbedAsync(eb);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<OneOf<OneOf.Types.Success, InsufficientTier, QuotaLimit>> AttemptRunCommand(
|
||||||
|
ulong userId,
|
||||||
|
ulong ownerId,
|
||||||
|
string commandName,
|
||||||
|
string groupName,
|
||||||
|
string moduleName)
|
||||||
|
{
|
||||||
|
// try to run as a user
|
||||||
|
var res = await AttemptRunCommand(userId, commandName, groupName, moduleName, true);
|
||||||
|
|
||||||
|
// if it fails, try to run as an owner
|
||||||
|
// but only if the command is ran in a server
|
||||||
|
// and if the owner is not the user
|
||||||
|
if (!res.IsT0 && ownerId != 0 && ownerId != userId)
|
||||||
|
res = await AttemptRunCommand(ownerId, commandName, groupName, moduleName, false);
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns either the current usage counter if limit wasn't reached, or QuotaLimit if it is.
|
||||||
|
/// </summary>
|
||||||
|
public async ValueTask<OneOf<(uint Hourly, uint Daily, uint Monthly), QuotaLimit>> TryIncrementQuotaCounterAsync(ulong userId,
|
||||||
|
bool isSelf,
|
||||||
|
FeatureType featureType,
|
||||||
|
string featureName,
|
||||||
|
uint? maybeHourly,
|
||||||
|
uint? maybeDaily,
|
||||||
|
uint? maybeMonthly)
|
||||||
|
{
|
||||||
|
await using var ctx = _db.GetDbContext();
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
await using var tran = await ctx.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
var userQuotaData = await ctx.GetTable<PatronQuota>()
|
||||||
|
.FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId
|
||||||
|
&& x.Feature == featureName)
|
||||||
|
?? new PatronQuota();
|
||||||
|
|
||||||
|
// if hourly exists, if daily exists, etc...
|
||||||
|
if (maybeHourly is uint hourly && userQuotaData.HourlyCount >= hourly)
|
||||||
|
{
|
||||||
|
return new QuotaLimit()
|
||||||
|
{
|
||||||
|
QuotaPeriod = QuotaPer.PerHour,
|
||||||
|
Quota = hourly,
|
||||||
|
// quite a neat trick. https://stackoverflow.com/a/5733560
|
||||||
|
ResetsAt = now.Date.AddHours(now.Hour + 1),
|
||||||
|
Feature = featureName,
|
||||||
|
FeatureType = featureType,
|
||||||
|
IsOwnQuota = isSelf
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maybeDaily is uint daily
|
||||||
|
&& userQuotaData.DailyCount >= daily)
|
||||||
|
{
|
||||||
|
return new QuotaLimit()
|
||||||
|
{
|
||||||
|
QuotaPeriod = QuotaPer.PerDay,
|
||||||
|
Quota = daily,
|
||||||
|
ResetsAt = now.Date.AddDays(1),
|
||||||
|
Feature = featureName,
|
||||||
|
FeatureType = featureType,
|
||||||
|
IsOwnQuota = isSelf
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maybeMonthly is uint monthly && userQuotaData.MonthlyCount >= monthly)
|
||||||
|
{
|
||||||
|
return new QuotaLimit()
|
||||||
|
{
|
||||||
|
QuotaPeriod = QuotaPer.PerMonth,
|
||||||
|
Quota = monthly,
|
||||||
|
ResetsAt = now.Date.SecondOfNextMonth(),
|
||||||
|
Feature = featureName,
|
||||||
|
FeatureType = featureType,
|
||||||
|
IsOwnQuota = isSelf
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.GetTable<PatronQuota>()
|
||||||
|
.InsertOrUpdateAsync(() => new()
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
FeatureType = featureType,
|
||||||
|
Feature = featureName,
|
||||||
|
DailyCount = 1,
|
||||||
|
MonthlyCount = 1,
|
||||||
|
HourlyCount = 1,
|
||||||
|
},
|
||||||
|
(old) => new()
|
||||||
|
{
|
||||||
|
HourlyCount = old.HourlyCount + 1,
|
||||||
|
DailyCount = old.DailyCount + 1,
|
||||||
|
MonthlyCount = old.MonthlyCount + 1,
|
||||||
|
},
|
||||||
|
() => new()
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
FeatureType = featureType,
|
||||||
|
Feature = featureName,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tran.CommitAsync();
|
||||||
|
|
||||||
|
return (userQuotaData.HourlyCount + 1, userQuotaData.DailyCount + 1, userQuotaData.MonthlyCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to add 1 to user's quota for the command, group and module.
|
||||||
|
/// Input MUST BE lowercase
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">Id of the user who is attempting to run the command</param>
|
||||||
|
/// <param name="commandName">Name of the command the user is trying to run</param>
|
||||||
|
/// <param name="groupName">Name of the command's group</param>
|
||||||
|
/// <param name="moduleName">Name of the command's top level module</param>
|
||||||
|
/// <param name="isSelf">Whether this is check is for the user himself. False if it's someone else's id (owner)</param>
|
||||||
|
/// <returns>Either a succcess (user can run the command) or one of the error values.</returns>
|
||||||
|
private async ValueTask<OneOf<OneOf.Types.Success, InsufficientTier, QuotaLimit>> AttemptRunCommand(
|
||||||
|
ulong userId,
|
||||||
|
string commandName,
|
||||||
|
string groupName,
|
||||||
|
string moduleName,
|
||||||
|
bool isSelf)
|
||||||
|
{
|
||||||
|
var confData = _pConf.Data;
|
||||||
|
|
||||||
|
if (!confData.IsEnabled)
|
||||||
|
return default;
|
||||||
|
|
||||||
|
if (_creds.GetCreds().IsOwner(userId))
|
||||||
|
return default;
|
||||||
|
|
||||||
|
// get user tier
|
||||||
|
var patron = await GetPatronAsync(userId);
|
||||||
|
FeatureType quotaForFeatureType;
|
||||||
|
|
||||||
|
if (confData.Quotas.Commands.TryGetValue(commandName, out var quotaData))
|
||||||
|
{
|
||||||
|
quotaForFeatureType = FeatureType.Command;
|
||||||
|
}
|
||||||
|
else if (confData.Quotas.Groups.TryGetValue(groupName, out quotaData))
|
||||||
|
{
|
||||||
|
quotaForFeatureType = FeatureType.Group;
|
||||||
|
}
|
||||||
|
else if (confData.Quotas.Modules.TryGetValue(moduleName, out quotaData))
|
||||||
|
{
|
||||||
|
quotaForFeatureType = FeatureType.Module;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
var featureName = quotaForFeatureType switch
|
||||||
|
{
|
||||||
|
FeatureType.Command => commandName,
|
||||||
|
FeatureType.Group => groupName,
|
||||||
|
FeatureType.Module => moduleName,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(quotaForFeatureType))
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!TryGetTierDataOrLower(quotaData, patron.Tier, out var data))
|
||||||
|
{
|
||||||
|
return new InsufficientTier()
|
||||||
|
{
|
||||||
|
Feature = featureName,
|
||||||
|
FeatureType = quotaForFeatureType,
|
||||||
|
RequiredTier = quotaData.Count == 0
|
||||||
|
? PatronTier.ComingSoon
|
||||||
|
: quotaData.Keys.First(),
|
||||||
|
UserTier = patron.Tier,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// no quota limits for this tier
|
||||||
|
if (data is null)
|
||||||
|
return default;
|
||||||
|
|
||||||
|
var quotaCheckResult = await TryIncrementQuotaCounterAsync(userId,
|
||||||
|
isSelf,
|
||||||
|
quotaForFeatureType,
|
||||||
|
featureName,
|
||||||
|
data.TryGetValue(QuotaPer.PerHour, out var hourly) ? hourly : null,
|
||||||
|
data.TryGetValue(QuotaPer.PerDay, out var daily) ? daily : null,
|
||||||
|
data.TryGetValue(QuotaPer.PerMonth, out var monthly) ? monthly : null
|
||||||
|
);
|
||||||
|
|
||||||
|
return quotaCheckResult.Match<OneOf<Success, InsufficientTier, QuotaLimit>>(
|
||||||
|
_ => new Success(),
|
||||||
|
x => x);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetTierDataOrLower<T>(
|
||||||
|
IReadOnlyDictionary<PatronTier, T?> data,
|
||||||
|
PatronTier tier,
|
||||||
|
out T? o)
|
||||||
|
{
|
||||||
|
// check for quotas on this tier
|
||||||
|
if (data.TryGetValue(tier, out o))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// if there are none, get the quota first tier below this one
|
||||||
|
// which has quotas specified
|
||||||
|
for (var i = _tiers.Length - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var lowerTier = _tiers[i];
|
||||||
|
if (lowerTier < tier && data.TryGetValue(lowerTier, out o))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there are none, that means the feature is intended
|
||||||
|
// to be patron-only but the quotas haven't been specified yet
|
||||||
|
// so it will be marked as "Coming Soon"
|
||||||
|
o = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Patron> GetPatronAsync(ulong userId)
|
||||||
|
{
|
||||||
|
await using var ctx = _db.GetDbContext();
|
||||||
|
|
||||||
|
// 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<UserQuotaStats> 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<PatronQuota>()
|
||||||
|
.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<string, FeatureQuotaStats> GetFeatureQuotaStats(
|
||||||
|
PatronTier patronTier,
|
||||||
|
IReadOnlyDictionary<string, PatronQuota>? allQuotasDict,
|
||||||
|
Dictionary<string, Dictionary<PatronTier, Dictionary<QuotaPer, uint>?>> commands)
|
||||||
|
{
|
||||||
|
var userCommandQuotaStats = new Dictionary<string, FeatureQuotaStats>();
|
||||||
|
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<FeatureLimit> 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<Patron> 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 = _eb.Create()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithTitle("❤️ Thank you for supporting NadekoBot! ❤️")
|
||||||
|
.WithDescription(
|
||||||
|
"Your donation has been processed and you will receive the rewards shortly.\n"
|
||||||
|
+ "You can visit <https://www.patreon.com/join/nadekobot> 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/nadekobot>*
|
||||||
|
*- 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 Nadeko'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://nadekobot.readthedocs.io/en/latest/permissions-system/>*
|
||||||
|
""",
|
||||||
|
isInline: false)
|
||||||
|
.WithFooter($"platform id: {patron.UniquePlatformUserId}");
|
||||||
|
|
||||||
|
await user.EmbedAsync(eb);
|
||||||
|
}
|
||||||
|
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 user.SendAsync(text);
|
||||||
|
++succ;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
++fail;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (succ, fail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PatronConfigData GetConfig()
|
||||||
|
=> _pConf.Data;
|
||||||
|
}
|
Loading…
Reference in a new issue