NadekoBot Patronage system, Search commands improvements + fixes

This commit is contained in:
Kwoth
2022-06-14 07:24:33 +00:00
parent 18b10b8c6f
commit 7b5145f116
165 changed files with 14920 additions and 1457 deletions

View File

@@ -1,4 +1,5 @@
#nullable disable
using NadekoBot.Modules.Utility.Patronage;
using System.Text;
namespace NadekoBot.Modules.Utility;
@@ -10,11 +11,13 @@ public partial class Utility
{
private readonly DiscordSocketClient _client;
private readonly IStatsService _stats;
private readonly IPatronageService _ps;
public InfoCommands(DiscordSocketClient client, IStatsService stats)
public InfoCommands(DiscordSocketClient client, IStatsService stats, IPatronageService ps)
{
_client = client;
_stats = stats;
_ps = ps;
}
[Cmd]
@@ -106,6 +109,7 @@ public partial class Utility
var embed = _eb.Create().AddField(GetText(strs.name), $"**{user.Username}**#{user.Discriminator}", true);
if (!string.IsNullOrWhiteSpace(user.Nickname))
embed.AddField(GetText(strs.nickname), user.Nickname, true);
embed.AddField(GetText(strs.id), user.Id.ToString(), true)
.AddField(GetText(strs.joined_server), $"{user.JoinedAt?.ToString("dd.MM.yyyy HH:mm") ?? "?"}", true)
.AddField(GetText(strs.joined_discord), $"{user.CreatedAt:dd.MM.yyyy HH:mm}", true)
@@ -114,9 +118,24 @@ public partial class Utility
true)
.WithOkColor();
var patron = await _ps.GetPatronAsync(user.Id);
if (patron.Tier != PatronTier.None)
{
embed.WithFooter(patron.Tier switch
{
PatronTier.V => "❤️❤️",
PatronTier.X => "❤️❤️❤️",
PatronTier.XX => "❤️❤️❤️❤️",
PatronTier.L => "❤️❤️❤️❤️❤️",
_ => "❤️",
});
}
var av = user.RealAvatarUrl();
if (av.IsAbsoluteUri)
embed.WithThumbnailUrl(av.ToString());
await ctx.Channel.EmbedAsync(embed);
}

View File

