Files
nadekobot/src/NadekoBot/Modules/Utility/Services/PatreonRewardsService.cs
2022-01-07 06:28:58 +01:00

336 lines
13 KiB
C#

using Discord.WebSocket;
using NadekoBot.Services;
using NadekoBot.Services.Database.Models;
using NadekoBot.Modules.Utility.Common.Patreon;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Discord;
using NadekoBot.Modules.Gambling.Services;
using NadekoBot.Extensions;
using Serilog;
using StackExchange.Redis;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace NadekoBot.Modules.Utility.Services
{
public class PatreonRewardsService : INService
{
private readonly SemaphoreSlim getPledgesLocker = new SemaphoreSlim(1, 1);
private readonly Timer _updater;
private readonly SemaphoreSlim claimLockJustInCase = new SemaphoreSlim(1, 1);
public TimeSpan Interval { get; } = TimeSpan.FromMinutes(3);
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 DateTime LastUpdate { get; private set; } = DateTime.UtcNow;
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;
if (client.ShardId == 0)
_updater = new Timer(async _ => await RefreshPledges(_credsProvider.GetCreds()).ConfigureAwait(false),
null, TimeSpan.Zero, Interval);
}
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 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; }
}
private async Task<bool> UpdateAccessToken(IBotCredentials creds)
{
Log.Information("Updating patreon access token...");
try
{
using var http = _httpFactory.CreateClient();
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}",
new StringContent(string.Empty));
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 _1 = creds.Patreon.ClientId;
var _2 = creds.Patreon.ClientSecret;
var _4 = creds.Patreon.RefreshToken;
return !(string.IsNullOrWhiteSpace(_1)
|| string.IsNullOrWhiteSpace(_2)
|| string.IsNullOrWhiteSpace(_4));
}
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;
await getPledgesLocker.WaitAsync().ConfigureAwait(false);
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 = null;
do
{
var res = await http.GetStringAsync(page).ConfigureAwait(false);
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");
}
finally
{
getPledgesLocker.Release();
}
}
public async Task<int> ClaimReward(ulong userId, string patreonUserId, int cents)
{
await claimLockJustInCase.WaitAsync().ConfigureAwait(false);
var settings = _gamblingConfigService.Data;
var now = DateTime.UtcNow;
try
{
var eligibleFor = (int)(cents * settings.PatreonCurrencyPerCent);
using (var uow = _db.GetDbContext())
{
var users = uow.Set<RewardedUser>();
var usr = await users.FirstOrDefaultAsync(x => x.PatreonUserId == patreonUserId);
if (usr is null)
{
users.Add(new RewardedUser()
{
PatreonUserId = patreonUserId,
LastReward = now,
AmountRewardedThisMonth = eligibleFor,
});
await uow.SaveChangesAsync();
await _currency.AddAsync(userId, "Patreon reward - new", eligibleFor, gamble: true);
Log.Information($"Sending new currency reward to {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, "Patreon reward - recurring", eligibleFor, gamble: true);
Log.Information($"Sending recurring currency reward to {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 = toAward;
await uow.SaveChangesAsync();
await _currency.AddAsync(userId, "Patreon reward - update", toAward, gamble: true);
Log.Information($"Sending updated currency reward to {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;
var channel = await user.GetOrCreateDMChannelAsync();
await channel.SendConfirmAsync(_eb, message);
}
catch
{
// ignored
}
}
}
}