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`.