@@ -1,305 +0,0 @@
#nullable disable
using LinqToDB.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Modules.Gambling.Services;
using NadekoBot.Modules.Utility.Common.Patreon;
using NadekoBot.Services.Database.Models;
using StackExchange.Redis;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace NadekoBot.Modules.Utility;
public class PatreonRewardsService : INService, IReadyExecutor
{
public TimeSpan Interval { get; } = TimeSpan.FromMinutes(3);
public DateTime LastUpdate { get; private set; } = DateTime.UtcNow;
private readonly SemaphoreSlim _claimLockJustInCase = new(1, 1);
private readonly DbService _db;
private readonly ICurrencyService _currency;
private readonly GamblingConfigService _gamblingConfigService;
private readonly ConnectionMultiplexer _redis;
private readonly IBotCredsProvider _credsProvider;
private readonly IHttpClientFactory _httpFactory;
private readonly IEmbedBuilderService _eb;
private readonly DiscordSocketClient _client;
public PatreonRewardsService(
DbService db,
ICurrencyService currency,
IHttpClientFactory factory,
IEmbedBuilderService eb,
DiscordSocketClient client,
GamblingConfigService gamblingConfigService,
ConnectionMultiplexer redis,
IBotCredsProvider credsProvider)
{
_db = db;
_currency = currency;
_gamblingConfigService = gamblingConfigService;
_redis = redis;
_credsProvider = credsProvider;
_httpFactory = factory;
_eb = eb;
_client = client;
}
public async Task OnReadyAsync()
{
if (_client.ShardId != 0)
return;
using var t = new PeriodicTimer(Interval);
do
{
try
{
await RefreshPledges(_credsProvider.GetCreds());
}
catch (Exception ex)
{
Log.Error(ex, "Unexpected error refreshing patreon pledges: {ErrorMessage}", ex.Message);
}
} while (await t.WaitForNextTickAsync());
}
private DateTime LastAccessTokenUpdate(IBotCredentials creds)
{
var db = _redis.GetDatabase();
var val = db.StringGet($"{creds.RedisKey()}_patreon_update");
if (val == default)
return DateTime.MinValue;
var lastTime = DateTime.FromBinary((long)val);
return lastTime;
}
private async Task<bool> UpdateAccessToken(IBotCredentials creds)
{
Log.Information("Updating patreon access token...");
try
{
using var http = _httpFactory.CreateClient();
using var res = await http.PostAsync("https://www.patreon.com/api/oauth2/token"
+ "?grant_type=refresh_token"
+ $"&refresh_token={creds.Patreon.RefreshToken}"
+ $"&client_id={creds.Patreon.ClientId}"
+ $"&client_secret={creds.Patreon.ClientSecret}",
null);
res.EnsureSuccessStatusCode();
var data = await res.Content.ReadFromJsonAsync<PatreonRefreshData>();
if (data is null)
throw new("Invalid patreon response.");
_credsProvider.ModifyCredsFile(oldData =>
{
oldData.Patreon.AccessToken = data.AccessToken;
oldData.Patreon.RefreshToken = data.RefreshToken;
});
var db = _redis.GetDatabase();
await db.StringSetAsync($"{creds.RedisKey()}_patreon_update", DateTime.UtcNow.ToBinary());
return true;
}
catch (Exception ex)
{
Log.Error("Failed updating patreon access token: {ErrorMessage}", ex.ToString());
return false;
}
}
private bool HasPatreonCreds(IBotCredentials creds)
{
var cid = creds.Patreon.ClientId;
var cs = creds.Patreon.ClientSecret;
var rt = creds.Patreon.RefreshToken;
return !(string.IsNullOrWhiteSpace(cid) || string.IsNullOrWhiteSpace(cs) || string.IsNullOrWhiteSpace(rt));
}
public async Task RefreshPledges(IBotCredentials creds)
{
if (DateTime.UtcNow.Day < 5)
return;
if (string.IsNullOrWhiteSpace(creds.Patreon.CampaignId))
return;
var lastUpdate = LastAccessTokenUpdate(creds);
var now = DateTime.UtcNow;
if (lastUpdate.Year != now.Year
|| lastUpdate.Month != now.Month
|| string.IsNullOrWhiteSpace(creds.Patreon.AccessToken))
{
// if the user has the necessary patreon creds
// and the access token expired or doesn't exist
// -> update access token
if (!HasPatreonCreds(creds))
return;
var success = await UpdateAccessToken(creds);
if (!success)
return;
}
LastUpdate = DateTime.UtcNow;
try
{
var members = new List<PatreonMember>();
var users = new List<PatreonUser>();
using (var http = _httpFactory.CreateClient())
{
http.DefaultRequestHeaders.Clear();
http.DefaultRequestHeaders.TryAddWithoutValidation("Authorization",
$"Bearer {creds.Patreon.AccessToken}");
var page = $"https://www.patreon.com/api/oauth2/v2/campaigns/{creds.Patreon.CampaignId}/members"
+ "?fields%5Bmember%5D=full_name,currently_entitled_amount_cents"
+ "&fields%5Buser%5D=social_connections"
+ "&include=user";
PatreonResponse data;
do
{
var res = await http.GetStringAsync(page);
data = JsonSerializer.Deserialize<PatreonResponse>(res);
if (data is null)
break;
members.AddRange(data.Data);
users.AddRange(data.Included);
} while (!string.IsNullOrWhiteSpace(page = data.Links?.Next));
}
var userData = members.Join(users,
m => m.Relationships.User.Data.Id,
u => u.Id,
(m, u) => new
{
PatreonUserId = m.Relationships.User.Data.Id,
UserId = ulong.TryParse(
u.Attributes?.SocialConnections?.Discord?.UserId ?? string.Empty,
out var userId)
? userId
: 0,
EntitledTo = m.Attributes.CurrentlyEntitledAmountCents
})
.Where(x => x is
{
UserId: not 0,
EntitledTo: > 0
})
.ToList();
foreach (var pledge in userData)
await ClaimReward(pledge.UserId, pledge.PatreonUserId, pledge.EntitledTo);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
Log.Warning("Patreon credentials invalid or expired. I will try to refresh them during the next run");
var db = _redis.GetDatabase();
await db.KeyDeleteAsync($"{creds.RedisKey()}_patreon_update");
}
catch (Exception ex)
{
Log.Warning(ex, "Error refreshing patreon pledges");
}
}
public async Task<int> ClaimReward(ulong userId, string patreonUserId, int cents)
{
await _claimLockJustInCase.WaitAsync();
var settings = _gamblingConfigService.Data;
var now = DateTime.UtcNow;
try
{
var eligibleFor = (int)(cents * settings.PatreonCurrencyPerCent);
await using var uow = _db.GetDbContext();
var users = uow.Set<RewardedUser>();
var usr = await users.FirstOrDefaultAsyncEF(x => x.PatreonUserId == patreonUserId);
if (usr is null)
{
users.Add(new()
{
PatreonUserId = patreonUserId,
LastReward = now,
AmountRewardedThisMonth = eligibleFor
});
await uow.SaveChangesAsync();
await _currency.AddAsync(userId, eligibleFor, new("patreon", "new"));
Log.Information("Sending new currency reward to {UserId}", userId);
await SendMessageToUser(userId,
"Thank you for your pledge! " + $"You've been awarded **{eligibleFor}**{settings.Currency.Sign} !");
return eligibleFor;
}
if (usr.LastReward.Month != now.Month)
{
usr.LastReward = now;
usr.AmountRewardedThisMonth = eligibleFor;
await uow.SaveChangesAsync();
await _currency.AddAsync(userId, eligibleFor, new("patreon", "recurring"));
Log.Information("Sending recurring currency reward to {UserId}", userId);
await SendMessageToUser(userId,
"Thank you for your continued support! "
+ $"You've been awarded **{eligibleFor}**{settings.Currency.Sign} for this month's support!");
return eligibleFor;
}
if (usr.AmountRewardedThisMonth < eligibleFor)
{
var toAward = eligibleFor - usr.AmountRewardedThisMonth;
usr.LastReward = now;
usr.AmountRewardedThisMonth = eligibleFor;
await uow.SaveChangesAsync();
await _currency.AddAsync(userId, toAward, new("patreon", "update"));
Log.Information("Sending updated currency reward to {UserId}", userId);
await SendMessageToUser(userId,
"Thank you for increasing your pledge! "
+ $"You've been awarded an additional **{toAward}**{settings.Currency.Sign} !");
return toAward;
}
return 0;
}
finally
{
_claimLockJustInCase.Release();
}
}
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;
await user.SendConfirmAsync(_eb, message);
}
catch
{
// ignored
}
}
}

