Files
nadekobot/src/NadekoBot/Modules/Utility/Patreon/PatreonRewardsService.cs
Kwoth 5d2d74b92a Full support for embed arrays in .greet/.bye, .say and other commands which use embeds
- Website to create them is live at eb2.nadeko.bot (it will soon be replacing eb.nadeko.bot)
- Embed arrays don't have a plainText property (it's renamed to 'content')
- Embed arrays use color hex values instead of an integer
- Old embed format will still work
- There shouldn't be any breaking changes
2022-04-26 02:33:19 +02:00

305 lines
11 KiB
C#

#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
}
}
}