From 09625335f0701e502f0993c2d0a876195cc8897f Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 4 Nov 2025 23:40:57 +0800 Subject: [PATCH] :sparkles: Steam presence service --- .../Account/Presences/SteamPresenceService.cs | 250 ++++++++++++++++++ .../Startup/ServiceCollectionExtensions.cs | 2 + 2 files changed, 252 insertions(+) create mode 100644 DysonNetwork.Pass/Account/Presences/SteamPresenceService.cs diff --git a/DysonNetwork.Pass/Account/Presences/SteamPresenceService.cs b/DysonNetwork.Pass/Account/Presences/SteamPresenceService.cs new file mode 100644 index 0000000..23fe6ad --- /dev/null +++ b/DysonNetwork.Pass/Account/Presences/SteamPresenceService.cs @@ -0,0 +1,250 @@ +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore; +using NodaTime; +using SteamWebAPI2.Interfaces; +using SteamWebAPI2.Utilities; + +namespace DysonNetwork.Pass.Account.Presences; + +public class SteamPresenceService( + AppDatabase db, + AccountEventService accountEventService, + ILogger logger, + IConfiguration configuration +) : IPresenceService +{ + /// + public string ServiceId => "steam"; + + /// + public async Task UpdatePresencesAsync(IEnumerable userIds) + { + var userIdList = userIds.ToList(); + var steamConnections = await db.AccountConnections + .Where(c => userIdList.Contains(c.AccountId) && c.Provider == "steam") + .Include(c => c.Account) + .ToListAsync(); + + if (steamConnections.Count == 0) + return; + + // Get Steam API key from configuration + var apiKey = configuration["Oidc:Steam:ApiKey"]; + if (string.IsNullOrEmpty(apiKey)) + { + logger.LogWarning("Steam API key not configured, skipping presence update for {Count} users", steamConnections.Count); + return; + } + + try + { + // Create Steam Web API client + var webInterfaceFactory = new SteamWebInterfaceFactory(apiKey); + var steamUserInterface = webInterfaceFactory.CreateSteamWebInterface(); + + // Collect all Steam IDs for batch request + var steamIds = steamConnections + .Select(c => ulong.Parse(c.ProvidedIdentifier)) + .ToList(); + + // Make batch API call (Steam supports up to 100 IDs per request) + var playerSummariesResponse = await steamUserInterface.GetPlayerSummariesAsync(steamIds); + var playerSummaries = playerSummariesResponse?.Data != null + ? (IEnumerable)playerSummariesResponse.Data + : new List(); + + // Create a lookup dictionary for quick access + var playerSummaryLookup = playerSummaries + .ToDictionary(ps => ((dynamic)ps).SteamId.ToString(), ps => ps); + + // Process each connection + foreach (var connection in steamConnections) + { + if (playerSummaryLookup.TryGetValue(connection.ProvidedIdentifier, out var playerSummaryData)) + { + await UpdateSteamPresenceFromDataAsync(connection.Account, playerSummaryData); + } + else + { + // No data for this user, remove any existing presence + await RemoveSteamPresenceAsync(connection.Account.Id); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update Steam presence for {Count} users", steamConnections.Count); + + // On batch error, fall back to individual calls to avoid losing all presence data + foreach (var connection in steamConnections) + { + try + { + await UpdateSteamPresenceAsync(connection.Account, connection.ProvidedIdentifier); + } + catch (Exception individualEx) + { + logger.LogError(individualEx, "Failed to update Steam presence for user {UserId}", connection.Account.Id); + await RemoveSteamPresenceAsync(connection.Account.Id); + } + } + } + } + + /// + /// Updates the Steam presence activity for a specific user using pre-fetched player summary data + /// + private async Task UpdateSteamPresenceFromDataAsync(SnAccount account, dynamic playerSummaryData) + { + if (!string.IsNullOrEmpty(playerSummaryData.PlayingGameId) && !string.IsNullOrEmpty(playerSummaryData.PlayingGameName)) + { + // User is playing a game + var presenceActivity = ParsePlayerSummaryToPresenceActivity(account.Id, playerSummaryData); + + // Try to update existing activity first + var updatedActivity = await accountEventService.UpdateActivityByManualId( + "steam", + account.Id, + UpdateActivityWithPresenceData, + 5 + ); + + // If update failed (no existing activity), create a new one + if (updatedActivity == null) + await accountEventService.SetActivity(presenceActivity, 5); + + // Local function to avoid capturing external variables in lambda + void UpdateActivityWithPresenceData(SnPresenceActivity activity) + { + activity.Type = PresenceType.Gaming; + activity.Title = presenceActivity.Title; + activity.Subtitle = presenceActivity.Subtitle; + activity.Caption = presenceActivity.Caption; + activity.LargeImage = presenceActivity.LargeImage; + activity.SmallImage = presenceActivity.SmallImage; + activity.TitleUrl = presenceActivity.TitleUrl; + activity.SubtitleUrl = presenceActivity.SubtitleUrl; + activity.Meta = presenceActivity.Meta; + } + } + else + { + // User is not playing a game, remove any existing Steam presence + await RemoveSteamPresenceAsync(account.Id); + } + } + + /// + /// Updates the Steam presence activity for a specific user (fallback individual API call) + /// + private async Task UpdateSteamPresenceAsync(SnAccount account, string steamId) + { + try + { + // Get Steam API key from configuration + var apiKey = configuration["Oidc:Steam:ApiKey"]; + if (string.IsNullOrEmpty(apiKey)) + { + logger.LogWarning("Steam API key not configured, skipping presence update for user {UserId}", account.Id); + return; + } + + // Create Steam Web API client + var webInterfaceFactory = new SteamWebInterfaceFactory(apiKey); + var steamUserInterface = webInterfaceFactory.CreateSteamWebInterface(); + + // Get player summary + var playerSummaryResponse = await steamUserInterface.GetPlayerSummaryAsync(ulong.Parse(steamId)); + var playerSummaryData = playerSummaryResponse.Data; + + if (!string.IsNullOrEmpty(playerSummaryData.PlayingGameId) && !string.IsNullOrEmpty(playerSummaryData.PlayingGameName)) + { + // User is playing a game + var presenceActivity = ParsePlayerSummaryToPresenceActivity(account.Id, playerSummaryData); + + // Try to update existing activity first + var updatedActivity = await accountEventService.UpdateActivityByManualId( + "steam", + account.Id, + UpdateActivityWithPresenceData, + 5 + ); + + // If update failed (no existing activity), create a new one + if (updatedActivity == null) + await accountEventService.SetActivity(presenceActivity, 5); + + // Local function to avoid capturing external variables in lambda + void UpdateActivityWithPresenceData(SnPresenceActivity activity) + { + activity.Type = PresenceType.Gaming; + activity.Title = presenceActivity.Title; + activity.Subtitle = presenceActivity.Subtitle; + activity.Caption = presenceActivity.Caption; + activity.LargeImage = presenceActivity.LargeImage; + activity.SmallImage = presenceActivity.SmallImage; + activity.TitleUrl = presenceActivity.TitleUrl; + activity.SubtitleUrl = presenceActivity.SubtitleUrl; + activity.Meta = presenceActivity.Meta; + } + } + else + { + // User is not playing a game, remove any existing Steam presence + await RemoveSteamPresenceAsync(account.Id); + } + } + catch (Exception ex) + { + // On error, remove the presence to avoid stale data + await RemoveSteamPresenceAsync(account.Id); + + logger.LogError(ex, "Failed to update Steam presence for user {UserId}", account.Id); + } + } + + /// + /// Removes the Steam presence activity for a user + /// + private async Task RemoveSteamPresenceAsync(Guid accountId) + { + await accountEventService.UpdateActivityByManualId( + "steam", + accountId, + activity => + { + // Mark it for immediate expiration + activity.LeaseExpiresAt = SystemClock.Instance.GetCurrentInstant(); + } + ); + } + + private static SnPresenceActivity ParsePlayerSummaryToPresenceActivity(Guid accountId, dynamic playerSummary) + { + var gameName = playerSummary.PlayingGameName ?? "Unknown Game"; + var gameId = playerSummary.PlayingGameId?.ToString() ?? ""; + + // Build metadata + var meta = new Dictionary + { + ["game_id"] = gameId, + ["steam_profile_url"] = $"https://steamcommunity.com/profiles/{playerSummary.SteamId}", + ["updated_at"] = SystemClock.Instance.GetCurrentInstant() + }; + + return new SnPresenceActivity + { + AccountId = accountId, + Type = PresenceType.Gaming, + ManualId = "steam", + Title = gameName, + Subtitle = "Playing on Steam", + Caption = null, // Could be game details if available + LargeImage = null, // Could fetch game icon from Steam API if needed + SmallImage = null, + TitleUrl = $"https://store.steampowered.com/app/{gameId}", + SubtitleUrl = $"https://steamcommunity.com/profiles/{playerSummary.SteamId}", + Meta = meta + }; + } +} diff --git a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs index 0138a52..b393690 100644 --- a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs @@ -161,7 +161,9 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.Configure(configuration.GetSection("OidcProvider")); services.AddScoped();