View File

@@ -0,0 +1,37 @@
using NadekoBot.Common.Yml;
using Cloneable;
namespace NadekoBot.Modules.Utility.Patronage;
[Cloneable]
public partial class PatronConfigData : ICloneable<PatronConfigData>
{
[Comment("DO NOT CHANGE")]
public int Version { get; set; } = 1;
[Comment("Whether the patronage feature is enabled")]
public bool IsEnabled { get; set; }
[Comment("List of patron only features and relevant quota data")]
public FeatureQuotas Quotas { get; set; }
public PatronConfigData()
{
Quotas = new();
}
public class FeatureQuotas
{
[Comment("Dictionary of feature names with their respective limits. Set to null for unlimited")]
public Dictionary<string, Dictionary<PatronTier, int?>> Features { get; set; } = new();
[Comment("Dictionary of commands with their respective quota data")]
public Dictionary<string, Dictionary<PatronTier, Dictionary<QuotaPer, uint>?>> Commands { get; set; } = new();
[Comment("Dictionary of groups with their respective quota data")]
public Dictionary<string, Dictionary<PatronTier, Dictionary<QuotaPer, uint>?>> Groups { get; set; } = new();
[Comment("Dictionary of modules with their respective quota data")]
public Dictionary<string, Dictionary<PatronTier, Dictionary<QuotaPer, uint>?>> Modules { get; set; } = new();
}
}

View File

@@ -0,0 +1,14 @@
// ReSharper disable InconsistentNaming
namespace NadekoBot.Modules.Utility.Patronage;
public enum PatronTier
{
None,
I,
V,
X,
XX,
L,
C,
ComingSoon
}

View File

@@ -0,0 +1,18 @@
using NadekoBot.Common.Configs;
namespace NadekoBot.Modules.Utility.Patronage;
public class PatronageConfig : ConfigServiceBase<PatronConfigData>
{
public override string Name
=> "patron";
private static readonly TypedKey<PatronConfigData> _changeKey
= new TypedKey<PatronConfigData>("config.patron.updated");
private const string FILE_PATH = "data/patron.yml";
public PatronageConfig(IConfigSeria serializer, IPubSub pubSub) : base(FILE_PATH, serializer, pubSub, _changeKey)
{
}
}

View File

@@ -0,0 +1,8 @@
namespace NadekoBot.Modules.Utility.Patronage;
public enum QuotaPer
{
PerHour,
PerDay,
PerMonth,
}

View File

