From e7f055c50bc18c3a4b95413774537b21590766ef Mon Sep 17 00:00:00 2001 From: LucHeart Date: Sun, 11 Oct 2026 17:49:23 +0200 Subject: [PATCH] feat: sync OpenShock control stats to Discord profile widgets The Activity now requests application_identities.write and records the grant per user. The bot's WidgetSyncService asks the OpenShock API once per tick for the users whose control stats changed (POST /1/integrations/control-stats/changes, automation token with ControlStatsRead) and writes them to the Game Stats Widget via the Application Identity Profile API, only when the data actually changed. Co-Authored-By: Claude Opus 5.5 --- DiscordBot/DiscordBotConfig.cs | 16 +- DiscordBot/Program.cs | 12 + .../Services/Widget/ControlStatsClient.cs | 60 +++ .../Services/Widget/ProfileWidgetClient.cs | 57 +++ .../Services/Widget/WidgetSyncService.cs | 164 ++++++++ .../Auth/ClaimsExtensions.cs | 7 + .../Auth/DiscordOAuthService.cs | 12 +- .../Auth/JwtTokenService.cs | 14 +- .../Controllers/AuthController.cs | 15 +- .../Controllers/UserController.cs | 5 +- ...1011154810_AddWidgetAuthorized.Designer.cs | 354 ++++++++++++++++++ .../20261011154810_AddWidgetAuthorized.cs | 29 ++ .../OpenShockDiscordContextModelSnapshot.cs | 8 +- .../OpenShockDiscordContext.cs | 3 + .../OpenShockDiscordDb/User.cs | 6 + activity/src/lib/discord.ts | 6 +- docs/discord-activity.md | 37 +- 17 files changed, 789 insertions(+), 16 deletions(-) create mode 100644 DiscordBot/Services/Widget/ControlStatsClient.cs create mode 100644 DiscordBot/Services/Widget/ProfileWidgetClient.cs create mode 100644 DiscordBot/Services/Widget/WidgetSyncService.cs create mode 100644 OpenShock.DiscordBot.Shared/Migrations/20261011154810_AddWidgetAuthorized.Designer.cs create mode 100644 OpenShock.DiscordBot.Shared/Migrations/20261011154810_AddWidgetAuthorized.cs diff --git a/DiscordBot/DiscordBotConfig.cs b/DiscordBot/DiscordBotConfig.cs index 862fffd..d3c41c6 100644 --- a/DiscordBot/DiscordBotConfig.cs +++ b/DiscordBot/DiscordBotConfig.cs @@ -3,4 +3,18 @@ public sealed class DiscordBotConfig { public required string Token { get; init; } -} \ No newline at end of file + + public WidgetSyncConfig Widget { get; init; } = new(); +} + +/// Keeps users' Discord profile widgets in sync with their OpenShock control stats. +public sealed class WidgetSyncConfig +{ + /// The OpenShock API stats are read from. Users linked to any other server are skipped. + public Uri ApiServer { get; init; } = new("https://api.openshock.app/"); + + /// Automation token granting ControlStatsRead on . Sync is off when unset. + public string? AutomationToken { get; init; } + + public TimeSpan Interval { get; init; } = TimeSpan.FromMinutes(1); +} diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 3fe8d81..90f758e 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -14,6 +14,7 @@ using System.Reflection; using OpenShock.DiscordBot.MessageHandler; using OpenShock.DiscordBot.Services.ProfanityDetector; +using OpenShock.DiscordBot.Services.Widget; try { @@ -101,6 +102,17 @@ httpClient.BaseAddress = new Uri("https://api.openshock.app/"); }); services.AddHostedService(); + + services.AddHttpClient(httpClient => + { + httpClient.DefaultRequestHeaders.Add("User-Agent", "OpenShockDiscordBot/1.0"); + httpClient.BaseAddress = discordBotConfig.Widget.ApiServer; + if (!string.IsNullOrWhiteSpace(discordBotConfig.Widget.AutomationToken)) + httpClient.DefaultRequestHeaders.Add(ControlStatsClient.AutomationTokenHeader, + discordBotConfig.Widget.AutomationToken); + }); + services.AddHttpClient(); + services.AddHostedService(); }); diff --git a/DiscordBot/Services/Widget/ControlStatsClient.cs b/DiscordBot/Services/Widget/ControlStatsClient.cs new file mode 100644 index 0000000..2710989 --- /dev/null +++ b/DiscordBot/Services/Widget/ControlStatsClient.cs @@ -0,0 +1,60 @@ +using System.Net.Http.Json; + +namespace OpenShock.DiscordBot.Services.Widget; + +/// +/// Client for the OpenShock API's integration endpoint that reports users' lifetime control stats, but only +/// for users whose stats changed since a cursor. +/// +public sealed class ControlStatsClient +{ + public const string AutomationTokenHeader = "X-OpenShock-Automation-Token"; + + /// Server side limit of user ids per request. + private const int MaxUserIds = 1000; + + private readonly HttpClient _http; + + public ControlStatsClient(HttpClient http) + { + _http = http; + } + + /// + /// Totals of every user in whose stats changed since , or of + /// all of them when it is null. The returned cursor is the oldest one of all chunks. + /// + public async Task GetChangesAsync(DateTime? since, IReadOnlyCollection userIds, + CancellationToken ct) + { + DateTime? cursor = null; + var users = new List(); + + 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(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 Users); + +public sealed record UserControlStatTotals(Guid UserId, ControlStatTotals[] Received, ControlStatTotals[] Given); + +/// One control type's totals, is the API's enum name (Shock, Vibrate, Sound). +public sealed record ControlStatTotals( + string Type, + bool Live, + long Count, + long DurationMs, + double AverageIntensity, + byte MaxIntensity); diff --git a/DiscordBot/Services/Widget/ProfileWidgetClient.cs b/DiscordBot/Services/Widget/ProfileWidgetClient.cs new file mode 100644 index 0000000..e654947 --- /dev/null +++ b/DiscordBot/Services/Widget/ProfileWidgetClient.cs @@ -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, + + /// The user no longer grants application_identities.write, stop writing until they do again. + Unauthorized, + + /// Anything else (rate limits, outages), worth retrying later. + Failed +} + +/// +/// Writes Game Stats Widget data through Discord's Application Identity Profile API, authorized with the bot token. +/// +public sealed class ProfileWidgetClient +{ + private readonly HttpClient _http; + private readonly DiscordBotConfig _config; + private readonly ILogger _logger; + + public ProfileWidgetClient(HttpClient http, DiscordBotConfig config, ILogger logger) + { + _http = http; + _config = config; + _logger = logger; + } + + /// + /// Replaces the user's widget data for the given OpenShock identity. Discord replaces the whole data object on + /// every PATCH, so must always be complete; an empty object clears it. + /// + public async Task 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; + } +} diff --git a/DiscordBot/Services/Widget/WidgetSyncService.cs b/DiscordBot/Services/Widget/WidgetSyncService.cs new file mode 100644 index 0000000..d2ef069 --- /dev/null +++ b/DiscordBot/Services/Widget/WidgetSyncService.cs @@ -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; + +/// +/// Periodically pushes the OpenShock control stats of users who granted application_identities.write 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. +/// +public sealed class WidgetSyncService : BackgroundService +{ + private readonly IServiceProvider _services; + private readonly DiscordSocketClient _client; + private readonly WidgetSyncConfig _config; + private readonly ILogger _logger; + + /// What was last written per Discord user, so unchanged data and stale identities can be detected. + private readonly Dictionary _synced = []; + + private DateTime? _cursor; + + public WidgetSyncService(IServiceProvider services, DiscordSocketClient client, DiscordBotConfig config, + ILogger 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(); + var stats = scope.ServiceProvider.GetRequiredService(); + var widgets = scope.ServiceProvider.GetRequiredService(); + + 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); + await widgets.UpdateAsync(applicationId, discordId, synced.OpenShockId, new JsonObject(), ct); + } + + var updates = new List(); + 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; + + /// + /// 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. + /// + 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); +} diff --git a/OpenShock.Activity.Api/Auth/ClaimsExtensions.cs b/OpenShock.Activity.Api/Auth/ClaimsExtensions.cs index 68dd9f4..d91167d 100644 --- a/OpenShock.Activity.Api/Auth/ClaimsExtensions.cs +++ b/OpenShock.Activity.Api/Auth/ClaimsExtensions.cs @@ -4,6 +4,9 @@ namespace OpenShock.Activity.Api.Auth; public static class ClaimsExtensions { + /// Set when the session's Discord grant included the profile widget scope. + public const string WidgetClaim = "widget"; + /// The Discord user id, taken from the JWT sub claim. public static ulong GetDiscordId(this ClaimsPrincipal principal) { @@ -15,4 +18,8 @@ public static ulong GetDiscordId(this ClaimsPrincipal principal) /// The Discord display name, taken from the JWT name claim. public static string GetDisplayName(this ClaimsPrincipal principal) => principal.FindFirstValue("name") ?? principal.GetDiscordId().ToString(); + + /// Whether the user granted the profile widget scope when this session was issued. + public static bool HasWidgetGrant(this ClaimsPrincipal principal) + => principal.FindFirstValue(WidgetClaim) == "true"; } diff --git a/OpenShock.Activity.Api/Auth/DiscordOAuthService.cs b/OpenShock.Activity.Api/Auth/DiscordOAuthService.cs index 7ad4774..c9e337e 100644 --- a/OpenShock.Activity.Api/Auth/DiscordOAuthService.cs +++ b/OpenShock.Activity.Api/Auth/DiscordOAuthService.cs @@ -5,13 +5,16 @@ namespace OpenShock.Activity.Api.Auth; -public sealed record DiscordIdentity(ulong Id, string Name, string AccessToken, string? Avatar); +public sealed record DiscordIdentity(ulong Id, string Name, string AccessToken, string? Avatar, bool WidgetAuthorized); public sealed class DiscordOAuthService { private const string TokenUrl = "https://discord.com/api/oauth2/token"; private const string MeUrl = "https://discord.com/api/users/@me"; + /// OAuth2 scope that lets the bot write the user's profile widget (Game Stats Widget) data. + public const string WidgetScope = "application_identities.write"; + private readonly HttpClient _http; private readonly DiscordConfig _cfg; private readonly ILogger _logger; @@ -61,10 +64,13 @@ public DiscordOAuthService(HttpClient http, ActivityApiConfig cfg, ILogger(ct); if (me is null || !ulong.TryParse(me.Id, out var id)) return null; - return new DiscordIdentity(id, me.GlobalName ?? me.Username, token.AccessToken, me.Avatar); + var widgetAuthorized = token.Scope?.Split(' ').Contains(WidgetScope) ?? false; + return new DiscordIdentity(id, me.GlobalName ?? me.Username, token.AccessToken, me.Avatar, widgetAuthorized); } - private sealed record DiscordTokenResponse([property: JsonPropertyName("access_token")] string? AccessToken); + private sealed record DiscordTokenResponse( + [property: JsonPropertyName("access_token")] string? AccessToken, + [property: JsonPropertyName("scope")] string? Scope); private sealed record DiscordMeResponse( [property: JsonPropertyName("id")] string Id, diff --git a/OpenShock.Activity.Api/Auth/JwtTokenService.cs b/OpenShock.Activity.Api/Auth/JwtTokenService.cs index 55f830e..e3488ad 100644 --- a/OpenShock.Activity.Api/Auth/JwtTokenService.cs +++ b/OpenShock.Activity.Api/Auth/JwtTokenService.cs @@ -18,17 +18,21 @@ public JwtTokenService(ActivityApiConfig cfg) public SymmetricSecurityKey SigningKey { get; } - public string Issue(ulong discordId, string name) + public string Issue(ulong discordId, string name, bool widgetAuthorized = false) { + List claims = + [ + new("sub", discordId.ToString()), + new("name", name) + ]; + if (widgetAuthorized) claims.Add(new Claim(ClaimsExtensions.WidgetClaim, "true")); + var descriptor = new SecurityTokenDescriptor { Issuer = _cfg.Issuer, Audience = _cfg.Audience, Expires = DateTime.UtcNow.AddDays(_cfg.LifetimeDays), - Subject = new ClaimsIdentity([ - new Claim("sub", discordId.ToString()), - new Claim("name", name) - ]), + Subject = new ClaimsIdentity(claims), SigningCredentials = new SigningCredentials(SigningKey, SecurityAlgorithms.HmacSha256) }; diff --git a/OpenShock.Activity.Api/Controllers/AuthController.cs b/OpenShock.Activity.Api/Controllers/AuthController.cs index a61337a..cb2179a 100644 --- a/OpenShock.Activity.Api/Controllers/AuthController.cs +++ b/OpenShock.Activity.Api/Controllers/AuthController.cs @@ -1,8 +1,10 @@ using System.Net.Mime; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using OpenShock.Activity.Api.Auth; using OpenShock.Activity.Api.Problems; +using OpenShock.DiscordBot.OpenShockDiscordDb; namespace OpenShock.Activity.Api.Controllers; @@ -11,12 +13,15 @@ public sealed class AuthController : ActivityControllerBase { private readonly DiscordOAuthService _discord; private readonly JwtTokenService _jwt; + private readonly OpenShockDiscordContext _db; private readonly IHostEnvironment _env; - public AuthController(DiscordOAuthService discord, JwtTokenService jwt, IHostEnvironment env) + public AuthController(DiscordOAuthService discord, JwtTokenService jwt, OpenShockDiscordContext db, + IHostEnvironment env) { _discord = discord; _jwt = jwt; + _db = db; _env = env; } @@ -32,7 +37,13 @@ public async Task Token(TokenRequest req, CancellationToken ct) var identity = await _discord.ExchangeCodeAsync(req.Code, ct); if (identity is null) return Problem(AuthError.DiscordExchangeFailed); - var token = _jwt.Issue(identity.Id, identity.Name); + // The bot's widget sync picks flagged users up. Users who aren't linked yet get flagged from the + // session's widget claim in POST /link. + if (identity.WidgetAuthorized) + await _db.Users.Where(x => x.DiscordId == identity.Id && !x.WidgetAuthorized) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.WidgetAuthorized, true), ct); + + var token = _jwt.Issue(identity.Id, identity.Name, identity.WidgetAuthorized); return Ok(new TokenResponse(identity.AccessToken, token, new AuthUserDto(identity.Id.ToString(), identity.Name, identity.Avatar))); } diff --git a/OpenShock.Activity.Api/Controllers/UserController.cs b/OpenShock.Activity.Api/Controllers/UserController.cs index bb5b00c..035bf03 100644 --- a/OpenShock.Activity.Api/Controllers/UserController.cs +++ b/OpenShock.Activity.Api/Controllers/UserController.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using OpenShock.Activity.Api.Problems; using OpenShock.Activity.Api.Realtime; +using OpenShock.Activity.Api.Auth; using OpenShock.DiscordBot.OpenShockDiscordDb; using OpenShock.SDK.CSharp; using OpenShock.SDK.CSharp.Models; @@ -64,7 +65,8 @@ public async Task Link(LinkRequest req, CancellationToken ct) DiscordId = DiscordId, OpenshockId = self.Id, ApiKey = req.ApiToken, - ApiServer = serverUri.ToString() + ApiServer = serverUri.ToString(), + WidgetAuthorized = User.HasWidgetGrant() }); } else @@ -72,6 +74,7 @@ public async Task Link(LinkRequest req, CancellationToken ct) existing.OpenshockId = self.Id; existing.ApiKey = req.ApiToken; existing.ApiServer = serverUri.ToString(); + existing.WidgetAuthorized |= User.HasWidgetGrant(); } await _db.SaveChangesAsync(ct); diff --git a/OpenShock.DiscordBot.Shared/Migrations/20261011154810_AddWidgetAuthorized.Designer.cs b/OpenShock.DiscordBot.Shared/Migrations/20261011154810_AddWidgetAuthorized.Designer.cs new file mode 100644 index 0000000..149c021 --- /dev/null +++ b/OpenShock.DiscordBot.Shared/Migrations/20261011154810_AddWidgetAuthorized.Designer.cs @@ -0,0 +1,354 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.DiscordBot.OpenShockDiscordDb; + +#nullable disable + +namespace OpenShock.DiscordBot.Migrations +{ + [DbContext(typeof(OpenShockDiscordContext))] + [Migration("20261011154810_AddWidgetAuthorized")] + partial class AddWidgetAuthorized + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OpenShock.DiscordBot.OpenShockDiscordDb.BotAdmin", b => + { + b.Property("DiscordId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("discord_id"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsRemovable") + .HasColumnType("boolean") + .HasColumnName("is_removable"); + + b.HasKey("DiscordId") + .HasName("administrators_pkey"); + + b.ToTable("administrators", (string)null); + }); + + modelBuilder.Entity("OpenShock.DiscordBot.OpenShockDiscordDb.ProfanityRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddedByUserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("added_by_user_id"); + + b.Property("Category") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("category"); + + b.Property("Comment") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("comment"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("LanguageCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("language_code"); + + b.Property("MatchWholeWord") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("match_whole_word"); + + b.Property("SeverityScore") + .HasColumnType("real") + .HasColumnName("severity_score"); + + b.Property("Trigger") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("trigger"); + + b.Property("ValidationRegex") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("validation_regex"); + + b.HasKey("Id") + .HasName("profanity_rules_pkey"); + + b.ToTable("profanity_rules", (string)null); + }); + + modelBuilder.Entity("OpenShock.DiscordBot.OpenShockDiscordDb.ProfanitySuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Comment") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("comment"); + + b.Property("LanguageCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("language_code"); + + b.Property("SuggestedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("suggested_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("SuggestedByUserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("suggested_by_user_id"); + + b.Property("Trigger") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("trigger"); + + b.HasKey("Id") + .HasName("profanity_suggestions_pkey"); + + b.ToTable("profanity_suggestions", (string)null); + }); + + modelBuilder.Entity("OpenShock.DiscordBot.OpenShockDiscordDb.RejectedProfanitySuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LanguageCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("language_code"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("reason"); + + b.Property("SuggestedByUserId") + .HasColumnType("numeric(20,0)") + .HasColumnName("suggested_by_user_id"); + + b.Property("Trigger") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("trigger"); + + b.HasKey("Id") + .HasName("rejected_profanity_suggestions_pkey"); + + b.ToTable("rejected_profanity_suggestions", (string)null); + }); + + modelBuilder.Entity("OpenShock.DiscordBot.OpenShockDiscordDb.User", b => + { + b.Property("DiscordId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("discord_id"); + + b.Property("AllowRoomShocks") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasColumnName("allow_room_shocks") + .HasDefaultValueSql("false"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("api_key"); + + b.Property("ApiServer") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("api_server"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OpenshockId") + .HasColumnType("uuid") + .HasColumnName("openshock_id"); + + b.Property("ProfanityShocking") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasColumnName("profanity_shocking") + .HasDefaultValueSql("false"); + + b.Property("RoomMaxDurationMs") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3000) + .HasColumnName("room_max_duration_ms"); + + b.Property("RoomMaxIntensity") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)30) + .HasColumnName("room_max_intensity"); + + b.Property("WidgetAuthorized") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasColumnName("widget_authorized") + .HasDefaultValueSql("false"); + + b.HasKey("DiscordId") + .HasName("users_pkey"); + + b.HasIndex(new[] { "OpenshockId" }, "users_openshock_id") + .IsUnique() + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OpenShock.DiscordBot.OpenShockDiscordDb.UsersFriendwhitelist", b => + { + b.Property("User") + .HasColumnType("numeric(20,0)") + .HasColumnName("user"); + + b.Property("WhitelistedFriend") + .HasColumnType("numeric(20,0)") + .HasColumnName("whitelisted_friend"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("User", "WhitelistedFriend") + .HasName("users_friendwhitelist_pkey"); + + b.HasIndex(new[] { "WhitelistedFriend" }, "friend_id") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.ToTable("users_friendwhitelist", (string)null); + }); + + modelBuilder.Entity("OpenShock.DiscordBot.OpenShockDiscordDb.UsersShocker", b => + { + b.Property("User") + .HasColumnType("numeric(20,0)") + .HasColumnName("user"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("User", "ShockerId") + .HasName("users_shockers_pkey"); + + b.ToTable("users_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.DiscordBot.OpenShockDiscordDb.UsersFriendwhitelist", b => + { + b.HasOne("OpenShock.DiscordBot.OpenShockDiscordDb.User", "UserNavigation") + .WithMany("UsersFriendwhitelists") + .HasForeignKey("User") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user"); + + b.Navigation("UserNavigation"); + }); + + modelBuilder.Entity("OpenShock.DiscordBot.OpenShockDiscordDb.UsersShocker", b => + { + b.HasOne("OpenShock.DiscordBot.OpenShockDiscordDb.User", "UserNavigation") + .WithMany("UsersShockers") + .HasForeignKey("User") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user"); + + b.Navigation("UserNavigation"); + }); + + modelBuilder.Entity("OpenShock.DiscordBot.OpenShockDiscordDb.User", b => + { + b.Navigation("UsersFriendwhitelists"); + + b.Navigation("UsersShockers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/OpenShock.DiscordBot.Shared/Migrations/20261011154810_AddWidgetAuthorized.cs b/OpenShock.DiscordBot.Shared/Migrations/20261011154810_AddWidgetAuthorized.cs new file mode 100644 index 0000000..7c0a5a9 --- /dev/null +++ b/OpenShock.DiscordBot.Shared/Migrations/20261011154810_AddWidgetAuthorized.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OpenShock.DiscordBot.Migrations +{ + /// + public partial class AddWidgetAuthorized : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "widget_authorized", + table: "users", + type: "boolean", + nullable: false, + defaultValueSql: "false"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "widget_authorized", + table: "users"); + } + } +} diff --git a/OpenShock.DiscordBot.Shared/Migrations/OpenShockDiscordContextModelSnapshot.cs b/OpenShock.DiscordBot.Shared/Migrations/OpenShockDiscordContextModelSnapshot.cs index 328f66a..17e64d4 100644 --- a/OpenShock.DiscordBot.Shared/Migrations/OpenShockDiscordContextModelSnapshot.cs +++ b/OpenShock.DiscordBot.Shared/Migrations/OpenShockDiscordContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("ProductVersion", "10.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -252,6 +252,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasDefaultValue((byte)30) .HasColumnName("room_max_intensity"); + b.Property("WidgetAuthorized") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasColumnName("widget_authorized") + .HasDefaultValueSql("false"); + b.HasKey("DiscordId") .HasName("users_pkey"); diff --git a/OpenShock.DiscordBot.Shared/OpenShockDiscordDb/OpenShockDiscordContext.cs b/OpenShock.DiscordBot.Shared/OpenShockDiscordDb/OpenShockDiscordContext.cs index 95ac7f0..069087f 100644 --- a/OpenShock.DiscordBot.Shared/OpenShockDiscordDb/OpenShockDiscordContext.cs +++ b/OpenShock.DiscordBot.Shared/OpenShockDiscordDb/OpenShockDiscordContext.cs @@ -69,6 +69,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.RoomMaxDurationMs) .HasDefaultValue(3000) .HasColumnName("room_max_duration_ms"); + entity.Property(e => e.WidgetAuthorized) + .HasDefaultValueSql("false") + .HasColumnName("widget_authorized"); entity.Property(e => e.OpenshockId).HasColumnName("openshock_id"); }); diff --git a/OpenShock.DiscordBot.Shared/OpenShockDiscordDb/User.cs b/OpenShock.DiscordBot.Shared/OpenShockDiscordDb/User.cs index 6cbd134..c8d2ed3 100644 --- a/OpenShock.DiscordBot.Shared/OpenShockDiscordDb/User.cs +++ b/OpenShock.DiscordBot.Shared/OpenShockDiscordDb/User.cs @@ -27,6 +27,12 @@ public partial class User /// Upper bound in milliseconds applied to room-consent shocks. public int RoomMaxDurationMs { get; set; } = 3000; + /// + /// True once the user granted the application_identities.write OAuth2 scope (via the Discord + /// Activity), which is required before we may push stats to their Discord profile widget. + /// + public bool WidgetAuthorized { get; set; } + public virtual ICollection UsersFriendwhitelists { get; set; } = new List(); public virtual ICollection UsersShockers { get; set; } = new List(); diff --git a/activity/src/lib/discord.ts b/activity/src/lib/discord.ts index d9d008f..fd28888 100644 --- a/activity/src/lib/discord.ts +++ b/activity/src/lib/discord.ts @@ -3,7 +3,7 @@ import { api } from '$lib/api'; import { connectRealtime } from '$lib/realtime'; import { setDiscordParticipants } from '$lib/stores/room.svelte'; import { session } from '$lib/stores/session.svelte'; -import { DiscordSDK } from '@discord/embedded-app-sdk'; +import { DiscordSDK, type Types } from '@discord/embedded-app-sdk'; const CLIENT_ID = PUBLIC_DISCORD_CLIENT_ID; @@ -23,7 +23,9 @@ export async function initDiscord(): Promise { response_type: 'code', state: '', prompt: 'none', - scope: ['identify'], + // application_identities.write lets the bot push stats to the user's Discord profile widget. + // The SDK's OAuthScopes union predates that scope (its RPC schema already knows it), hence the cast. + scope: ['identify', 'application_identities.write' as Types.OAuthScopes], }); const token = await api.exchangeToken(code); diff --git a/docs/discord-activity.md b/docs/discord-activity.md index de49514..c009268 100644 --- a/docs/discord-activity.md +++ b/docs/discord-activity.md @@ -44,7 +44,7 @@ live in `appsettings.json`; secrets come from user-secrets (local) or env vars ## Discord Developer Portal 1. **Activities → Enable** the embedded app. -2. **OAuth2**: scope `identify`. Generate a **client secret** → `Activity:Discord:ClientSecret`. +2. **OAuth2**: scopes `identify` and `application_identities.write` (the latter for the profile widget). Generate a **client secret** → `Activity:Discord:ClientSecret`. 3. **Activities → URL Mappings** (two): - `/` → `activity.openshock.app` (frontend) - `/api` → `activity-api.openshock.app` (API) @@ -52,6 +52,41 @@ live in `appsettings.json`; secrets come from user-secrets (local) or env vars Discord **strips the mapping prefix**, so `/api/me` in the browser reaches the API as `/me`. The API deliberately serves its routes at root; the `/api` prefix is browser-side only. +## Profile widget (Game Stats Widget) + +Users who open the Activity grant `application_identities.write`, which lets the bot write their OpenShock +control stats to their Discord profile widget through the +[Application Identity Profile API](https://docs.discord.com/developers/resources/application-identity-profile). +The Activity API only records the grant (`users.widget_authorized`); the **bot** does the syncing. + +`WidgetSyncService` (bot) ticks every `bot:Widget:Interval` (default 1 min) and makes **one** request per tick +to the OpenShock API's `POST /1/integrations/control-stats/changes`, authorized with an automation token +granting `ControlStatsRead` (`bot:Widget:AutomationToken`, create it in the admin panel). The API answers with +the lifetime totals of only those users whose stats changed since the previous cursor; users the bot sees for +the first time get a full sync. A widget is only written when its data differs from what was last written, +cleared when the user unlinks or re-links another account, and the grant is dropped if Discord answers `403`. +Users linked to a server other than `bot:Widget:ApiServer` (default `https://api.openshock.app/`) are skipped. + +| Config (bot) | Notes | +| --- | --- | +| `bot:Widget:AutomationToken` | Automation token with `ControlStatsRead`. Sync is off when unset. **Secret.** | +| `bot:Widget:ApiServer` | OpenShock API to read stats from. | +| `bot:Widget:Interval` | Sync interval, e.g. `00:01:00`. | + +Map these **User Data** keys in the Developer Portal widget layout (all number dynamic fields; *received* is +activity on the user's own shockers, *given* is what they sent, their own shockers included): + +| Key | Value | +| --- | --- | +| `shocks_received` / `shocks_given` | Shock count | +| `vibrations_received` / `vibrations_given` | Vibrate count | +| `sounds_received` / `sounds_given` | Sound count | +| `shock_seconds_received` / `shock_seconds_given` | Total shock duration in seconds | +| `max_shock_intensity_received` / `max_shock_intensity_given` | Highest shock intensity | + +Users who only ever used the bot's slash commands haven't granted the scope, so their widget stays empty +until they open the Activity once. + ## Hosting - **Frontend:** Cloudflare Pages project (build `activity/`, adapter-cloudflare) on `activity.openshock.app`.