diff --git a/TODO.md b/TODO.md
index ba1a6ac..e4f18bf 100644
--- a/TODO.md
+++ b/TODO.md
@@ -4,8 +4,8 @@
- Start and finish Ellie.Bot.Modules.Administration
- Start and finish Ellie.Bot.Modules.Utility
- ~~Finish Ellie.Bot.Modules.Music~~ - Finished
- - Finish Ellie.Bot.Modules.Xp - Started
- - Start and finish Ellie.Bot.Modules.Patronage
+ - ~~Finish Ellie.Bot.Modules.Xp~~ - Finished
+ - ~~Finish Ellie.Bot.Modules.Patronage~~ - Finished
- Start and finish Ellie.Bot.Modules.Help
- Start amd finish Ellie.Bot.Modules.Permissions
- Fix the numerous bugs
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Patronage/Ellie.Bot.Modules.Patronage.csproj b/src/Ellie.Bot.Modules.Patronage/Ellie.Bot.Modules.Patronage.csproj
new file mode 100644
index 0000000..1e442b5
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Patronage/Ellie.Bot.Modules.Patronage.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net7.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Patronage/Patronage/Config/PatronageConfig.cs b/src/Ellie.Bot.Modules.Patronage/Patronage/Config/PatronageConfig.cs
new file mode 100644
index 0000000..9249474
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Patronage/Patronage/Config/PatronageConfig.cs
@@ -0,0 +1,36 @@
+using Ellie.Common.Configs;
+
+namespace Ellie.Modules.Patronage;
+
+public class PatronageConfig : ConfigServiceBase
+{
+ public override string Name
+ => "patron";
+
+ private static readonly TypedKey _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;
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonClient.cs b/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonClient.cs
new file mode 100644
index 0000000..ad1f4bb
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonClient.cs
@@ -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>> 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($"Request did not return a sucess status code. Status code: {res.StatusCode}");
+
+ try
+ {
+ var data = await res.Content.ReadFromJsonAsync();
+
+ if (data is null)
+ return new Error($"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($"Error during deserialization: {ex.Message}");
+ }
+ }
+
+ private async ValueTask 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>, Error>> GetMembersAsync(string campaignId)
+ {
+ if (!await EnsureTokenValidAsync())
+ return new Error("Unable to get patreon token");
+
+ return OneOf>, Error>.FromT0(
+ GetMembersInternalAsync(campaignId));
+ }
+
+ private async IAsyncEnumerable> 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(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));
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonCredentials.cs b/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonCredentials.cs
new file mode 100644
index 0000000..9576612
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonCredentials.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonData.cs b/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonData.cs
new file mode 100644
index 0000000..99c5247
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonData.cs
@@ -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 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 Data { get; set; }
+
+ [JsonPropertyName("included")]
+ public List 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; }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonMemberData.cs b/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonMemberData.cs
new file mode 100644
index 0000000..60471d7
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonMemberData.cs
@@ -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
+{
+
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonRefreshData.cs b/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonRefreshData.cs
new file mode 100644
index 0000000..9a874fb
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonRefreshData.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonSubscriptionHandler.cs b/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonSubscriptionHandler.cs
new file mode 100644
index 0000000..d34f18f
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Patronage/Patronage/Patreon/PatreonSubscriptionHandler.cs
@@ -0,0 +1,79 @@
+#nullable disable
+namespace Ellie.Modules.Patronage;
+
+///
+/// Service tasked with handling pledges on patreon
+///
+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> 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> 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>();
+ });
+ }
+ 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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Patronage/Patronage/PatronageCommands.cs b/src/Ellie.Bot.Modules.Patronage/Patronage/PatronageCommands.cs
new file mode 100644
index 0000000..2527350
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Patronage/Patronage/PatronageCommands.cs
@@ -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 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
+ ? "✅"
+ : "⚠️";
+}
\ No newline at end of file
diff --git a/src/Ellie.Bot.Modules.Patronage/Patronage/PatronageService.cs b/src/Ellie.Bot.Modules.Patronage/Patronage/PatronageService.cs
new file mode 100644
index 0000000..98dfeae
--- /dev/null
+++ b/src/Ellie.Bot.Modules.Patronage/Patronage/PatronageService.cs
@@ -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;
+
+///
+public sealed class PatronageService
+ : IPatronageService,
+ IReadyExecutor,
+ IExecPreCommand,
+ IEService
+{
+ public event Func OnNewPatronPayment = static delegate { return Task.CompletedTask; };
+ public event Func OnPatronUpdated = static delegate { return Task.CompletedTask; };
+ public event Func 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();
+
+ private readonly PatronageConfig _pConf;
+ private readonly DbService _db;
+ private readonly DiscordSocketClient _client;
+ private readonly ISubscriptionHandler _subsHandler;
+ private readonly IEmbedBuilderService _eb;
+ private static readonly TypedKey _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()
+ .TruncateAsync();
+ }
+ else if (nowDate.DayNumber != lastDate.DayNumber)
+ {
+ // day is different, means hour is different.
+ // reset both hourly and daily quota counts.
+ await ctx.GetTable()
+ .UpdateAsync((old) => new()
+ {
+ HourlyCount = 0,
+ DailyCount = 0,
+ });
+ }
+ else if (now.Hour != lastRun.Hour) // if it's not, just reset hourly quotas
+ {
+ await ctx.GetTable()
+ .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 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()
+ .FirstOrDefaultAsync(x
+ => x.UniquePlatformUserId == subscriber.UniquePlatformUserId);
+
+ if (dbPatron is null)
+ {
+ // if the user is not in the database alrady
+ dbPatron = await ctx.GetTable()
+ .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()
+ .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()
+ .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()
+ .Where(x => x.UniquePlatformUserId == patron.UniquePlatformUserId
+ && x.ValidThru != expiredDate)
+ .UpdateAsync(old => new()
+ {
+ ValidThru = expiredDate
+ });
+
+ if (changedCount == 0)
+ continue;
+
+ var updated = await ctx.GetTable()
+ .Where(x => x.UniquePlatformUserId == patron.UniquePlatformUserId)
+ .FirstAsync();
+
+ await OnPatronRefunded(PatronUserToPatron(updated));
+ }
+ }
+
+ public async Task 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> 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;
+ }
+
+ ///
+ /// Returns either the current usage counter if limit wasn't reached, or QuotaLimit if it is.
+ ///
+ public async ValueTask> 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()
+ .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()
+ .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);
+ }
+
+ ///
+ /// Attempts to add 1 to user's quota for the command, group and module.
+ /// Input MUST BE lowercase
+ ///
+ /// Id of the user who is attempting to run the command
+ /// Name of the command the user is trying to run
+ /// Name of the command's group
+ /// Name of the command's top level module
+ /// Whether this is check is for the user himself. False if it's someone else's id (owner)
+ /// Either a succcess (user can run the command) or one of the error values.
+ private async ValueTask> 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>(
+ _ => new Success(),
+ x => x);
+ }
+
+ private bool TryGetTierDataOrLower(
+ IReadOnlyDictionary 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 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()
+ .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 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()
+ .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 GetFeatureQuotaStats(
+ PatronTier patronTier,
+ IReadOnlyDictionary? allQuotasDict,
+ Dictionary?>> commands)
+ {
+ var userCommandQuotaStats = new Dictionary();
+ 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 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 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 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 *
+ *- 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: *
+ """,
+ 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()
+ .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;
+}
\ No newline at end of file