@@ -0,0 +1,190 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using NadekoBot.Modules.Utility.Patronage;
using NadekoBot.Modules.Gambling.Bank;
using NadekoBot.Modules.Gambling.Services;
using NadekoBot.Services.Currency;
using NadekoBot.Services.Database.Models;
namespace NadekoBot.Modules.Utility;
public class CurrencyRewardService : INService, IDisposable
{
private readonly ICurrencyService _cs;
private readonly IPatronageService _ps;
private readonly DbService _db;
private readonly IBankService _bs;
private readonly IEmbedBuilderService _eb;
private readonly GamblingConfigService _config;
private readonly DiscordSocketClient _client;
public CurrencyRewardService(
ICurrencyService cs,
IPatronageService ps,
DbService db,
IBankService bs,
IEmbedBuilderService eb,
GamblingConfigService config,
DiscordSocketClient client)
{
_cs = cs;
_ps = ps;
_db = db;
_bs = bs;
_eb = eb;
_config = config;
_client = client;
_ps.OnNewPatronPayment += OnNewPayment;
_ps.OnPatronRefunded += OnPatronRefund;
_ps.OnPatronUpdated += OnPatronUpdate;
}
public void Dispose()
{
_ps.OnNewPatronPayment -= OnNewPayment;
_ps.OnPatronRefunded -= OnPatronRefund;
_ps.OnPatronUpdated -= OnPatronUpdate;
}
private async Task OnPatronUpdate(Patron oldPatron, Patron newPatron)
{
if (oldPatron.Amount != newPatron.Amount)
{
var conf = _config.Data;
var newAmount = (long)(Math.Max(newPatron.Amount, oldPatron.Amount) * conf.PatreonCurrencyPerCent);
UpdateOutput<RewardedUser>[] output;
await using (var ctx = _db.GetDbContext())
{
output = await ctx.GetTable<RewardedUser>()
.Where(x => x.PlatformUserId == newPatron.UnqiuePlatformUserId)
.UpdateWithOutputAsync(old => new()
{
PlatformUserId = newPatron.UnqiuePlatformUserId,
UserId = newPatron.UserId,
// amount before bonuses
AmountRewardedThisMonth = newAmount,
LastReward = newPatron.PaidAt
});
}
// if the user wasn't previously in the db for some reason,
// we will treat him as a new patron
if (output.Length == 0)
{
await OnNewPayment(newPatron);
return;
}
var oldAmount = output[0].Deleted.AmountRewardedThisMonth;
var diff = newAmount - oldAmount;
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%
var realAmount = GetRealCurrencyReward(newPatron.Amount, diff, out var percentBonus);
await _cs.AddAsync(newPatron.UserId, realAmount, new TxData("patron","update"));
_ = SendMessageToUser(newPatron.UserId,
$"You've received an additional **{realAmount}**{_config.Data.Currency.Sign} as a currency reward (+{percentBonus}%)!");
}
}
private long GetRealCurrencyReward(int fullPledge, long currentAmount, out int percentBonus)
{
// needs at least 5$ to be eligible for a bonus
if (fullPledge < 500)
{
percentBonus = 0;
return currentAmount;
}
var dollarValue = fullPledge / 100;
percentBonus = dollarValue switch
{
> 100 => 100,
_ => dollarValue
};
return (long)(currentAmount * (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.UnqiuePlatformUserId,
UserId = patron.UserId,
AmountRewardedThisMonth = amount,
LastReward = patron.PaidAt,
},
old => new()
{
AmountRewardedThisMonth = amount,
UserId = patron.UserId,
LastReward = patron.PaidAt
},
() => new()
{
PlatformUserId = patron.UnqiuePlatformUserId
});
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 = _eb.Create()
.WithOkColor()
.WithDescription(message);
await user.EmbedAsync(eb);
}
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>()
.UpdateWithOutputAsync(old => new()
{
AmountRewardedThisMonth = old.AmountRewardedThisMonth * 2
});
// var toTake = old.Length == 0
// ? patron.Amount
// : old[0].Inserted.AmountRewardedThisMonth;
// if (toTake > 0)
// {
// Log.Warning("Wiping the wallet and bank of the user {UserId} due to a refund/fraud...",
// patron.UserId);
// await _cs.RemoveAsync(patron.UserId, patron.Amount, new("patreon", "refund"));
// await _bs.BurnAllAsync(patron.UserId);
// Log.Warning("Burned {Amount} currency from the bank of the user {UserId} due to a refund/fraud.",
// patron.Amount,
// patron.UserId);
// }
}
}

View File

