using System.Net; using System.Text.Json; using System.Threading.RateLimiting; using Humanizer; using NodaTime; using NodaTime.Serialization.SystemTextJson; using NodaTime.Text; using Polly; namespace Catalogger.Backend.Services; public class PluralkitApiService(ILogger logger) { private const string UserAgent = "Catalogger.NET (https://codeberg.org/starshine/catalogger)"; private const string ApiBaseUrl = "https://api.pluralkit.me/v2"; private readonly HttpClient _client = new(); private readonly ILogger _logger = logger.ForContext(); private readonly ResiliencePipeline _pipeline = new ResiliencePipelineBuilder() .AddRateLimiter( new FixedWindowRateLimiter( new FixedWindowRateLimiterOptions() { Window = 1.Seconds(), PermitLimit = 2, QueueLimit = 64, } ) ) .AddTimeout(20.Seconds()) .Build(); private async Task DoRequestAsync( string path, bool allowNotFound = false, CancellationToken ct = default ) where T : class { var req = new HttpRequestMessage(HttpMethod.Get, $"{ApiBaseUrl}{path}"); req.Headers.Add("User-Agent", UserAgent); _logger.Debug("Requesting {Path} from PluralKit API", path); var resp = await _pipeline.ExecuteAsync(async ct2 => await _client.SendAsync(req, ct2), ct); if (resp.StatusCode == HttpStatusCode.NotFound && allowNotFound) { _logger.Debug("PluralKit API path {Path} returned 404 but 404 response is valid", path); return null; } if (!resp.IsSuccessStatusCode) { _logger.Error( "Received non-200 status code {StatusCode} from PluralKit API path {Path}", resp.StatusCode, req ); throw new CataloggerError("Non-200 status code from PluralKit API"); } var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, }.ConfigureForNodaTime( new NodaJsonSettings { InstantConverter = new NodaPatternConverter(InstantPattern.ExtendedIso), } ); return await resp.Content.ReadFromJsonAsync(jsonOptions, ct) ?? throw new CataloggerError("JSON response from PluralKit API was null"); } public async Task GetPluralKitMessageAsync( ulong id, CancellationToken ct = default ) => await DoRequestAsync($"/messages/{id}", allowNotFound: true, ct); public async Task GetPluralKitSystemAsync( ulong id, CancellationToken ct = default ) => await DoRequestAsync($"/systems/{id}", allowNotFound: true, ct); public record PkMessage( ulong Id, ulong Original, ulong Sender, ulong Channel, ulong Guild, PkSystem? System, PkMember? Member ); public record PkSystem(string Id, Guid Uuid, string? Name, string? Tag, Instant? Created); public record PkMember(string Id, Guid Uuid, string Name, string? DisplayName); }