Repository navigation
feat: sync OpenShock control stats to Discord profile widgets #89
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| using System.Net.Http.Json; | ||
|
|
||
| namespace OpenShock.DiscordBot.Services.Widget; | ||
|
|
||
| /// <summary> | ||
| /// Client for the OpenShock API's integration endpoint that reports users' lifetime control stats, but only | ||
| /// for users whose stats changed since a cursor. | ||
| /// </summary> | ||
| public sealed class ControlStatsClient | ||
| { | ||
| public const string AutomationTokenHeader = "X-OpenShock-Automation-Token"; | ||
|
|
||
| /// <summary>Server side limit of user ids per request.</summary> | ||
| private const int MaxUserIds = 1000; | ||
|
|
||
| private readonly HttpClient _http; | ||
|
|
||
| public ControlStatsClient(HttpClient http) | ||
| { | ||
| _http = http; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Totals of every user in <paramref name="userIds"/> whose stats changed since <paramref name="since"/>, or of | ||
| /// all of them when it is null. The returned cursor is the oldest one of all chunks. | ||
| /// </summary> | ||
| public async Task<ControlStatChanges> GetChangesAsync(DateTime? since, IReadOnlyCollection<Guid> userIds, | ||
| CancellationToken ct) | ||
| { | ||
| DateTime? cursor = null; | ||
| var users = new List<UserControlStatTotals>(); | ||
|
|
||
| foreach (var chunk in userIds.Chunk(MaxUserIds)) | ||
| { | ||
| using var response = await _http.PostAsJsonAsync("1/integrations/control-stats/changes", | ||
| new { since, userIds = chunk }, ct); | ||
| response.EnsureSuccessStatusCode(); | ||
|
|
||
| var page = await response.Content.ReadFromJsonAsync<ControlStatChanges>(ct) | ||
| ?? throw new InvalidOperationException("Empty control stats response"); | ||
| users.AddRange(page.Users); | ||
| if (cursor is null || page.Cursor < cursor) cursor = page.Cursor; | ||
| } | ||
|
|
||
| return new ControlStatChanges(cursor ?? DateTime.UtcNow, users); | ||
| } | ||
| } | ||
|
|
||
| public sealed record ControlStatChanges(DateTime Cursor, IReadOnlyList<UserControlStatTotals> Users); | ||
|
|
||
| public sealed record UserControlStatTotals(Guid UserId, ControlStatTotals[] Received, ControlStatTotals[] Given); | ||
|
|
||
| /// <summary>One control type's totals, <see cref="Type"/> is the API's enum name (Shock, Vibrate, Sound).</summary> | ||
| public sealed record ControlStatTotals( | ||
| string Type, | ||
| bool Live, | ||
| long Count, | ||
| long DurationMs, | ||
| double AverageIntensity, | ||
| byte MaxIntensity); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| using System.Net.Http.Headers; | ||
| using System.Net.Http.Json; | ||
| using System.Text.Json.Nodes; | ||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| namespace OpenShock.DiscordBot.Services.Widget; | ||
|
|
||
| public enum ProfileWidgetResult | ||
| { | ||
| Success, | ||
|
|
||
| /// <summary>The user no longer grants <c>application_identities.write</c>, stop writing until they do again.</summary> | ||
| Unauthorized, | ||
|
|
||
| /// <summary>Anything else (rate limits, outages), worth retrying later.</summary> | ||
| Failed | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Writes Game Stats Widget data through Discord's Application Identity Profile API, authorized with the bot token. | ||
| /// </summary> | ||
| public sealed class ProfileWidgetClient | ||
| { | ||
| private readonly HttpClient _http; | ||
| private readonly DiscordBotConfig _config; | ||
| private readonly ILogger<ProfileWidgetClient> _logger; | ||
|
|
||
| public ProfileWidgetClient(HttpClient http, DiscordBotConfig config, ILogger<ProfileWidgetClient> logger) | ||
| { | ||
| _http = http; | ||
| _config = config; | ||
| _logger = logger; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Replaces the user's widget data for the given OpenShock identity. Discord replaces the whole data object on | ||
| /// every PATCH, so <paramref name="data"/> must always be complete; an empty object clears it. | ||
| /// </summary> | ||
| public async Task<ProfileWidgetResult> UpdateAsync(ulong applicationId, ulong discordId, Guid openShockId, | ||
| JsonObject data, CancellationToken ct) | ||
| { | ||
| using var request = new HttpRequestMessage(HttpMethod.Patch, | ||
| $"https://discord.com/api/v10/applications/{applicationId}/users/{discordId}/identities/{openShockId}/profile"); | ||
| request.Headers.Authorization = new AuthenticationHeaderValue("Bot", _config.Token); | ||
| request.Content = JsonContent.Create(new JsonObject { ["data"] = data }); | ||
|
|
||
| using var response = await _http.SendAsync(request, ct); | ||
| if (response.IsSuccessStatusCode) return ProfileWidgetResult.Success; | ||
|
|
||
| _logger.LogWarning("Profile widget update for {DiscordId} failed with {Status}: {Error}", | ||
| discordId, response.StatusCode, await response.Content.ReadAsStringAsync(ct)); | ||
|
|
||
| return response.StatusCode == System.Net.HttpStatusCode.Forbidden | ||
| ? ProfileWidgetResult.Unauthorized | ||
| : ProfileWidgetResult.Failed; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| using System.Text.Json.Nodes; | ||
| using Discord.WebSocket; | ||
| using Microsoft.EntityFrameworkCore; | ||
| using Microsoft.Extensions.DependencyInjection; | ||
| using Microsoft.Extensions.Hosting; | ||
| using Microsoft.Extensions.Logging; | ||
| using OpenShock.DiscordBot.OpenShockDiscordDb; | ||
|
|
||
| namespace OpenShock.DiscordBot.Services.Widget; | ||
|
|
||
| /// <summary> | ||
| /// Periodically pushes the OpenShock control stats of users who granted <c>application_identities.write</c> to | ||
| /// their Discord profile widget. Each tick costs one stats request: the API only reports users whose stats changed | ||
| /// since the previous cursor, and users seen for the first time get one full sync. A widget is only written when | ||
| /// its data actually differs from what was last written. | ||
| /// </summary> | ||
| public sealed class WidgetSyncService : BackgroundService | ||
| { | ||
| private readonly IServiceProvider _services; | ||
| private readonly DiscordSocketClient _client; | ||
| private readonly WidgetSyncConfig _config; | ||
| private readonly ILogger<WidgetSyncService> _logger; | ||
|
|
||
| /// <summary>What was last written per Discord user, so unchanged data and stale identities can be detected.</summary> | ||
| private readonly Dictionary<ulong, SyncedWidget> _synced = []; | ||
|
|
||
| private DateTime? _cursor; | ||
|
|
||
| public WidgetSyncService(IServiceProvider services, DiscordSocketClient client, DiscordBotConfig config, | ||
| ILogger<WidgetSyncService> logger) | ||
| { | ||
| _services = services; | ||
| _client = client; | ||
| _config = config.Widget; | ||
| _logger = logger; | ||
| } | ||
|
|
||
| protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(_config.AutomationToken)) | ||
| { | ||
| _logger.LogInformation("No widget automation token configured, profile widget sync is disabled"); | ||
| return; | ||
| } | ||
|
|
||
| using var timer = new PeriodicTimer(_config.Interval); | ||
| do | ||
| { | ||
| try | ||
| { | ||
| await SyncAsync(stoppingToken); | ||
| } | ||
| catch (Exception e) when (!stoppingToken.IsCancellationRequested) | ||
| { | ||
| _logger.LogError(e, "Profile widget sync failed"); | ||
| } | ||
| } while (await timer.WaitForNextTickAsync(stoppingToken)); | ||
| } | ||
|
|
||
| private async Task SyncAsync(CancellationToken ct) | ||
| { | ||
| // The bot user's id is the application id, unknown until the gateway logged in | ||
| if (_client.CurrentUser is not { } bot) return; | ||
| var applicationId = bot.Id; | ||
|
|
||
| await using var scope = _services.CreateAsyncScope(); | ||
| var db = scope.ServiceProvider.GetRequiredService<OpenShockDiscordContext>(); | ||
| var stats = scope.ServiceProvider.GetRequiredService<ControlStatsClient>(); | ||
| var widgets = scope.ServiceProvider.GetRequiredService<ProfileWidgetClient>(); | ||
|
|
||
| var authorized = (await db.Users.Where(x => x.WidgetAuthorized) | ||
| .Select(x => new { x.DiscordId, x.OpenshockId, x.ApiServer }) | ||
| .ToArrayAsync(ct)) | ||
| .Where(x => IsSyncedServer(x.ApiServer)) | ||
| .ToDictionary(x => x.DiscordId, x => x.OpenshockId); | ||
|
|
||
| // Unlinked, revoked or re-linked to another OpenShock account: clear what we wrote for the old identity | ||
| foreach (var (discordId, synced) in _synced.ToArray()) | ||
| { | ||
| if (authorized.TryGetValue(discordId, out var openShockId) && openShockId == synced.OpenShockId) continue; | ||
|
|
||
| _synced.Remove(discordId); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Retain a pending clear until Discord accepts it. If Discord rejects the empty-data PATCH or the request fails, removing 🤖 Prompt for AI Agents |
||
| await widgets.UpdateAsync(applicationId, discordId, synced.OpenShockId, new JsonObject(), ct); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Delete the old application identity when the OpenShock ID changes. If a user re-links from OpenShock ID A to ID B, this PATCH clears A’s profile data but leaves A’s Discord Application Identity in place. Discord requires later profile writes to use an existing identity’s ID. The write for B then receives 🤖 Prompt for AI AgentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a user re-links after a successful sync, this only replaces the old identity's data with Useful? React with 👍 / 👎.
Comment on lines
+82
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the cleanup PATCH fails transiently (for example, Discord rate-limits or is unavailable while a user unlinks or re-links), this removes the only record of the old identity before ignoring the result. On all later ticks that user is absent or has a different ID, so no further clear is attempted and the stale widget data remains permanently; unlike the normal update path, this cleanup has no retry. Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| var updates = new List<UserControlStatTotals>(); | ||
| DateTime? cursor = null; | ||
|
|
||
| if (_cursor is { } since && _synced.Count > 0) | ||
| { | ||
| var changes = await stats.GetChangesAsync(since, _synced.Values.Select(x => x.OpenShockId).ToArray(), ct); | ||
| updates.AddRange(changes.Users); | ||
| cursor = changes.Cursor; | ||
| } | ||
|
|
||
| var fresh = authorized.Where(x => !_synced.ContainsKey(x.Key)).Select(x => x.Value).ToArray(); | ||
| if (fresh.Length > 0) | ||
| { | ||
| var initial = await stats.GetChangesAsync(null, fresh, ct); | ||
| updates.AddRange(initial.Users); | ||
| if (cursor is null || initial.Cursor < cursor) cursor = initial.Cursor; | ||
| } | ||
|
|
||
| var discordIds = authorized.ToDictionary(x => x.Value, x => x.Key); | ||
| foreach (var totals in updates) | ||
| { | ||
| if (!discordIds.TryGetValue(totals.UserId, out var discordId)) continue; | ||
|
|
||
| var data = BuildWidgetData(totals); | ||
| var serialized = data.ToJsonString(); | ||
| if (_synced.TryGetValue(discordId, out var synced) && synced.Data == serialized) continue; | ||
|
|
||
| switch (await widgets.UpdateAsync(applicationId, discordId, totals.UserId, data, ct)) | ||
| { | ||
| case ProfileWidgetResult.Success: | ||
| _synced[discordId] = new SyncedWidget(totals.UserId, serialized); | ||
| break; | ||
| case ProfileWidgetResult.Unauthorized: | ||
| _synced.Remove(discordId); | ||
| await db.Users.Where(x => x.DiscordId == discordId) | ||
| .ExecuteUpdateAsync(s => s.SetProperty(x => x.WidgetAuthorized, false), ct); | ||
| break; | ||
| default: | ||
| // Forgotten users get a full sync next tick, which retries the write | ||
| _synced.Remove(discordId); | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if (cursor is not null) _cursor = cursor; | ||
| } | ||
|
|
||
| private bool IsSyncedServer(string apiServer) => | ||
| Uri.TryCreate(apiServer, UriKind.Absolute, out var uri) && | ||
| Uri.Compare(uri, _config.ApiServer, UriComponents.SchemeAndServer, UriFormat.Unescaped, | ||
| StringComparison.OrdinalIgnoreCase) == 0; | ||
|
|
||
| /// <summary> | ||
| /// The widget's dynamic fields. Their names are the "User Data" keys the widget layout maps in the Developer | ||
| /// Portal, so renaming one needs the layout updated too. | ||
| /// </summary> | ||
| private static JsonObject BuildWidgetData(UserControlStatTotals totals) | ||
| { | ||
| var fields = new JsonArray(); | ||
|
|
||
| foreach (var (direction, rows) in new[] { ("received", totals.Received), ("given", totals.Given) }) | ||
| { | ||
| var shocks = rows.Where(x => x.Type == "Shock").ToArray(); | ||
|
|
||
| AddNumber(fields, $"shocks_{direction}", shocks.Sum(x => x.Count)); | ||
| AddNumber(fields, $"vibrations_{direction}", rows.Where(x => x.Type == "Vibrate").Sum(x => x.Count)); | ||
| AddNumber(fields, $"sounds_{direction}", rows.Where(x => x.Type == "Sound").Sum(x => x.Count)); | ||
| AddNumber(fields, $"shock_seconds_{direction}", Math.Round(shocks.Sum(x => x.DurationMs) / 1000.0, 1)); | ||
| AddNumber(fields, $"max_shock_intensity_{direction}", shocks.Select(x => (int)x.MaxIntensity).DefaultIfEmpty().Max()); | ||
| } | ||
|
|
||
| return new JsonObject { ["dynamic"] = fields }; | ||
| } | ||
|
|
||
| private static void AddNumber(JsonArray fields, string name, JsonNode value) => | ||
| fields.Add(new JsonObject { ["type"] = 2, ["name"] = name, ["value"] = value }); | ||
|
|
||
| private sealed record SyncedWidget(Guid OpenShockId, string Data); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Preserve identity mappings across bot restarts.
If a user unlinks while the bot is stopped, the next process starts with an empty
_synceddictionary. The database no longer contains that user, so the service cannot find or clear the previously written Discord profile. Persist the written identity mapping, or clear the Discord identity in the unlink flow. The PR summary also identifies this restart gap.🤖 Prompt for AI Agents