@@ -0,0 +1,8 @@
namespace NadekoBot.Modules.Utility.Patronage;
public readonly struct FeatureQuotaStats
{
public (uint Cur, uint Max) Hourly { get; init; }
public (uint Cur, uint Max) Daily { get; init; }
public (uint Cur, uint Max) Monthly { get; init; }
}

View File

@@ -0,0 +1,11 @@
using NadekoBot.Db.Models;
namespace NadekoBot.Modules.Utility.Patronage;
public readonly struct InsufficientTier
{
public FeatureType FeatureType { get; init; }
public string Feature { get; init; }
public PatronTier RequiredTier { get; init; }
public PatronTier UserTier { get; init; }
}

View File

@@ -0,0 +1,150 @@
#nullable disable
using NadekoBot.Modules.Utility.Common.Patreon;
using OneOf;
using OneOf.Types;
using System.Net.Http.Json;
using System.Text.Json;
namespace NadekoBot.Modules.Utility;
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));
}
}

View File

@@ -0,0 +1,10 @@
#nullable disable
namespace NadekoBot.Modules.Utility;
public readonly struct PatreonCredentials
{
public string ClientId { get; init; }
public string ClientSecret { get; init; }
public string AccessToken { get; init; }
public string RefreshToken { get; init; }
}

View File

@@ -0,0 +1,33 @@
#nullable disable
namespace NadekoBot.Modules.Utility;
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
{
}

View File

@@ -0,0 +1,81 @@
#nullable disable
using NadekoBot.Modules.Utility.Patronage;
namespace NadekoBot.Modules.Utility;
/// <summary>
/// Service tasked with handling pledges on patreon
/// </summary>
public sealed class PatreonSubscriptionHandler : ISubscriptionHandler, INService
{
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;
}
}
}

View File

@@ -0,0 +1,38 @@
namespace NadekoBot.Modules.Utility.Patronage;
public readonly struct Patron
{
/// <summary>
/// Unique id assigned to this patron by the payment platform
/// </summary>
public string UnqiuePlatformUserId { get; init; }
/// <summary>
/// Discord UserId to which this <see cref="UnqiuePlatformUserId"/> is connected to
/// </summary>
public ulong UserId { get; init; }
/// <summary>
/// Amount the Patron is currently pledging or paid
/// </summary>
public int Amount { get; init; }
/// <summary>
/// Current Tier of the patron
/// (do not question it in consumer classes, as the calculation should be always internal and may change)
/// </summary>
public PatronTier Tier { get; init; }
/// <summary>
/// When was the last time this <see cref="Amount"/> was paid
/// </summary>
public DateTime PaidAt { get; init; }
/// <summary>
/// After which date does the user's Patronage benefit end
/// </summary>
public DateTime ValidThru { get; init; }
public bool IsActive
=> !ValidThru.IsBeforeToday();
}

View File

@@ -0,0 +1,39 @@
namespace NadekoBot.Modules.Utility.Patronage;
public static class PatronExtensions
{
public static DateOnly ToDateOnly(this DateTime dateTime)
=> DateOnly.FromDateTime(dateTime);
public static bool IsBeforeToday(this DateTime date)
=> date < DateTime.UtcNow.Date;
public static string ToFullName(this PatronTier tier)
=> tier switch
{
_ => $"Patron Tier {tier}",
};
public static string ToFullName(this QuotaPer per)
=> per.Humanize(LetterCasing.LowerCase);
public static DateTime DayOfNextMonth(this DateTime date, int day)
{
var nextMonth = date.AddMonths(1);
var dt = DateTime.SpecifyKind(new(nextMonth.Year, nextMonth.Month, day), DateTimeKind.Utc);
return dt;
}
public static DateTime FirstOfNextMonth(this DateTime date)
=> date.DayOfNextMonth(1);
public static DateTime SecondOfNextMonth(this DateTime date)
=> date.DayOfNextMonth(2);
public static string ToShortAndRelativeTimestampTag(this DateTime date)
{
var fullResetStr = TimestampTag.FromDateTime(date, TimestampTagStyles.ShortDateTime);
var relativeResetStr = TimestampTag.FromDateTime(date, TimestampTagStyles.Relative);
return $"{fullResetStr}\n{relativeResetStr}";
}
}

View File

