From d482017009d482dd0712325814d977b6f4df7aca Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Fri, 11 Sep 2026 01:40:28 +1200 Subject: [PATCH 1/3] feat: sponsor leaderboard, daily report and tier roles The leaderboard was fed by hand with /add-points. Pull the numbers from the funding platforms instead, post them daily, and hand out roles. - SponsorService reads GitHub Sponsors (GraphQL) and Patreon, keeping state in sponsors.json next to the existing contribution.json - SponsorSyncService posts the ranking once a day and reconciles the Supporter / Gold / Legendary / Top Sponsor roles - SponsorModule adds /sponsors, /sponsor-sync and the account link commands Private sponsors are listed as Anonymous with their amount. Platforms whose credentials are unset are skipped, so this is inert until configured. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 16 ++ src/NosCoreBot/Modules/SponsorModule.cs | 88 +++++++ src/NosCoreBot/Program.cs | 2 + src/NosCoreBot/Services/SponsorService.cs | 249 ++++++++++++++++++ src/NosCoreBot/Services/SponsorSyncService.cs | 181 +++++++++++++ src/NosCoreBot/Worker.cs | 5 +- 6 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 src/NosCoreBot/Modules/SponsorModule.cs create mode 100644 src/NosCoreBot/Services/SponsorService.cs create mode 100644 src/NosCoreBot/Services/SponsorSyncService.cs 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..b4b02cd --- /dev/null +++ b/src/NosCoreBot/Modules/SponsorModule.cs @@ -0,0 +1,88 @@ +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 state = await _sponsors.LoadStateAsync(); + var key = $"{platform.ToLowerInvariant()}:{id}"; + state.Links[key] = user.Id; + await _sponsors.SaveStateAsync(state); + 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 state = await _sponsors.LoadStateAsync(); + var key = $"{platform.ToLowerInvariant()}:{id}"; + if (!state.Links.Remove(key)) + { + await ReplyAsync($"No link for `{key}`."); + return; + } + + await _sponsors.SaveStateAsync(state); + await ReplyAsync($"Unlinked `{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 _sponsors.LoadStateAsync(); + await _sync.RunAsync(state); + 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; + } + + var lines = state.Links.Select(link => $"`{link.Key}` -> <@{link.Value}>"); + await ReplyAsync(string.Join('\n', lines)); + } +} 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..aba2e83 --- /dev/null +++ b/src/NosCoreBot/Services/SponsorService.cs @@ -0,0 +1,249 @@ +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.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, int MonthlyCents, + int LifetimeCents) +{ + public string Key => $"{Platform}:{Id}"; +} + +public class SponsorState +{ + public Dictionary Links { 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!) { + user(login: $login) { + sponsorshipsAsMaintainer(first: 100, includePrivate: true, activeOnly: false) { + nodes { + privacyLevel + createdAt + isOneTimePayment + tier { monthlyPriceInCents } + sponsorEntity { + __typename + ... on User { login name } + ... on Organization { login name } + } + } + } + } + } + """; + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + 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 + .OrderByDescending(entry => entry.LifetimeCents) + .ThenByDescending(entry => entry.MonthlyCents) + .ToList(); + } + + 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 payload = JsonSerializer.Serialize(new + { + query = SponsorsQuery, + variables = new { login } + }); + + 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 new List(); + } + + 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 new List(); + } + + var entries = new List(); + var nodes = document.RootElement + .GetProperty("data").GetProperty("user") + .GetProperty("sponsorshipsAsMaintainer").GetProperty("nodes"); + + foreach (var node in nodes.EnumerateArray()) + { + var isPublic = node.GetProperty("privacyLevel").GetString() == "PUBLIC"; + 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 sponsor = node.GetProperty("sponsorEntity"); + if (sponsor.ValueKind != JsonValueKind.Object) + { + continue; + } + + 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, isPublic, + isOneTime ? 0 : monthlyCents, + isOneTime ? monthlyCents : monthlyCents * MonthsSince(createdAt))); + } + + 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 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"; + + 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 new List(); + } + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var entries = new List(); + 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 ? attributes.GetProperty("currently_entitled_amount_cents").GetInt32() : 0, lifetime)); + } + + return entries; + } + + 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 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..12d1cd6 --- /dev/null +++ b/src/NosCoreBot/Services/SponsorSyncService.cs @@ -0,0 +1,181 @@ +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 + { + var reportHour = int.TryParse(Environment.GetEnvironmentVariable("SPONSOR_REPORT_HOUR"), out var hour) + ? hour + : 9; + var state = await _sponsors.LoadStateAsync(); + if (DateTime.UtcNow.Hour != reportHour || state.LastReportUtc?.Date == DateTime.UtcNow.Date) + { + return; + } + + await RunAsync(state); + } + catch (Exception exception) + { + _logger.LogError(exception, "Sponsor sync failed"); + } + } + + public async Task RunAsync(SponsorState state) + { + state.Snapshot = await _sponsors.FetchAsync(); + state.LastReportUtc = DateTime.UtcNow; + await _sponsors.SaveStateAsync(state); + + 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, state); + + if (ulong.TryParse(Environment.GetEnvironmentVariable("SPONSOR_CHANNEL_ID"), out var channelId) + && guild.GetTextChannel(channelId) is { } channel) + { + await channel.SendMessageAsync(embed: BuildEmbed(state.Snapshot)); + } + else + { + _logger.LogWarning("SPONSOR_CHANNEL_ID is unset or not a text channel in the guild"); + } + } + + 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(); + + foreach (var entry in state.Snapshot) + { + if (!state.Links.TryGetValue(entry.Key, out var discordId) + || guild.GetUser(discordId) is not { } member) + { + continue; + } + + var earned = Tiers.FirstOrDefault(tier => entry.MonthlyCents >= tier.MinimumCents).Name; + var wanted = new List(); + if (earned != null) + { + wanted.Add(earned); + } + + if (topSponsor != null && entry.Key == topSponsor.Key) + { + wanted.Add(TopSponsorRole); + } + + 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); From 63542f6ca4e625ea35f9e2969ce8f207feb00ab2 Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Fri, 11 Sep 2026 10:41:44 +1200 Subject: [PATCH 2/3] fix: cancelled sponsors kept their role and kept accruing total Review found the snapshot treated historical sponsorships as current. - read isActive, zero the monthly amount when a sponsorship has ended, and freeze lifetime totals in state instead of recomputing them from the start date forever - reconcile roles every tick, publish once a day, and record the report only after the message actually goes out - strip managed roles from members who no longer qualify, including after an unlink or a sponsor leaving the snapshot - page through both APIs, and serialise load-modify-save so a link command and the timer cannot clobber each other Co-Authored-By: Claude Opus 5 (1M context) --- src/NosCoreBot/Modules/SponsorModule.cs | 22 +- src/NosCoreBot/Services/SponsorService.cs | 226 ++++++++++++------ src/NosCoreBot/Services/SponsorSyncService.cs | 95 +++++--- 3 files changed, 221 insertions(+), 122 deletions(-) diff --git a/src/NosCoreBot/Modules/SponsorModule.cs b/src/NosCoreBot/Modules/SponsorModule.cs index b4b02cd..e86f28d 100644 --- a/src/NosCoreBot/Modules/SponsorModule.cs +++ b/src/NosCoreBot/Modules/SponsorModule.cs @@ -33,10 +33,8 @@ public async Task ShowSponsors() [RequireUserPermission(GuildPermission.ManageRoles)] public async Task LinkSponsor(SocketGuildUser user, string platform, string id) { - var state = await _sponsors.LoadStateAsync(); var key = $"{platform.ToLowerInvariant()}:{id}"; - state.Links[key] = user.Id; - await _sponsors.SaveStateAsync(state); + await _sponsors.MutateAsync(state => state.Links[key] = user.Id); await ReplyAsync($"Linked {user.Mention} to `{key}`. Roles apply on the next sync."); } @@ -46,16 +44,10 @@ public async Task LinkSponsor(SocketGuildUser user, string platform, string id) [RequireUserPermission(GuildPermission.ManageRoles)] public async Task UnlinkSponsor(string platform, string id) { - var state = await _sponsors.LoadStateAsync(); var key = $"{platform.ToLowerInvariant()}:{id}"; - if (!state.Links.Remove(key)) - { - await ReplyAsync($"No link for `{key}`."); - return; - } - - await _sponsors.SaveStateAsync(state); - await ReplyAsync($"Unlinked `{key}`."); + var removed = false; + await _sponsors.MutateAsync(state => removed = state.Links.Remove(key)); + await ReplyAsync(removed ? $"Unlinked `{key}`." : $"No link for `{key}`."); } [Command("sponsor-sync")] @@ -64,8 +56,7 @@ public async Task UnlinkSponsor(string platform, string id) [RequireUserPermission(GuildPermission.ManageRoles)] public async Task SyncNow() { - var state = await _sponsors.LoadStateAsync(); - await _sync.RunAsync(state); + var state = await _sync.RunAsync(_ => true); await ReplyAsync($"Synced {state.Snapshot.Count} sponsors."); } @@ -82,7 +73,6 @@ public async Task ShowLinks() return; } - var lines = state.Links.Select(link => $"`{link.Key}` -> <@{link.Value}>"); - await ReplyAsync(string.Join('\n', lines)); + await ReplyAsync(string.Join('\n', state.Links.Select(link => $"`{link.Key}` -> <@{link.Value}>"))); } } diff --git a/src/NosCoreBot/Services/SponsorService.cs b/src/NosCoreBot/Services/SponsorService.cs index aba2e83..cefffec 100644 --- a/src/NosCoreBot/Services/SponsorService.cs +++ b/src/NosCoreBot/Services/SponsorService.cs @@ -8,6 +8,7 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Amazon; using Amazon.Runtime; @@ -17,8 +18,8 @@ namespace NosCoreBot.Services; -public record SponsorEntry(string Platform, string Id, string DisplayName, bool IsPublic, int MonthlyCents, - int LifetimeCents) +public record SponsorEntry(string Platform, string Id, string DisplayName, bool IsPublic, bool IsActive, + int MonthlyCents, int LifetimeCents) { public string Key => $"{Platform}:{Id}"; } @@ -27,6 +28,8 @@ 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; } @@ -39,10 +42,12 @@ public class SponsorService private const string StateKey = "sponsors.json"; private const string SponsorsQuery = """ - query($login: String!) { + query($login: String!, $cursor: String) { user(login: $login) { - sponsorshipsAsMaintainer(first: 100, includePrivate: true, activeOnly: false) { + sponsorshipsAsMaintainer(first: 100, after: $cursor, includePrivate: true, activeOnly: false) { + pageInfo { hasNextPage endCursor } nodes { + isActive privacyLevel createdAt isOneTimePayment @@ -60,6 +65,7 @@ ... 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) { @@ -72,10 +78,7 @@ public async Task> FetchAsync() var entries = new List(); entries.AddRange(await FetchGitHubAsync()); entries.AddRange(await FetchPatreonAsync()); - return entries - .OrderByDescending(entry => entry.LifetimeCents) - .ThenByDescending(entry => entry.MonthlyCents) - .ToList(); + return entries; } private async Task> FetchGitHubAsync() @@ -88,61 +91,72 @@ private async Task> FetchGitHubAsync() } var login = Environment.GetEnvironmentVariable("GITHUB_SPONSORS_LOGIN") ?? "0Lucifer0"; - var payload = JsonSerializer.Serialize(new - { - query = SponsorsQuery, - variables = new { login } - }); - - 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"); + var entries = new List(); + string cursor = null; - using var response = await _httpClient.SendAsync(request); - if (!response.IsSuccessStatusCode) + do { - _logger.LogWarning("GitHub Sponsors returned {Status}", response.StatusCode); - return new List(); - } + var payload = JsonSerializer.Serialize(new + { + query = SponsorsQuery, + variables = new { login, cursor } + }); - 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 new List(); - } + 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"); - var entries = new List(); - var nodes = document.RootElement - .GetProperty("data").GetProperty("user") - .GetProperty("sponsorshipsAsMaintainer").GetProperty("nodes"); + using var response = await _httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("GitHub Sponsors returned {Status}", response.StatusCode); + return entries; + } - foreach (var node in nodes.EnumerateArray()) - { - var isPublic = node.GetProperty("privacyLevel").GetString() == "PUBLIC"; - 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 sponsor = node.GetProperty("sponsorEntity"); - if (sponsor.ValueKind != JsonValueKind.Object) + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + if (document.RootElement.TryGetProperty("errors", out var errors)) { - continue; + _logger.LogWarning("GitHub Sponsors query failed: {Errors}", errors.ToString()); + return entries; } - var id = sponsor.GetProperty("login").GetString()!; - var name = sponsor.TryGetProperty("name", out var nameElement) - && nameElement.ValueKind == JsonValueKind.String - ? nameElement.GetString()! - : id; + var sponsorships = document.RootElement + .GetProperty("data").GetProperty("user") + .GetProperty("sponsorshipsAsMaintainer"); - entries.Add(new SponsorEntry("github", id, name, isPublic, - isOneTime ? 0 : monthlyCents, - isOneTime ? monthlyCents : monthlyCents * MonthsSince(createdAt))); - } + 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; } @@ -163,40 +177,77 @@ private async Task> FetchPatreonAsync() } 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"; - 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) + while (url != null) { - _logger.LogWarning("Patreon returned {Status}", response.StatusCode); - return new List(); - } + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - var entries = new List(); - 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) + using var response = await _httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) { - continue; + _logger.LogWarning("Patreon returned {Status}", response.StatusCode); + return entries; } - 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 ? attributes.GetProperty("currently_entitled_amount_cents").GetInt32() : 0, lifetime)); + 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 @@ -216,7 +267,32 @@ public async Task LoadStateAsync() } } - public async Task SaveStateAsync(SponsorState state) + 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(); diff --git a/src/NosCoreBot/Services/SponsorSyncService.cs b/src/NosCoreBot/Services/SponsorSyncService.cs index 12d1cd6..f0e8478 100644 --- a/src/NosCoreBot/Services/SponsorSyncService.cs +++ b/src/NosCoreBot/Services/SponsorSyncService.cs @@ -43,16 +43,7 @@ private async Task TickAsync() { try { - var reportHour = int.TryParse(Environment.GetEnvironmentVariable("SPONSOR_REPORT_HOUR"), out var hour) - ? hour - : 9; - var state = await _sponsors.LoadStateAsync(); - if (DateTime.UtcNow.Hour != reportHour || state.LastReportUtc?.Date == DateTime.UtcNow.Date) - { - return; - } - - await RunAsync(state); + await RunAsync(ShouldPublish); } catch (Exception exception) { @@ -60,30 +51,56 @@ private async Task TickAsync() } } - public async Task RunAsync(SponsorState state) + private static bool ShouldPublish(SponsorState state) { - state.Snapshot = await _sponsors.FetchAsync(); - state.LastReportUtc = DateTime.UtcNow; - await _sponsors.SaveStateAsync(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 guild = GetGuild(); - if (guild == null) + var state = await _sponsors.MutateAsync(async current => { - _logger.LogWarning("SPONSOR_GUILD_ID is unset or the bot is not in that guild"); - return; - } + current.Snapshot = SponsorService.ApplyLifetimeFloor(current, fetched); - await SyncRolesAsync(guild, state); + var guild = GetGuild(); + if (guild == null) + { + _logger.LogWarning("SPONSOR_GUILD_ID is unset or the bot is not in that guild"); + return; + } - if (ulong.TryParse(Environment.GetEnvironmentVariable("SPONSOR_CHANNEL_ID"), out var channelId) - && guild.GetTextChannel(channelId) is { } channel) - { - await channel.SendMessageAsync(embed: BuildEmbed(state.Snapshot)); - } - else + 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.LogWarning("SPONSOR_CHANNEL_ID is unset or not a text channel in the guild"); + _logger.LogInformation("Posted the sponsor leaderboard with {Count} entries", state.Snapshot.Count); } + + return state; } private SocketGuild GetGuild() @@ -129,18 +146,23 @@ public static Embed BuildEmbed(IReadOnlyList snapshot) public async Task SyncRolesAsync(SocketGuild guild, SponsorState state) { var roles = await EnsureRolesAsync(guild); - var topSponsor = state.Snapshot.FirstOrDefault(); + 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) - || guild.GetUser(discordId) is not { } member) + 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; - var wanted = new List(); if (earned != null) { wanted.Add(earned); @@ -150,7 +172,18 @@ public async Task SyncRolesAsync(SocketGuild guild, SponsorState state) { 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); From 0ed88cfa214b22641742f1b6a4ae2886171c20d6 Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Fri, 11 Sep 2026 15:04:57 +1200 Subject: [PATCH 3/3] test: cover the sponsor total and leaderboard rules The test project had no reference to the bot and no tests. Pin the parts that decide what people see: totals never shrink, an ended sponsorship freezes at the total it stopped at, and private sponsors render as Anonymous with their name nowhere in the embed. Co-Authored-By: Claude Opus 5 (1M context) --- .../NosCoreBot.Tests/NosCoreBot.Tests.csproj | 4 + tests/NosCoreBot.Tests/SponsorTests.cs | 103 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 tests/NosCoreBot.Tests/SponsorTests.cs 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)); + } + } +}