diff --git a/README.md b/README.md
index d181091..9ad611f 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,22 @@
[](https://ko-fi.com/A3562BQV)
+## Sponsor leaderboard ##
+`/sponsors` shows the leaderboard. Once a day the bot refreshes it from the funding platforms, posts it to a channel and applies sponsor roles.
+
+| Variable | Purpose |
+|---|---|
+| `GITHUB_SPONSORS_TOKEN` | PAT with `read:user`. Without it GitHub Sponsors is skipped |
+| `GITHUB_SPONSORS_LOGIN` | maintainer login, defaults to `0Lucifer0` |
+| `PATREON_ACCESS_TOKEN`, `PATREON_CAMPAIGN_ID` | creator token and campaign. Without them Patreon is skipped |
+| `PATREON_NAMES_PUBLIC` | `true` to show Patreon names. They are anonymised otherwise, because the Patreon API exposes no privacy flag |
+| `SPONSOR_GUILD_ID`, `SPONSOR_CHANNEL_ID` | where the report is posted and roles are applied |
+| `SPONSOR_REPORT_HOUR` | UTC hour of the daily post, defaults to 9 |
+
+The `Supporter`, `Gold Supporter`, `Legendary Supporter` and `Top Sponsor` roles are created on the first sync โ the bot's own role has to sit above them for it to assign them. A sponsor only gets a role once someone links their accounts with `/link-sponsor github `, since no funding platform exposes a sponsor's Discord identity.
+
+Sponsors who chose to stay private are listed as `Anonymous` with their amount. GitHub lifetime totals are estimated from tier price and start date, which is all its API exposes; Patreon reports exact lifetime totals. Ko-fi is not included โ it has no read API, only webhooks.
+
## Warning! ##
We are not responsible of any damages caused by bad usage of our source. Please before asking questions or installing this source read this readme and also do a research, google is your friend. If you mess up when installing our source because you didnt follow it, we will laugh at you. A lot.
diff --git a/src/NosCoreBot/Modules/SponsorModule.cs b/src/NosCoreBot/Modules/SponsorModule.cs
new file mode 100644
index 0000000..e86f28d
--- /dev/null
+++ b/src/NosCoreBot/Modules/SponsorModule.cs
@@ -0,0 +1,78 @@
+using System.Linq;
+using System.Threading.Tasks;
+using Discord;
+using Discord.Commands;
+using Discord.WebSocket;
+using NosCoreBot.Services;
+
+namespace NosCoreBot.Modules;
+
+public class SponsorModule : ModuleBase
+{
+ private readonly SponsorService _sponsors;
+ private readonly SponsorSyncService _sync;
+
+ public SponsorModule(SponsorService sponsors, SponsorSyncService sync)
+ {
+ _sponsors = sponsors;
+ _sync = sync;
+ }
+
+ [Command("sponsors")]
+ [Name("sponsors")]
+ [Summary("show the sponsor leaderboard")]
+ public async Task ShowSponsors()
+ {
+ var state = await _sponsors.LoadStateAsync();
+ await ReplyAsync("", false, SponsorSyncService.BuildEmbed(state.Snapshot));
+ }
+
+ [Command("link-sponsor")]
+ [Name("link-sponsor ")]
+ [Summary("link a discord member to a sponsor account so they get their role")]
+ [RequireUserPermission(GuildPermission.ManageRoles)]
+ public async Task LinkSponsor(SocketGuildUser user, string platform, string id)
+ {
+ var key = $"{platform.ToLowerInvariant()}:{id}";
+ await _sponsors.MutateAsync(state => state.Links[key] = user.Id);
+ await ReplyAsync($"Linked {user.Mention} to `{key}`. Roles apply on the next sync.");
+ }
+
+ [Command("unlink-sponsor")]
+ [Name("unlink-sponsor ")]
+ [Summary("remove a sponsor account link")]
+ [RequireUserPermission(GuildPermission.ManageRoles)]
+ public async Task UnlinkSponsor(string platform, string id)
+ {
+ var key = $"{platform.ToLowerInvariant()}:{id}";
+ var removed = false;
+ await _sponsors.MutateAsync(state => removed = state.Links.Remove(key));
+ await ReplyAsync(removed ? $"Unlinked `{key}`." : $"No link for `{key}`.");
+ }
+
+ [Command("sponsor-sync")]
+ [Name("sponsor-sync")]
+ [Summary("refresh sponsors, post the leaderboard and apply roles now")]
+ [RequireUserPermission(GuildPermission.ManageRoles)]
+ public async Task SyncNow()
+ {
+ var state = await _sync.RunAsync(_ => true);
+ await ReplyAsync($"Synced {state.Snapshot.Count} sponsors.");
+ }
+
+ [Command("sponsor-links")]
+ [Name("sponsor-links")]
+ [Summary("list the sponsor account links")]
+ [RequireUserPermission(GuildPermission.ManageRoles)]
+ public async Task ShowLinks()
+ {
+ var state = await _sponsors.LoadStateAsync();
+ if (state.Links.Count == 0)
+ {
+ await ReplyAsync("No sponsor links yet.");
+ return;
+ }
+
+ await ReplyAsync(string.Join('\n', state.Links.Select(link => $"`{link.Key}` -> <@{link.Value}>")));
+ }
+}
diff --git a/src/NosCoreBot/Program.cs b/src/NosCoreBot/Program.cs
index 9e89960..d1cd2a6 100644
--- a/src/NosCoreBot/Program.cs
+++ b/src/NosCoreBot/Program.cs
@@ -38,6 +38,8 @@ public static IHostBuilder CreateHostBuilder(string[] args)
.AddSingleton()
.AddSingleton()
.AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
.AddSingleton()
.BuildServiceProvider();
services.AddHostedService();
diff --git a/src/NosCoreBot/Services/SponsorService.cs b/src/NosCoreBot/Services/SponsorService.cs
new file mode 100644
index 0000000..cefffec
--- /dev/null
+++ b/src/NosCoreBot/Services/SponsorService.cs
@@ -0,0 +1,325 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon;
+using Amazon.Runtime;
+using Amazon.S3;
+using Amazon.S3.Model;
+using Microsoft.Extensions.Logging;
+
+namespace NosCoreBot.Services;
+
+public record SponsorEntry(string Platform, string Id, string DisplayName, bool IsPublic, bool IsActive,
+ int MonthlyCents, int LifetimeCents)
+{
+ public string Key => $"{Platform}:{Id}";
+}
+
+public class SponsorState
+{
+ public Dictionary Links { get; set; } = new();
+
+ public Dictionary Lifetime { get; set; } = new();
+
+ public List Snapshot { get; set; } = new();
+
+ public DateTime? LastReportUtc { get; set; }
+
+ public DateTime UpdatedAt { get; set; }
+}
+
+public class SponsorService
+{
+ private const string StateKey = "sponsors.json";
+
+ private const string SponsorsQuery = """
+ query($login: String!, $cursor: String) {
+ user(login: $login) {
+ sponsorshipsAsMaintainer(first: 100, after: $cursor, includePrivate: true, activeOnly: false) {
+ pageInfo { hasNextPage endCursor }
+ nodes {
+ isActive
+ privacyLevel
+ createdAt
+ isOneTimePayment
+ tier { monthlyPriceInCents }
+ sponsorEntity {
+ __typename
+ ... on User { login name }
+ ... on Organization { login name }
+ }
+ }
+ }
+ }
+ }
+ """;
+
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+ private readonly SemaphoreSlim _stateLock = new(1, 1);
+
+ public SponsorService(HttpClient httpClient, ILogger logger)
+ {
+ _httpClient = httpClient;
+ _logger = logger;
+ }
+
+ public async Task> FetchAsync()
+ {
+ var entries = new List();
+ entries.AddRange(await FetchGitHubAsync());
+ entries.AddRange(await FetchPatreonAsync());
+ return entries;
+ }
+
+ private async Task> FetchGitHubAsync()
+ {
+ var token = Environment.GetEnvironmentVariable("GITHUB_SPONSORS_TOKEN");
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ _logger.LogInformation("GITHUB_SPONSORS_TOKEN is unset, skipping GitHub Sponsors");
+ return new List();
+ }
+
+ var login = Environment.GetEnvironmentVariable("GITHUB_SPONSORS_LOGIN") ?? "0Lucifer0";
+ var entries = new List();
+ string cursor = null;
+
+ do
+ {
+ var payload = JsonSerializer.Serialize(new
+ {
+ query = SponsorsQuery,
+ variables = new { login, cursor }
+ });
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.github.com/graphql");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ request.Headers.UserAgent.ParseAdd("NosCoreBot");
+ request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
+
+ using var response = await _httpClient.SendAsync(request);
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogWarning("GitHub Sponsors returned {Status}", response.StatusCode);
+ return entries;
+ }
+
+ using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ if (document.RootElement.TryGetProperty("errors", out var errors))
+ {
+ _logger.LogWarning("GitHub Sponsors query failed: {Errors}", errors.ToString());
+ return entries;
+ }
+
+ var sponsorships = document.RootElement
+ .GetProperty("data").GetProperty("user")
+ .GetProperty("sponsorshipsAsMaintainer");
+
+ foreach (var node in sponsorships.GetProperty("nodes").EnumerateArray())
+ {
+ var sponsor = node.GetProperty("sponsorEntity");
+ if (sponsor.ValueKind != JsonValueKind.Object)
+ {
+ continue;
+ }
+
+ var isActive = node.GetProperty("isActive").GetBoolean();
+ var isOneTime = node.GetProperty("isOneTimePayment").GetBoolean();
+ var createdAt = node.GetProperty("createdAt").GetDateTimeOffset();
+ var monthlyCents = node.TryGetProperty("tier", out var tier) && tier.ValueKind == JsonValueKind.Object
+ ? tier.GetProperty("monthlyPriceInCents").GetInt32()
+ : 0;
+
+ var id = sponsor.GetProperty("login").GetString()!;
+ var name = sponsor.TryGetProperty("name", out var nameElement)
+ && nameElement.ValueKind == JsonValueKind.String
+ ? nameElement.GetString()!
+ : id;
+
+ entries.Add(new SponsorEntry("github", id, name,
+ node.GetProperty("privacyLevel").GetString() == "PUBLIC", isActive,
+ isActive && !isOneTime ? monthlyCents : 0,
+ isOneTime ? monthlyCents : monthlyCents * MonthsSince(createdAt)));
+ }
+
+ var pageInfo = sponsorships.GetProperty("pageInfo");
+ cursor = pageInfo.GetProperty("hasNextPage").GetBoolean()
+ ? pageInfo.GetProperty("endCursor").GetString()
+ : null;
+ } while (cursor != null);
+
+ return entries;
+ }
+
+ private static int MonthsSince(DateTimeOffset createdAt)
+ {
+ return Math.Max(1, (int)Math.Floor((DateTimeOffset.UtcNow - createdAt).TotalDays / 30.44));
+ }
+
+ private async Task> FetchPatreonAsync()
+ {
+ var token = Environment.GetEnvironmentVariable("PATREON_ACCESS_TOKEN");
+ var campaignId = Environment.GetEnvironmentVariable("PATREON_CAMPAIGN_ID");
+ if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(campaignId))
+ {
+ _logger.LogInformation("PATREON_ACCESS_TOKEN or PATREON_CAMPAIGN_ID is unset, skipping Patreon");
+ return new List();
+ }
+
+ var namesArePublic = Environment.GetEnvironmentVariable("PATREON_NAMES_PUBLIC") == "true";
+ var entries = new List();
+ var url = $"https://www.patreon.com/api/oauth2/v2/campaigns/{campaignId}/members"
+ + "?fields%5Bmember%5D=full_name,patron_status,currently_entitled_amount_cents,lifetime_support_cents"
+ + "&page%5Bcount%5D=100";
+
+ while (url != null)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ using var response = await _httpClient.SendAsync(request);
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogWarning("Patreon returned {Status}", response.StatusCode);
+ return entries;
+ }
+
+ using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ foreach (var member in document.RootElement.GetProperty("data").EnumerateArray())
+ {
+ var attributes = member.GetProperty("attributes");
+ var lifetime = attributes.GetProperty("lifetime_support_cents").GetInt32();
+ if (lifetime <= 0)
+ {
+ continue;
+ }
+
+ var isActive = attributes.GetProperty("patron_status").GetString() == "active_patron";
+ entries.Add(new SponsorEntry("patreon", member.GetProperty("id").GetString()!,
+ attributes.GetProperty("full_name").GetString() ?? "Patron", namesArePublic, isActive,
+ isActive ? attributes.GetProperty("currently_entitled_amount_cents").GetInt32() : 0, lifetime));
+ }
+
+ url = NextPatreonPage(document);
+ }
+
+ return entries;
+ }
+
+ private static string NextPatreonPage(JsonDocument document)
+ {
+ return document.RootElement.TryGetProperty("meta", out var meta)
+ && meta.TryGetProperty("pagination", out var pagination)
+ && pagination.TryGetProperty("cursors", out var cursors)
+ && cursors.TryGetProperty("next", out var next)
+ && next.ValueKind == JsonValueKind.String
+ ? next.GetString()
+ : null;
+ }
+
+ public static List ApplyLifetimeFloor(SponsorState state, IEnumerable fetched)
+ {
+ var entries = new List();
+ foreach (var entry in fetched)
+ {
+ var known = state.Lifetime.TryGetValue(entry.Key, out var stored) ? stored : 0;
+ var lifetime = entry.IsActive
+ ? Math.Max(known, entry.LifetimeCents)
+ : known > 0
+ ? known
+ : entry.LifetimeCents;
+ state.Lifetime[entry.Key] = lifetime;
+ entries.Add(entry with { LifetimeCents = lifetime });
+ }
+
+ return entries
+ .OrderByDescending(entry => entry.LifetimeCents)
+ .ThenByDescending(entry => entry.MonthlyCents)
+ .ToList();
+ }
+
+ public async Task LoadStateAsync()
+ {
+ try
+ {
+ using var client = CreateS3Client();
+ using var response = await client.GetObjectAsync(new GetObjectRequest
+ {
+ BucketName = Environment.GetEnvironmentVariable("S3_BUCKET"),
+ Key = StateKey
+ });
+ await using var stream = response.ResponseStream;
+ return await JsonSerializer.DeserializeAsync(stream) ?? new SponsorState();
+ }
+ catch (AmazonS3Exception exception) when (exception.StatusCode == HttpStatusCode.NotFound)
+ {
+ return new SponsorState();
+ }
+ }
+
+ public async Task MutateAsync(Func mutation)
+ {
+ await _stateLock.WaitAsync();
+ try
+ {
+ var state = await LoadStateAsync();
+ await mutation(state);
+ await SaveStateAsync(state);
+ return state;
+ }
+ finally
+ {
+ _stateLock.Release();
+ }
+ }
+
+ public Task MutateAsync(Action mutation)
+ {
+ return MutateAsync(state =>
+ {
+ mutation(state);
+ return Task.CompletedTask;
+ });
+ }
+
+ private async Task SaveStateAsync(SponsorState state)
+ {
+ state.UpdatedAt = DateTime.UtcNow;
+ using var client = CreateS3Client();
+ await using var stream = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(state));
+ await client.PutObjectAsync(new PutObjectRequest
+ {
+ BucketName = Environment.GetEnvironmentVariable("S3_BUCKET"),
+ Key = StateKey,
+ ContentType = "application/json",
+ InputStream = stream
+ });
+ }
+
+ private static AmazonS3Client CreateS3Client()
+ {
+ return new AmazonS3Client(new BasicAWSCredentials(
+ Environment.GetEnvironmentVariable("S3_ACCESS_KEY"),
+ Environment.GetEnvironmentVariable("S3_SECRET_KEY")), RegionEndpoint.USWest2);
+ }
+
+ public static string DisplayName(SponsorEntry entry)
+ {
+ return entry.IsPublic ? entry.DisplayName : "Anonymous";
+ }
+
+ public static string Money(int cents)
+ {
+ return (cents / 100m).ToString("C0", CultureInfo.GetCultureInfo("en-US"));
+ }
+}
diff --git a/src/NosCoreBot/Services/SponsorSyncService.cs b/src/NosCoreBot/Services/SponsorSyncService.cs
new file mode 100644
index 0000000..f0e8478
--- /dev/null
+++ b/src/NosCoreBot/Services/SponsorSyncService.cs
@@ -0,0 +1,214 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Timers;
+using Discord;
+using Discord.WebSocket;
+using Microsoft.Extensions.Logging;
+
+namespace NosCoreBot.Services;
+
+public class SponsorSyncService
+{
+ public static readonly (string Name, int MinimumCents, Color Color)[] Tiers =
+ {
+ ("Legendary Supporter", 5000, Color.Gold),
+ ("Gold Supporter", 1000, Color.Orange),
+ ("Supporter", 300, Color.Blue)
+ };
+
+ public const string TopSponsorRole = "Top Sponsor";
+
+ private readonly DiscordSocketClient _discord;
+ private readonly SponsorService _sponsors;
+ private readonly ILogger _logger;
+ private readonly Timer _timer = new(TimeSpan.FromMinutes(15).TotalMilliseconds);
+
+ public SponsorSyncService(DiscordSocketClient discord, SponsorService sponsors,
+ ILogger logger)
+ {
+ _discord = discord;
+ _sponsors = sponsors;
+ _logger = logger;
+ _timer.Elapsed += async (_, _) => await TickAsync();
+ }
+
+ public void Start()
+ {
+ _timer.Start();
+ }
+
+ private async Task TickAsync()
+ {
+ try
+ {
+ await RunAsync(ShouldPublish);
+ }
+ catch (Exception exception)
+ {
+ _logger.LogError(exception, "Sponsor sync failed");
+ }
+ }
+
+ private static bool ShouldPublish(SponsorState state)
+ {
+ var reportHour = int.TryParse(Environment.GetEnvironmentVariable("SPONSOR_REPORT_HOUR"), out var hour)
+ ? hour
+ : 9;
+ return DateTime.UtcNow.Hour == reportHour && state.LastReportUtc?.Date != DateTime.UtcNow.Date;
+ }
+
+ public async Task RunAsync(Func shouldPublish)
+ {
+ var fetched = await _sponsors.FetchAsync();
+ var published = false;
+
+ var state = await _sponsors.MutateAsync(async current =>
+ {
+ current.Snapshot = SponsorService.ApplyLifetimeFloor(current, fetched);
+
+ var guild = GetGuild();
+ if (guild == null)
+ {
+ _logger.LogWarning("SPONSOR_GUILD_ID is unset or the bot is not in that guild");
+ return;
+ }
+
+ await SyncRolesAsync(guild, current);
+
+ if (!shouldPublish(current))
+ {
+ return;
+ }
+
+ if (ulong.TryParse(Environment.GetEnvironmentVariable("SPONSOR_CHANNEL_ID"), out var channelId)
+ && guild.GetTextChannel(channelId) is { } channel)
+ {
+ await channel.SendMessageAsync(embed: BuildEmbed(current.Snapshot));
+ current.LastReportUtc = DateTime.UtcNow;
+ published = true;
+ }
+ else
+ {
+ _logger.LogWarning("SPONSOR_CHANNEL_ID is unset or not a text channel in the guild");
+ }
+ });
+
+ if (published)
+ {
+ _logger.LogInformation("Posted the sponsor leaderboard with {Count} entries", state.Snapshot.Count);
+ }
+
+ return state;
+ }
+
+ private SocketGuild GetGuild()
+ {
+ return ulong.TryParse(Environment.GetEnvironmentVariable("SPONSOR_GUILD_ID"), out var guildId)
+ ? _discord.GetGuild(guildId)
+ : null;
+ }
+
+ public static Embed BuildEmbed(IReadOnlyList snapshot)
+ {
+ var builder = new EmbedBuilder()
+ .WithTitle("Sponsor Leaderboard")
+ .WithColor(Color.Gold)
+ .WithFooter("Sponsors who chose to stay private are listed as Anonymous. "
+ + "GitHub totals are estimated from tier and start date.")
+ .WithCurrentTimestamp();
+
+ if (snapshot.Count == 0)
+ {
+ builder.WithDescription("No sponsors yet. Be the first: https://github.com/sponsors/0Lucifer0");
+ return builder.Build();
+ }
+
+ var lines = snapshot.Take(25).Select((entry, index) =>
+ {
+ var medal = index switch
+ {
+ 0 => "๐ฅ",
+ 1 => "๐ฅ",
+ 2 => "๐ฅ",
+ _ => "โจ"
+ };
+ var monthly = entry.MonthlyCents > 0 ? $" ยท {SponsorService.Money(entry.MonthlyCents)}/mo" : "";
+ return $"{medal} **{SponsorService.DisplayName(entry)}** โ {SponsorService.Money(entry.LifetimeCents)}"
+ + $" total{monthly} ยท {entry.Platform}";
+ });
+
+ builder.WithDescription(string.Join('\n', lines));
+ return builder.Build();
+ }
+
+ public async Task SyncRolesAsync(SocketGuild guild, SponsorState state)
+ {
+ var roles = await EnsureRolesAsync(guild);
+ var topSponsor = state.Snapshot.FirstOrDefault(entry => entry.IsActive);
+ var wantedByMember = new Dictionary>();
+
+ foreach (var entry in state.Snapshot)
+ {
+ if (!state.Links.TryGetValue(entry.Key, out var discordId))
+ {
+ continue;
+ }
+
+ if (!wantedByMember.TryGetValue(discordId, out var wanted))
+ {
+ wanted = new HashSet();
+ wantedByMember[discordId] = wanted;
+ }
+
+ var earned = Tiers.FirstOrDefault(tier => entry.MonthlyCents >= tier.MinimumCents).Name;
+ if (earned != null)
+ {
+ wanted.Add(earned);
+ }
+
+ if (topSponsor != null && entry.Key == topSponsor.Key)
+ {
+ wanted.Add(TopSponsorRole);
+ }
+ }
+
+ var managedIds = roles.Values.Select(role => role.Id).ToHashSet();
+ var members = wantedByMember.Keys
+ .Select(guild.GetUser)
+ .Where(member => member != null)
+ .Concat(guild.Users.Where(member => member.Roles.Any(role => managedIds.Contains(role.Id))))
+ .DistinctBy(member => member.Id);
+
+ foreach (var member in members)
+ {
+ var wanted = wantedByMember.TryGetValue(member.Id, out var names) ? names : new HashSet();
+ foreach (var (name, role) in roles)
+ {
+ var hasRole = member.Roles.Any(memberRole => memberRole.Id == role.Id);
+ if (wanted.Contains(name) && !hasRole)
+ {
+ await member.AddRoleAsync(role);
+ }
+ else if (!wanted.Contains(name) && hasRole)
+ {
+ await member.RemoveRoleAsync(role);
+ }
+ }
+ }
+ }
+
+ private static async Task> EnsureRolesAsync(SocketGuild guild)
+ {
+ var roles = new Dictionary();
+ foreach (var (name, _, color) in Tiers.Append((TopSponsorRole, 0, Color.Purple)))
+ {
+ var role = guild.Roles.FirstOrDefault(guildRole => guildRole.Name == name)
+ ?? (IRole)await guild.CreateRoleAsync(name, color: color, isHoisted: true, isMentionable: false);
+ roles[name] = role;
+ }
+
+ return roles;
+ }
+}
diff --git a/src/NosCoreBot/Worker.cs b/src/NosCoreBot/Worker.cs
index 8fb9c41..acd90e9 100644
--- a/src/NosCoreBot/Worker.cs
+++ b/src/NosCoreBot/Worker.cs
@@ -21,15 +21,17 @@ public class Worker : BackgroundService
private readonly CommandService _cmservice;
private readonly CommandHandlingService _chservice;
private readonly TimeHandlingService _thservice;
+ private readonly SponsorSyncService _sponsorsync;
private readonly IHostApplicationLifetime _lifetime;
public Worker(DiscordSocketClient client, CommandService cmservice, CommandHandlingService chservice,
- TimeHandlingService thservice, IHostApplicationLifetime lifetime)
+ TimeHandlingService thservice, SponsorSyncService sponsorsync, IHostApplicationLifetime lifetime)
{
_client = client;
_cmservice = cmservice;
_chservice = chservice;
_thservice = thservice;
+ _sponsorsync = sponsorsync;
_lifetime = lifetime;
}
@@ -65,6 +67,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
await _chservice.InitializeAsync();
+ _sponsorsync.Start();
await _thservice.UploadInputFilesAsync();
await Task.Delay(-1, stoppingToken);
diff --git a/tests/NosCoreBot.Tests/NosCoreBot.Tests.csproj b/tests/NosCoreBot.Tests/NosCoreBot.Tests.csproj
index 494e4ca..a9a8e43 100644
--- a/tests/NosCoreBot.Tests/NosCoreBot.Tests.csproj
+++ b/tests/NosCoreBot.Tests/NosCoreBot.Tests.csproj
@@ -12,4 +12,8 @@
+
+
+
+
diff --git a/tests/NosCoreBot.Tests/SponsorTests.cs b/tests/NosCoreBot.Tests/SponsorTests.cs
new file mode 100644
index 0000000..428a7b3
--- /dev/null
+++ b/tests/NosCoreBot.Tests/SponsorTests.cs
@@ -0,0 +1,103 @@
+using System.Collections.Generic;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using NosCoreBot.Services;
+
+namespace NosCoreBot.Tests
+{
+ [TestClass]
+ public class SponsorTests
+ {
+ private static SponsorEntry Entry(string id, bool isActive, int monthlyCents, int lifetimeCents,
+ bool isPublic = true)
+ {
+ return new SponsorEntry("github", id, id, isPublic, isActive, monthlyCents, lifetimeCents);
+ }
+
+ [TestMethod]
+ public void ActiveSponsorTotalGrowsWithTheFetchedValue()
+ {
+ var state = new SponsorState { Lifetime = { ["github:alice"] = 3000 } };
+
+ var entries = SponsorService.ApplyLifetimeFloor(state, new[] { Entry("alice", true, 1000, 9000) });
+
+ Assert.AreEqual(9000, entries[0].LifetimeCents);
+ Assert.AreEqual(9000, state.Lifetime["github:alice"]);
+ }
+
+ [TestMethod]
+ public void ActiveSponsorTotalNeverShrinks()
+ {
+ var state = new SponsorState { Lifetime = { ["github:alice"] = 9000 } };
+
+ var entries = SponsorService.ApplyLifetimeFloor(state, new[] { Entry("alice", true, 1000, 3000) });
+
+ Assert.AreEqual(9000, entries[0].LifetimeCents);
+ }
+
+ [TestMethod]
+ public void EndedSponsorshipKeepsTheTotalItStoppedAt()
+ {
+ var state = new SponsorState { Lifetime = { ["github:bob"] = 2000 } };
+
+ var entries = SponsorService.ApplyLifetimeFloor(state, new[] { Entry("bob", false, 0, 7000) });
+
+ Assert.AreEqual(2000, entries[0].LifetimeCents);
+ Assert.AreEqual(2000, state.Lifetime["github:bob"]);
+ }
+
+ [TestMethod]
+ public void UnknownEndedSponsorshipFallsBackToTheFetchedTotal()
+ {
+ var state = new SponsorState();
+
+ var entries = SponsorService.ApplyLifetimeFloor(state, new[] { Entry("carol", false, 0, 4200) });
+
+ Assert.AreEqual(4200, entries[0].LifetimeCents);
+ }
+
+ [TestMethod]
+ public void EntriesAreRankedByTotal()
+ {
+ var state = new SponsorState();
+
+ var entries = SponsorService.ApplyLifetimeFloor(state, new[]
+ {
+ Entry("small", true, 300, 600),
+ Entry("big", true, 5000, 50000),
+ Entry("middle", true, 1000, 12000)
+ });
+
+ CollectionAssert.AreEqual(new List { "big", "middle", "small" },
+ entries.ConvertAll(entry => entry.Id));
+ }
+
+ [TestMethod]
+ public void PrivateSponsorsAreAnonymisedInTheLeaderboard()
+ {
+ var embed = SponsorSyncService.BuildEmbed(new[]
+ {
+ Entry("public-one", true, 1000, 12000),
+ Entry("private-one", true, 5000, 50000, false)
+ });
+
+ StringAssert.Contains(embed.Description, "public-one");
+ StringAssert.Contains(embed.Description, "Anonymous");
+ Assert.IsFalse(embed.Description.Contains("private-one"));
+ }
+
+ [TestMethod]
+ public void AnEmptyLeaderboardInvitesSponsors()
+ {
+ var embed = SponsorSyncService.BuildEmbed(new SponsorEntry[0]);
+
+ StringAssert.Contains(embed.Description, "github.com/sponsors");
+ }
+
+ [TestMethod]
+ public void AmountsAreRenderedAsWholeDollars()
+ {
+ Assert.AreEqual("$50", SponsorService.Money(5000));
+ Assert.AreEqual("$3", SponsorService.Money(300));
+ }
+ }
+}