@@ -0,0 +1,153 @@
using NadekoBot.Modules.Utility.Patronage;
namespace NadekoBot.Modules.Utility;
public partial class Utility
{
[OnlyPublicBot]
public partial class PatronageCommands : NadekoModule
{
private readonly PatronageService _service;
private readonly PatronageConfig _pConf;
public PatronageCommands(PatronageService service, PatronageConfig pConf)
{
_service = service;
_pConf = pConf;
}
[Cmd]
[Priority(2)]
public partial Task Patron()
=> InternalPatron(ctx.User);
[Cmd]
[Priority(0)]
[OwnerOnly]
public partial Task Patron(IUser user)
=> InternalPatron(user);
[Cmd]
[Priority(0)]
[OwnerOnly]
public async partial 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 partial 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
? "✅"
: "⚠️";
}
}

View File

@@ -0,0 +1,834 @@
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db.Models;
using OneOf;
using StackExchange.Redis;
using CommandInfo = Discord.Commands.CommandInfo;
namespace NadekoBot.Modules.Utility.Patronage;
/// <inheritdoc cref="IPatronageService"/>
public sealed class PatronageService
: IPatronageService,
IReadyExecutor,
IExecPreCommand,
INService
{
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 readonly ConnectionMultiplexer _redis;
private readonly IBotCredentials _creds;
private readonly TypedKey<bool> _quotaKey;
public PatronageService(
PatronageConfig pConf,
DbService db,
DiscordSocketClient client,
ISubscriptionHandler subsHandler,
IEmbedBuilderService eb,
ConnectionMultiplexer redis,
IBotCredentials creds)
{
_pConf = pConf;
_db = db;
_client = client;
_subsHandler = subsHandler;
_eb = eb;
_redis = redis;
_creds = creds;
_quotaKey = new TypedKey<bool>($"{_creds.RedisKey()}:quota:last_hourly_reset");
}
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 rdb = _redis.GetDatabase();
var lastVal = await rdb.StringGetAsync(_quotaKey.Key);
if (lastVal != default)
{
lastRun = DateTime.FromBinary((long)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,
});
await rdb.StringSetAsync(_quotaKey.Key, true);
}
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 rdb.StringSetAsync(_quotaKey.Key, 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)
{
// 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)
{
// the user updated the pledge or changed the connected discord account
var newData = await ctx.GetTable<PatronUser>()
.Where(x => x.UniquePlatformUserId == subscriber.UniquePlatformUserId
&& x.LastCharge < lastChargeUtc)
.UpdateWithOutputAsync(old => new()
{
UserId = subscriber.UserId,
AmountCents = subscriber.Cents,
LastCharge = lastChargeUtc,
ValidThru = old.ValidThru,
});
await tran.CommitAsync();
// this should never happen
if (newData.Length == 0)
continue;
await OnPatronUpdated(PatronUserToPatron(dbPatron), PatronUserToPatron(newData[0].Inserted));
}
}
}
catch (Exception ex)
{
Log.Error(ex,
"Unexpected error occured while processing rewards for patron {UserId}",
subscriber.UserId);
}
}
foreach (var patron in subscribers.Where(x => x.ChargeStatus == SubscriptionChargeStatus.Refunded))
{
var expiredDate = DateTime.MinValue;
// if the subscription is refunded, Disable user's valid thru
var output = await ctx.GetTable<PatronUser>()
.Where(x => x.UniquePlatformUserId == patron.UniquePlatformUserId
&& x.ValidThru != expiredDate)
.UpdateWithOutputAsync(old => new()
{
ValidThru = expiredDate
});
if (output.Length == 0)
continue;
await OnPatronRefunded(PatronUserToPatron(output[0].Inserted));
}
}
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.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(_ => default, 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,
};
}
// should i allow users to pay extra for more quota?
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 = default,
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 = defaultValue,
IsPatronLimit = false,
};
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()
{
UnqiuePlatformUserId = 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
{
>= 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.UnqiuePlatformUserId}");
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;
}
public readonly struct FeatureLimitKey
{
public string PrettyName { get; init; }
public string Key { get; init; }
}

View File

@@ -0,0 +1,66 @@
using NadekoBot.Db.Models;
namespace NadekoBot.Modules.Utility.Patronage;
/// <summary>
/// Represents information about why the user has triggered a quota limit
/// </summary>
public readonly struct QuotaLimit
{
/// <summary>
/// Amount of usages reached, which is the limit
/// </summary>
public uint Quota { get; init; }
/// <summary>
/// Which period is this quota limit for (hourly, daily, monthly, etc...)
/// </summary>
public QuotaPer QuotaPeriod { get; init; }
/// <summary>
/// When does this quota limit reset
/// </summary>
public DateTime ResetsAt { get; init; }
/// <summary>
/// Type of the feature this quota limit is for
/// </summary>
public FeatureType FeatureType { get; init; }
/// <summary>
/// Name of the feature this quota limit is for
/// </summary>
public string Feature { get; init; }
/// <summary>
/// Whether it is the user's own quota (true), or server owners (false)
/// </summary>
public bool IsOwnQuota { get; init; }
}
/// <summary>
/// Respresent information about the feature limit
/// </summary>
public readonly struct FeatureLimit
{
/// <summary>
/// Whether this limit comes from the patronage system
/// </summary>
public bool IsPatronLimit { get; init; } = false;
/// <summary>
/// Maximum limit allowed
/// </summary>
public int? Quota { get; init; } = null;
/// <summary>
/// Name of the limit
/// </summary>
public string Name { get; init; } = string.Empty;
public FeatureLimit()
{
}
}

