diff --git a/README.md b/README.md index d181091..9ad611f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,22 @@ [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/A3562BQV) Become a Patron! +## 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)); + } + } +}