View File

@@ -0,0 +1,10 @@
#nullable disable
namespace NadekoBot.Modules.Utility;
public enum SubscriptionChargeStatus
{
Paid,
Refunded,
Unpaid,
Other,
}

View File

@@ -0,0 +1,25 @@
namespace NadekoBot.Modules.Utility.Patronage;
public readonly struct UserQuotaStats
{
private static readonly IReadOnlyDictionary<string, FeatureQuotaStats> _emptyDictionary
= new Dictionary<string, FeatureQuotaStats>();
public PatronTier Tier { get; init; }
= PatronTier.None;
public IReadOnlyDictionary<string, FeatureQuotaStats> Features { get; init; }
= _emptyDictionary;
public IReadOnlyDictionary<string, FeatureQuotaStats> Commands { get; init; }
= _emptyDictionary;
public IReadOnlyDictionary<string, FeatureQuotaStats> Groups { get; init; }
= _emptyDictionary;
public IReadOnlyDictionary<string, FeatureQuotaStats> Modules { get; init; }
= _emptyDictionary;
public UserQuotaStats()
{
}
}

View File

@@ -0,0 +1,11 @@
namespace NadekoBot.Modules.Utility;
public interface ISubscriberData
{
public string UniquePlatformUserId { get; }
public ulong UserId { get; }
public int Cents { get; }
public DateTime? LastCharge { get; }
public SubscriptionChargeStatus ChargeStatus { get; }
}

View File

@@ -0,0 +1,56 @@
using NadekoBot.Db.Models;
using OneOf;
namespace NadekoBot.Modules.Utility.Patronage;
/// <summary>
/// Manages patrons and provides access to their data
/// </summary>
public interface IPatronageService
{
/// <summary>
/// Called when the payment is made.
/// Either as a single payment for that patron,
/// or as a recurring monthly donation.
/// </summary>
public event Func<Patron, Task> OnNewPatronPayment;
/// <summary>
/// Called when the patron changes the pledge amount
/// (Patron old, Patron new) => Task
/// </summary>
public event Func<Patron, Patron, Task> OnPatronUpdated;
/// <summary>
/// Called when the patron refunds the purchase or it's marked as fraud
/// </summary>
public event Func<Patron, Task> OnPatronRefunded;
/// <summary>
/// Gets a Patron with the specified userId
/// </summary>
/// <param name="userId">UserId for which to get the patron data for.</param>
/// <returns>A patron with the specifeid userId</returns>
public Task<Patron> GetPatronAsync(ulong userId);
/// <summary>
/// Gets the quota statistic for the user/patron specified by the userId
/// </summary>
/// <param name="userId">UserId of the user for which to get the quota statistic for</param>
/// <returns>Quota stats for the specified user</returns>
Task<UserQuotaStats> GetUserQuotaStatistic(ulong userId);
Task<FeatureLimit> TryGetFeatureLimitAsync(FeatureLimitKey key, ulong userId, int? defaultValue);
ValueTask<OneOf<(uint Hourly, uint Daily, uint Monthly), QuotaLimit>> TryIncrementQuotaCounterAsync(
ulong userId,
bool isSelf,
FeatureType featureType,
string featureName,
uint? maybeHourly,
uint? maybeDaily,
uint? maybeMonthly);
PatronConfigData GetConfig();
}

View File

@@ -0,0 +1,16 @@
#nullable disable
namespace NadekoBot.Modules.Utility;
/// <summary>
/// Services implementing this interface are handling pledges/subscriptions/payments coming
/// from a payment platform.
/// </summary>
public interface ISubscriptionHandler
{
/// <summary>
/// Get Current patrons in batches.
/// This will only return patrons who have their discord account connected
/// </summary>
/// <returns>Batched patrons</returns>
public IAsyncEnumerable<IReadOnlyCollection<ISubscriberData>> GetPatronsAsync();
}

View File

@@ -76,15 +76,14 @@ public class RemindService : INService, IReadyExecutor
await uow.SaveChangesAsync();
}
// todo move isonshard to a method
private async Task<List<Reminder>> GetRemindersBeforeAsync(DateTime now)
{
await using var uow = _db.GetDbContext();
return await uow.Reminders
.ToLinqToDBTable()
.Where(x => x.ServerId / 4194304 % (ulong)_creds.TotalShards == (ulong)_client.ShardId
&& x.When < now)
.ToListAsyncLinqToDB();
.ToLinqToDBTable()
.Where(x => Linq2DbExpressions.GuildOnShard(x.ServerId, _creds.TotalShards, _client.ShardId)
&& x.When < now)
.ToListAsyncLinqToDB();
}
public bool TryParseRemindMessage(string input, out RemindObject obj)

View File

@@ -465,9 +465,6 @@ public partial class Utility : NadekoModule
}
[Cmd]
#if GLOBAL_NADEKO
[Ratelimit(30)]
#endif
public async partial Task Ping()
{
await sem.WaitAsync(5000);

View File

@@ -6,7 +6,7 @@ namespace NadekoBot.Modules.Utility.Services;
public class VerboseErrorsService : INService
{
private readonly ConcurrentHashSet<ulong> _guildsEnabled;
private readonly ConcurrentHashSet<ulong> _guildsDisabled;
private readonly DbService _db;
private readonly CommandHandler _ch;
private readonly HelpService _hs;
@@ -23,12 +23,12 @@ public class VerboseErrorsService : INService
_ch.CommandErrored += LogVerboseError;
_guildsEnabled = new(bot.AllGuildConfigs.Where(x => x.VerboseErrors).Select(x => x.GuildId));
_guildsDisabled = new(bot.AllGuildConfigs.Where(x => !x.VerboseErrors).Select(x => x.GuildId));
}
private async Task LogVerboseError(CommandInfo cmd, ITextChannel channel, string reason)
{
if (channel is null || !_guildsEnabled.Contains(channel.GuildId))
if (channel is null || _guildsDisabled.Contains(channel.GuildId))
return;
try
@@ -36,35 +36,35 @@ public class VerboseErrorsService : INService
var embed = _hs.GetCommandHelp(cmd, channel.Guild)
.WithTitle("Command Error")
.WithDescription(reason)
.WithFooter("Admin may disable verbose errors via `.ve` command")
.WithErrorColor();
await channel.EmbedAsync(embed);
}
catch
{
//ignore
Log.Information("Verbose error wasn't able to be sent to the server: {GuildId}",
channel.GuildId);
}
}
public bool ToggleVerboseErrors(ulong guildId, bool? enabled = null)
public bool ToggleVerboseErrors(ulong guildId, bool? maybeEnabled = null)
{
using (var uow = _db.GetDbContext())
{
var gc = uow.GuildConfigsForId(guildId, set => set);
using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId, set => set);
if (enabled == null)
enabled = gc.VerboseErrors = !gc.VerboseErrors; // Old behaviour, now behind a condition
else
gc.VerboseErrors = (bool)enabled; // New behaviour, just set it.
if (maybeEnabled is bool isEnabled) // set it
gc.VerboseErrors = isEnabled;
else // toggle it
isEnabled = gc.VerboseErrors = !gc.VerboseErrors;
uow.SaveChanges();
}
uow.SaveChanges();
if ((bool)enabled) // This doesn't need to be duplicated inside the using block
_guildsEnabled.Add(guildId);
if (isEnabled) // This doesn't need to be duplicated inside the using block
_guildsDisabled.TryRemove(guildId);
else
_guildsEnabled.TryRemove(guildId);
_guildsDisabled.Add(guildId);
return (bool)enabled;
return isEnabled;
}
}

View File

@@ -12,7 +12,7 @@ public sealed class Attributes
public bool IsFollower { get; set; }
[JsonPropertyName("last_charge_date")]
public DateTime LastChargeDate { get; set; }
public DateTime? LastChargeDate { get; set; }
[JsonPropertyName("last_charge_status")]
public string LastChargeStatus { get; set; }
@@ -57,7 +57,7 @@ public sealed class Address
// // public CurrentlyEntitledTiers CurrentlyEntitledTiers { get; set; }
// }
public sealed class PatreonResponse
public sealed class PatreonMembersResponse
{
[JsonPropertyName("data")]
public List<PatreonMember> Data { get; set; }