diff --git a/.github/scripts/coverage.ps1 b/.github/scripts/coverage.ps1 index 226331d0..00f8d485 100644 --- a/.github/scripts/coverage.ps1 +++ b/.github/scripts/coverage.ps1 @@ -5,7 +5,7 @@ try { Remove-Item -Path "SubathonManager.Tests\TestResults\*" -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path "coverage-local" -Recurse -Force -ErrorAction SilentlyContinue - dotnet test SubathonManager.Tests\SubathonManager.Tests.csproj ` + dotnet test --project SubathonManager.Tests\SubathonManager.Tests.csproj ` --results-directory coverage-local ` --coverage ` --coverage-output-format cobertura ` diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1f4d46aa..318edcbb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -321,7 +321,7 @@ jobs: -p:FileVersion=${{ steps.avars.outputs.file_version }} -p:AssemblyVersion=${{ steps.avars.outputs.file_version }} -p:InformationalVersion=${{ steps.avars.outputs.version }} - -p:Copyright="© 2025–${{ env.CopyrightYear }} WolfwithSword and contributors" + -p:Copyright="© 2025-${{ env.CopyrightYear }} WolfwithSword and contributors" -o build-avalonia - name: Copy presets, resources folder and license into build output @@ -611,7 +611,7 @@ jobs: else LINK="-" fi - echo "| \`${RUNTIME}\` | $(icon "$STATUS") | ${STORAGE:-–} | ${LINK} |" + echo "| \`${RUNTIME}\` | $(icon "$STATUS") | ${STORAGE:--} | ${LINK} |" done if [ "$found" -eq 0 ]; then echo "| - | no platform builds ran | - | - |" diff --git a/.gitignore b/.gitignore index a700706a..9feeb1ff 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ presets/** !.idea/inspectionProfiles/ !.idea/runConfigurations/ +*cobertura.xml + resources/HONSE/* ## Ignore Visual Studio temporary files, build results, and @@ -227,6 +229,7 @@ obj TestResults/ coverage-report/ +coverage-local/ # Stream Deck plugin packaging stuff *.streamDeckPlugin diff --git a/SubathonManager.Core/AppServices.cs b/SubathonManager.Core/AppServices.cs index bf0c2e54..daa67139 100644 --- a/SubathonManager.Core/AppServices.cs +++ b/SubathonManager.Core/AppServices.cs @@ -1,128 +1,116 @@ -using Updatum; +using System.Diagnostics.CodeAnalysis; using System.Reflection; -using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; -namespace SubathonManager.Core { - - public static class AppServices - { - public static IServiceProvider Provider { get; set; } = default!; - - public static string AppVersion - { - get - { - var ver = Assembly.GetExecutingAssembly() - .GetCustomAttribute()? - .InformationalVersion ?? "dev"; - - var plusIndex = ver.IndexOf('+'); - return plusIndex > 0 && ver.StartsWith('v') ? ver[..plusIndex] : ver; - } - } +using Updatum; + +namespace SubathonManager.Core; + +public static class AppServices { + private static readonly UpdatumManager AppUpdater = new("WolfwithSword", "SubathonManager") { + AssetExtensionFilter = "zip", + CurrentVersion = GetVersion(), + FetchOnlyLatestRelease = true, + AssetRegexPattern = GetAssetRegexPattern() + }; - private static readonly UpdatumManager AppUpdater = new("WolfwithSword", "SubathonManager") - { - AssetExtensionFilter = "zip", - CurrentVersion = GetVersion(), - FetchOnlyLatestRelease = true, - AssetRegexPattern = GetAssetRegexPattern(), - }; - - private static string GetAssetRegexPattern() - { - string rid = - OperatingSystem.IsWindows() ? "win-x64" : - OperatingSystem.IsLinux() ? "linux-x64" : - OperatingSystem.IsMacOS() - ? (RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "osx-arm64" : "osx-x64") - : string.Empty; - - return string.IsNullOrEmpty(rid) ? string.Empty : $@"(?i).*{rid}.*\.zip$"; + public static IServiceProvider Provider { get; set; } = default!; + + public static string AppVersion { + get { + string ver = Assembly.GetExecutingAssembly() + .GetCustomAttribute()? + .InformationalVersion ?? "dev"; + + int plusIndex = ver.IndexOf('+'); + return plusIndex > 0 && ver.StartsWith('v') ? ver[..plusIndex] : ver; } + } - private static Version GetVersion() - { - if (string.IsNullOrWhiteSpace(AppVersion)) - throw new ArgumentException("Version string cannot be null or empty.", nameof(AppVersion)); + private static string GetAssetRegexPattern() { + string rid = + OperatingSystem.IsWindows() + ? "win-x64" + : OperatingSystem.IsLinux() + ? "linux-x64" + : OperatingSystem.IsMacOS() + ? RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "osx-arm64" : "osx-x64" + : string.Empty; + + return string.IsNullOrEmpty(rid) ? string.Empty : $@"(?i).*{rid}.*\.zip$"; + } + + private static Version GetVersion() { + if (string.IsNullOrWhiteSpace(AppVersion)) + throw new ArgumentException("Version string cannot be null or empty.", nameof(AppVersion)); - string input = AppVersion.Trim(); - if (input.StartsWith("v", StringComparison.OrdinalIgnoreCase)) - input = input.Substring(1); + string input = AppVersion.Trim(); + if (input.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + input = input.Substring(1); - var parts = input.Split('.', StringSplitOptions.RemoveEmptyEntries); + string[] parts = input.Split('.', StringSplitOptions.RemoveEmptyEntries); - int major = parts.Length > 0 && int.TryParse(parts[0], out var m) ? m : 0; - int minor = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n : 0; - int build = parts.Length > 2 && int.TryParse(parts[2], out var b) ? b : 0; - int revision = parts.Length > 3 && int.TryParse(parts[3], out var r) ? r : 0; + int major = parts.Length > 0 && int.TryParse(parts[0], out int m) ? m : 0; + int minor = parts.Length > 1 && int.TryParse(parts[1], out int n) ? n : 0; + int build = parts.Length > 2 && int.TryParse(parts[2], out int b) ? b : 0; + int revision = parts.Length > 3 && int.TryParse(parts[3], out int r) ? r : 0; + + return new Version(major, minor, build, revision); + } - return new Version(major, minor, build, revision); + public static async Task<(bool, string?, string?)> CheckForUpdate(ILogger? logger) { + try { + bool updateFound = await AppUpdater.CheckForUpdatesAsync(); + if (AppUpdater.LatestReleaseTagVersionStr == AppVersion) updateFound = false; + if (updateFound) + logger?.LogInformation( + $"SubathonManager found an update ({AppUpdater.LatestReleaseTagVersionStr}) from current {AppVersion}." + + $" Changelog: {AppUpdater.GetChangelog()}"); + return (updateFound, AppUpdater.LatestReleaseTagVersionStr, + $"{AppUpdater.RepositoryUrl}/releases/{AppUpdater.LatestReleaseTagVersionStr}"); + } + catch (Exception ex) { + logger?.LogWarning(ex, "Failed to check for updates"); } - public static async Task<(bool, string?, string?)> CheckForUpdate(ILogger? logger) - { - - try - { - bool updateFound = await AppUpdater.CheckForUpdatesAsync(); - if (AppUpdater.LatestReleaseTagVersionStr == AppVersion) updateFound = false; - if (updateFound) - logger?.LogInformation($"SubathonManager found an update ({AppUpdater.LatestReleaseTagVersionStr}) from current {AppVersion}." + - $" Changelog: {AppUpdater.GetChangelog()}"); - return (updateFound, AppUpdater.LatestReleaseTagVersionStr, $"{AppUpdater.RepositoryUrl}/releases/{AppUpdater.LatestReleaseTagVersionStr}"); - } - catch (Exception ex) - { - logger?.LogWarning(ex, "Failed to check for updates"); + return (false, null, null); + } + + public static async Task DownloadUpdate(ILogger? logger) { + try { + UpdatumDownloadedAsset? downloads = await AppUpdater.DownloadUpdateAsync(); + if (downloads == null) { + logger?.LogError("SubathonManager found an update but failed to download."); + return null; } - return (false, null, null); + + return downloads; + } + catch (Exception ex) { + logger?.LogWarning(ex, "Failed to download update"); } - public static async Task DownloadUpdate(ILogger? logger) - { - try - { - var downloads = await AppUpdater.DownloadUpdateAsync(); - if (downloads == null) - { - logger?.LogError($"SubathonManager found an update but failed to download."); - return null; - } + return null; + } - return downloads; + [ExcludeFromCodeCoverage] + public static async Task InstallUpdate(UpdatumDownloadedAsset? asset, ILogger? logger) { + if (asset == null) + return false; - } - catch (Exception ex) - { - logger?.LogWarning(ex, "Failed to download update"); - } - return null; + try { + await AppUpdater.InstallUpdateAsync(asset); + return true; } - - [ExcludeFromCodeCoverage] - public static async Task InstallUpdate(UpdatumDownloadedAsset? asset, ILogger? logger) - { - if (asset == null) - return false; - - try - { - await AppUpdater.InstallUpdateAsync(asset, true); - return true; - } - catch (Exception ex) - { - logger?.LogWarning(ex, "Failed to install update"); - } - return false; + catch (Exception ex) { + logger?.LogWarning(ex, "Failed to install update"); } - public static async Task DownloadAndInstall(ILogger? logger) - { - var asset = await DownloadUpdate(logger); - return await InstallUpdate(asset, logger); - } + return false; + } + + public static async Task DownloadAndInstall(ILogger? logger) { + UpdatumDownloadedAsset? asset = await DownloadUpdate(logger); + return await InstallUpdate(asset, logger); } } \ No newline at end of file diff --git a/SubathonManager.Core/Config.cs b/SubathonManager.Core/Config.cs index 24769b5a..0a5ba17a 100644 --- a/SubathonManager.Core/Config.cs +++ b/SubathonManager.Core/Config.cs @@ -1,193 +1,168 @@ -using IniParser; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using IniParser; using IniParser.Model; -using System.Diagnostics.CodeAnalysis; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; -namespace SubathonManager.Core -{ - [ExcludeFromCodeCoverage] - public class Config : IConfig - { - private static readonly string ConfigPath = Path.GetFullPath(Path.Combine(string.Empty - , "data/config.ini")); - - public static readonly string DataFolder = Path.GetFullPath(Path.Combine(string.Empty - , "data")); - - public static readonly string DatabasePath = Path.Combine(Path.GetFullPath(Path.Combine(string.Empty, - "data")), "subathonmanager.db"); - - public static readonly string AppFolder = Path.GetFullPath("."); - - private static readonly FileIniDataParser Parser = new(); - private static IniData Data { get; set; } = new(); - - public static string TwitchClientId { get; } = "jsykjc9k0yqkbqg4ttsfgnwwqmoxfh"; - - public bool PendingChanges { get; private set; } - - public virtual IniParser.Model.KeyDataCollection GetSection(string section) - { - return Data[section]; - } - - public bool MigrateConfig() - { - bool migrated = false; - if (GetSection("Twitch").Any(k => k.KeyName.StartsWith("Commands."))) - { - foreach (var keyData in GetSection("Twitch").Where(k => k.KeyName.StartsWith("Commands."))) - { - Set("Chat", keyData.KeyName, keyData.Value); - GetSection("Twitch").RemoveKey(keyData.KeyName); - migrated = true; - } - } +namespace SubathonManager.Core; + +[ExcludeFromCodeCoverage] +public class Config : IConfig { + private static readonly string ConfigPath = Path.GetFullPath(Path.Combine(string.Empty + , "data/config.ini")); + + public static readonly string DataFolder = Path.GetFullPath(Path.Combine(string.Empty + , "data")); + + public static readonly string DatabasePath = Path.Combine(Path.GetFullPath(Path.Combine(string.Empty, + "data")), "subathonmanager.db"); + + public static readonly string AppFolder = Path.GetFullPath("."); + + private static readonly FileIniDataParser Parser = new(); + private static IniData Data { get; set; } = new(); - if (GetSection("Twitch").ContainsKey("BitsAsDonation")) - { - Set("Currency", "BitsLikeAsDonation", Get("Twitch", "BitsAsDonation", "False")); - GetSection("Twitch").RemoveKey("BitsAsDonation"); + public static string TwitchClientId { get; } = "jsykjc9k0yqkbqg4ttsfgnwwqmoxfh"; + + public bool PendingChanges { get; private set; } + + public virtual KeyDataCollection GetSection(string section) { + return Data[section]; + } + + public bool MigrateConfig() { + var migrated = false; + if (GetSection("Twitch").Any(k => k.KeyName.StartsWith("Commands."))) + foreach (KeyData? keyData in GetSection("Twitch").Where(k => k.KeyName.StartsWith("Commands."))) { + Set("Chat", keyData.KeyName, keyData.Value); + GetSection("Twitch").RemoveKey(keyData.KeyName); migrated = true; } - if (migrated) Save(); - return migrated; + if (GetSection("Twitch").ContainsKey("BitsAsDonation")) { + Set("Currency", "BitsLikeAsDonation", Get("Twitch", "BitsAsDonation", "False")); + GetSection("Twitch").RemoveKey("BitsAsDonation"); + migrated = true; } - - public virtual void LoadOrCreateDefault() - { - string folder = Path.GetFullPath(Path.Combine(string.Empty, - "data")); - Directory.CreateDirectory(folder); - - Console.WriteLine($"[Config] Checking config at {ConfigPath}"); - var dir = Path.GetDirectoryName(ConfigPath); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - if (!File.Exists(ConfigPath)) - { - Console.WriteLine("[Config] Creating new config.ini..."); - CreateDefault(); - Save(); - } - else - { - try - { - Data = Parser.ReadFile(ConfigPath); - } - catch (Exception ex) - { - Console.WriteLine($"[Config] Failed to read config.ini: {ex.Message}"); - Console.WriteLine("[Config] Recreating config with defaults."); - CreateDefault(); - Save(); - } - } - if (string.IsNullOrWhiteSpace(Get("Currency", "Primary"))) - { - Set("Currency", "Primary", "USD"); + if (migrated) Save(); + return migrated; + } + + public virtual void LoadOrCreateDefault() { + string folder = Path.GetFullPath(Path.Combine(string.Empty, + "data")); + Directory.CreateDirectory(folder); + + Console.WriteLine($"[Config] Checking config at {ConfigPath}"); + string? dir = Path.GetDirectoryName(ConfigPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + if (!File.Exists(ConfigPath)) { + Console.WriteLine("[Config] Creating new config.ini..."); + CreateDefault(); + Save(); + } + else { + try { + Data = Parser.ReadFile(ConfigPath); + } + catch (Exception ex) { + Console.WriteLine($"[Config] Failed to read config.ini: {ex.Message}"); + Console.WriteLine("[Config] Recreating config with defaults."); + CreateDefault(); Save(); } } - private void CreateDefault() - { - Data = new IniData(); - Data["Server"]["Port"] = "14040"; - - Data["Discord"]["Events.WebhookUrl"] = ""; - Data["Discord"]["WebhookUrl"] = ""; - - foreach (Core.Enums.SubathonEventType type in Enum.GetValues(typeof(Core.Enums.SubathonEventType))) - { - if (type.GetLegacyGoAffProSiteId() > 0) continue; // retired per-store values - Data["Discord"][$"Events.Log.{type}"] = $"{false}"; - } - Data["Discord"]["Events.Log.Simulated"] = $"{false}"; - Data["Currency"]["Primary"] = "USD"; + if (string.IsNullOrWhiteSpace(Get("Currency", "Primary"))) { + Set("Currency", "Primary", "USD"); + Save(); } + } + + public virtual void Save() { + Parser.WriteFile(ConfigPath, Data); + PendingChanges = false; + SettingsEvents.RaiseSettingsUnsavedChanges(PendingChanges); + } + + public virtual string GetDatabasePath() { + return DatabasePath; + } + + public virtual string? Get(string section, string key, string? defaultValue = "") { + return Data[section][key] ?? defaultValue; + } + + public virtual bool GetBool(string section, string key, bool defaultValue = false) { + bool val = bool.TryParse(Get(section, key, defaultValue.ToString()), out bool b) ? b : defaultValue; + return val; + } - public virtual void Save() - { - Parser.WriteFile(ConfigPath, Data); - PendingChanges = false; + public string? GetFromEncoded(string section, string key, string? defaultValue = "") { + string val = Get(section, key, defaultValue) ?? string.Empty; + byte[] fromBase64String = Convert.FromBase64String(val); + return Encoding.UTF8.GetString(fromBase64String); + } + + public virtual bool Set(string section, string key, string? value) { + if (Data[section][key] != value) { + Data[section][key] = value ?? string.Empty; + PendingChanges = true; SettingsEvents.RaiseSettingsUnsavedChanges(PendingChanges); + return true; } - public virtual string GetDatabasePath() - { - return DatabasePath; - } + return false; + } - public virtual string? Get(string section, string key, string? defaultValue = "") - { - return Data[section][key] ?? defaultValue; - } + public virtual bool SetBool(string section, string key, bool? value) { + value ??= false; + return Set(section, key, ((bool)value).ToString()); + } - public virtual bool GetBool(string section, string key, bool defaultValue = false) - { - bool val = Boolean.TryParse(Get(section, key, defaultValue.ToString()), out bool b) ? b : defaultValue; - return val; - } + public bool SetEncoded(string section, string key, string value) { + byte[] bytes = Encoding.UTF8.GetBytes(value); + return Set(section, key, Convert.ToBase64String(bytes)); + } - public string? GetFromEncoded(string section, string key, string? defaultValue = "") - { - string val = Get(section, key, defaultValue) ?? string.Empty; - var fromBase64String = Convert.FromBase64String(val); - return System.Text.Encoding.UTF8.GetString(fromBase64String); - } + public OrderTypeModes GetOrderTypeMode(string section, string orderEnumName, OrderTypeModes modeDefault) { + string? stringVal = Get(section, $"{orderEnumName}.Mode", "Dollar"); + OrderTypeModes sourceMode = Enum.TryParse(stringVal, out OrderTypeModes m) ? m : OrderTypeModes.Dollar; + return sourceMode; + } - public virtual bool Set(string section, string key, string? value) - { - if (Data[section][key] != value) - { - Data[section][key] = value ?? string.Empty; - PendingChanges = true; - SettingsEvents.RaiseSettingsUnsavedChanges(PendingChanges); - return true; - } - return false; - } + public bool SetOrderTypeMode(string section, string orderEnumName, OrderTypeModes mode) { + return Set(section, $"{orderEnumName}.Mode", $"{mode}"); + } - public virtual bool SetBool(string section, string key, bool? value) - { - value ??= false; - return Set(section, key, ((bool)value).ToString()); - } + public string GetInstallId() { + string? id = Get("Telemetry", "InstallId"); + if (!string.IsNullOrWhiteSpace(id)) return id; - public bool SetEncoded(string section, string key, string value) - { - var bytes = System.Text.Encoding.UTF8.GetBytes(value); - return Set(section, key, Convert.ToBase64String(bytes)); - } + id = Guid.NewGuid().ToString(); + Set("Telemetry", "InstallId", id); + Save(); + return id; + } - public OrderTypeModes GetOrderTypeMode(string section, string orderEnumName, OrderTypeModes modeDefault) - { - var stringVal = Get(section, $"{orderEnumName}.Mode", "Dollar"); - var sourceMode = Enum.TryParse(stringVal, out OrderTypeModes m) ? m : OrderTypeModes.Dollar; - return sourceMode; - } + private void CreateDefault() { + Data = new IniData(); + Data["Server"]["Port"] = "14040"; - public bool SetOrderTypeMode(string section, string orderEnumName, OrderTypeModes mode) - { - return Set(section, $"{orderEnumName}.Mode", $"{mode}"); - } + Data["Discord"]["Events.WebhookUrl"] = ""; + Data["Discord"]["WebhookUrl"] = ""; - public string GetInstallId() - { - var id = Get("Telemetry", "InstallId", ""); - if (!string.IsNullOrWhiteSpace(id)) return id; - - id = Guid.NewGuid().ToString(); - Set("Telemetry", "InstallId", id); - Save(); - return id; + foreach (SubathonEventType type in Enum.GetValues(typeof(SubathonEventType))) { + if (type.GetLegacyGoAffProSiteId() > 0) continue; // retired per-store values + Data["Discord"][$"Events.Log.{type}"] = $"{false}"; } + + Data["Discord"]["Events.Log.Simulated"] = $"{false}"; + Data["Currency"]["Primary"] = "USD"; } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/DiscoverProcess.cs b/SubathonManager.Core/DiscoverProcess.cs new file mode 100644 index 00000000..010adf78 --- /dev/null +++ b/SubathonManager.Core/DiscoverProcess.cs @@ -0,0 +1,18 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Enums; + +namespace SubathonManager.Core; + +[ExcludeFromCodeCoverage] +public static class DiscoverProcess { + public static bool IsProcessRunning(ProcessSearch target) { + foreach (string name in target.GetQueryNames()) { + Process[] procs = Process.GetProcessesByName(name); + foreach (Process p in procs) p.Dispose(); + if (procs.Length > 0) return true; + } + + return false; + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/EnumExtensions.cs b/SubathonManager.Core/Enums/EnumExtensions.cs index dd2da853..c4563382 100644 --- a/SubathonManager.Core/Enums/EnumExtensions.cs +++ b/SubathonManager.Core/Enums/EnumExtensions.cs @@ -1,57 +1,49 @@ -using System.Reflection; -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Reflection; // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Core.Enums; -public static class EnumExtensions -{ - public static string GetDescription(this Enum value) - { - var field = value.GetType().GetField(value.ToString()); +public static class EnumExtensions { + public static string GetDescription(this Enum value) { + FieldInfo? field = value.GetType().GetField(value.ToString()); if (field == null) return value.ToString(); var attr = field.GetCustomAttribute(); return attr?.Description ?? value.ToString(); } - - public static string GetLabel(this Enum value) - { - var field = value.GetType().GetField(value.ToString()); + + public static string GetLabel(this Enum value) { + FieldInfo? field = value.GetType().GetField(value.ToString()); if (field == null) return value.ToString(); var attr = field.GetCustomAttribute(); return attr?.Label ?? value.ToString(); } - - public static bool IsDisabled(this Enum value) - { - var field = value.GetType().GetField(value.ToString()); + + public static bool IsDisabled(this Enum value) { + FieldInfo? field = value.GetType().GetField(value.ToString()); if (field == null) return true; var attr = field.GetCustomAttribute(); return !attr?.Enabled ?? true; - } - - public static bool IsEnabled(this Enum value) - { + } + + public static bool IsEnabled(this Enum value) { return !IsDisabled(value); } - - public static int GetOrderNumber(this Enum value) - { - var field = value.GetType().GetField(value.ToString()); + + public static int GetOrderNumber(this Enum value) { + FieldInfo? field = value.GetType().GetField(value.ToString()); if (field == null) return 99999; var attr = field.GetCustomAttribute(); return attr?.Order ?? 99999; - } - + } } [AttributeUsage(AttributeTargets.Field)] -public class EnumMetaAttribute : Attribute -{ +public class EnumMetaAttribute : Attribute { public virtual string? Description { get; set; } = ""; public virtual string? Label { get; init; } public int Order { get; init; } @@ -59,11 +51,18 @@ public class EnumMetaAttribute : Attribute public bool Enabled { get; init; } = true; } -public class EventSourceMetaAttribute : EnumMetaAttribute -{ - public override string? Label => SourceGroup is (SubathonSourceGroup.UseSource or SubathonSourceGroup.Unknown) ? ToString() : SourceGroup.GetLabel(); +[AttributeUsage(AttributeTargets.Field)] +public class ProcessSearchMetaAttribute : Attribute { + public required string[] QueryNames { get; init; } +} + +public class EventSourceMetaAttribute : EnumMetaAttribute { + public override string? Label => SourceGroup is SubathonSourceGroup.UseSource or SubathonSourceGroup.Unknown + ? ToString() + : SourceGroup.GetLabel(); + public SubathonSourceGroup SourceGroup { get; init; } = SubathonSourceGroup.UseSource; - + public bool Visible { get; init; } = true; public SubathonEventSource TrueSource { get; init; } = SubathonEventSource.Unknown; public int SourceOrder { get; init; } = 99999; @@ -71,23 +70,25 @@ public class EventSourceMetaAttribute : EnumMetaAttribute public bool IsExternalSource { get; init; } = false; } -public class EventTypeMetaAttribute : EnumMetaAttribute -{ - public override string? Description => Source is not (SubathonEventSource.Command or SubathonEventSource.Unknown) ? $"{Source.ToString()} {Label}".Trim( ): Label; +public class EventTypeMetaAttribute : EnumMetaAttribute { + public override string? Description => Source is not (SubathonEventSource.Command or SubathonEventSource.Unknown) + ? $"{Source.ToString()} {Label}".Trim() + : Label; + public bool IsCurrencyDonation { get; init; } public bool IsGift { get; init; } public bool IsMembership { get; init; } public bool IsSubscription { get; init; } public bool IsSubscriptionLike => IsSubscription || IsGift || IsMembership; public bool IsExternal { get; init; } - + public bool IsExtension { get; init; } public bool IsToken { get; init; } public bool IsRaid { get; init; } public bool IsTrain { get; init; } public bool IsFollow { get; init; } public bool IsOrder { get; init; } - + public bool IsGenericEvent { get; init; } public bool IsCommand { get; init; } public bool IsOther { get; init; } @@ -97,43 +98,36 @@ public class EventTypeMetaAttribute : EnumMetaAttribute } [ExcludeFromCodeCoverage] -public class GoAffProTypeMetaAttribute : EventTypeMetaAttribute -{ - public override string? Description => Label; +public class GoAffProTypeMetaAttribute : EventTypeMetaAttribute { + public override string? Description => Label; + // maintaining for legacy data public int LegacySiteId { get; init; } = -1; } -public class CommandMetaAttribute : EnumMetaAttribute -{ +public class CommandMetaAttribute : EnumMetaAttribute { public bool RequiresParameter { get; init; } public bool IsControlType { get; init; } } [ExcludeFromCodeCoverage] -public class WheelSpinActionMetaAttribute : EnumMetaAttribute -{ +public class WheelSpinActionMetaAttribute : EnumMetaAttribute { public bool IsCommand { get; init; } = false; public bool HasAction { get; init; } = true; public bool IsDoneImmediately { get; init; } = false; } - -public static class EnumMetaCache -{ +public static class EnumMetaCache { private static readonly ConcurrentDictionary> Cache = new(); - public static T? Get(Enum value) where T : EnumMetaAttribute - { - var type = value.GetType(); + public static T? Get(Enum value) where T : EnumMetaAttribute { + Type type = value.GetType(); - var map = Cache.GetOrAdd(type, t => - { + Dictionary map = Cache.GetOrAdd(type, t => { var dict = new Dictionary(); - foreach (var field in t.GetFields(BindingFlags.Public | BindingFlags.Static)) - { - var enumValue = field.GetValue(null)!; + foreach (FieldInfo field in t.GetFields(BindingFlags.Public | BindingFlags.Static)) { + object enumValue = field.GetValue(null)!; var attr2 = field.GetCustomAttribute(); dict[enumValue] = attr2; @@ -142,9 +136,30 @@ public static class EnumMetaCache return dict; }); - if (!map.TryGetValue(value, out var attr)) + if (!map.TryGetValue(value, out EnumMetaAttribute? attr)) return null; return attr as T; } +} + +[ExcludeFromCodeCoverage] +public static class ProcessSearchExtensions { + private static readonly Dictionary _cache = BuildCache(); + + private static Dictionary BuildCache() { + var map = new Dictionary(); + + foreach (ProcessSearch value in Enum.GetValues()) { + FieldInfo? field = typeof(ProcessSearch).GetField(value.ToString()); + var attr = field?.GetCustomAttribute(); + map[value] = attr?.QueryNames ?? []; + } + + return map; + } + + public static string[] GetQueryNames(this ProcessSearch value) { + return _cache.TryGetValue(value, out string[]? names) ? names : []; + } } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/GoalsType.cs b/SubathonManager.Core/Enums/GoalsType.cs index 82146207..dabcf89a 100644 --- a/SubathonManager.Core/Enums/GoalsType.cs +++ b/SubathonManager.Core/Enums/GoalsType.cs @@ -1,7 +1,6 @@ namespace SubathonManager.Core.Enums; -public enum GoalsType -{ +public enum GoalsType { Points, Money } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/MakeShipProductType.cs b/SubathonManager.Core/Enums/MakeShipProductType.cs index bc3412d1..5f362971 100644 --- a/SubathonManager.Core/Enums/MakeShipProductType.cs +++ b/SubathonManager.Core/Enums/MakeShipProductType.cs @@ -1,9 +1,8 @@ namespace SubathonManager.Core.Enums; -public enum MakeShipProductType -{ +public enum MakeShipProductType { Unknown, Petition, Campaign, Invalid // bad url usually -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/OrderTypeModes.cs b/SubathonManager.Core/Enums/OrderTypeModes.cs index 5e59da2c..99355437 100644 --- a/SubathonManager.Core/Enums/OrderTypeModes.cs +++ b/SubathonManager.Core/Enums/OrderTypeModes.cs @@ -1,7 +1,6 @@ namespace SubathonManager.Core.Enums; -public enum OrderTypeModes -{ +public enum OrderTypeModes { Item, Order, Dollar diff --git a/SubathonManager.Core/Enums/ProcessSearch.cs b/SubathonManager.Core/Enums/ProcessSearch.cs new file mode 100644 index 00000000..7d3cebab --- /dev/null +++ b/SubathonManager.Core/Enums/ProcessSearch.cs @@ -0,0 +1,15 @@ +namespace SubathonManager.Core.Enums; + +public enum ProcessSearch { + [ProcessSearchMeta(QueryNames = ["obs", "obs64", "OBS"])] + OBS, + + [ProcessSearchMeta(QueryNames = ["VTube Studio", "VTubeStudio"])] + VTubeStudio, + + [ProcessSearchMeta(QueryNames = ["Streamer.bot"])] + StreamerBot, + + [ProcessSearchMeta(QueryNames = ["Stream Deck", "StreamDeck"])] + StreamDeck +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/ProtocolMessageType.cs b/SubathonManager.Core/Enums/ProtocolMessageType.cs index b52fd98b..60ec1f9f 100644 --- a/SubathonManager.Core/Enums/ProtocolMessageType.cs +++ b/SubathonManager.Core/Enums/ProtocolMessageType.cs @@ -1,7 +1,6 @@ namespace SubathonManager.Core.Enums; -public enum ProtocolMessageType -{ +public enum ProtocolMessageType { Unknown, OAuth, SmoFile diff --git a/SubathonManager.Core/Enums/SubathonCommandType.cs b/SubathonManager.Core/Enums/SubathonCommandType.cs index 889ff538..3748e3cf 100644 --- a/SubathonManager.Core/Enums/SubathonCommandType.cs +++ b/SubathonManager.Core/Enums/SubathonCommandType.cs @@ -1,66 +1,80 @@ -using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; + namespace SubathonManager.Core.Enums; -public enum SubathonCommandType -{ - [CommandMeta(Description="Add Points", RequiresParameter = true)] +public enum SubathonCommandType { + [CommandMeta(Description = "Add Points", RequiresParameter = true)] AddPoints, - [CommandMeta(Description="Remove Points", RequiresParameter = true)] + + [CommandMeta(Description = "Remove Points", RequiresParameter = true)] SubtractPoints, - [CommandMeta(Description="Set Points", RequiresParameter = true, IsControlType = true)] + + [CommandMeta(Description = "Set Points", RequiresParameter = true, IsControlType = true)] SetPoints, - [CommandMeta(Description="Add Time", RequiresParameter = true)] + + [CommandMeta(Description = "Add Time", RequiresParameter = true)] AddTime, - [CommandMeta(Description="Remove Time", RequiresParameter = true)] + + [CommandMeta(Description = "Remove Time", RequiresParameter = true)] SubtractTime, - [CommandMeta(Description="Set Time", RequiresParameter = true, IsControlType = true)] + + [CommandMeta(Description = "Set Time", RequiresParameter = true, IsControlType = true)] SetTime, - [CommandMeta(Description="Lock", IsControlType = true)] + + [CommandMeta(Description = "Lock", IsControlType = true)] Lock, - [CommandMeta(Description="Unlock", IsControlType = true)] + + [CommandMeta(Description = "Unlock", IsControlType = true)] Unlock, - [CommandMeta(Description="Pause Timer", IsControlType = true)] + + [CommandMeta(Description = "Pause Timer", IsControlType = true)] Pause, - [CommandMeta(Description="Resume Timer", IsControlType = true)] + + [CommandMeta(Description = "Resume Timer", IsControlType = true)] Resume, - [CommandMeta(Description="Set Multiplier", RequiresParameter = true, IsControlType = true)] + + [CommandMeta(Description = "Set Multiplier", RequiresParameter = true, IsControlType = true)] SetMultiplier, - [CommandMeta(Description="Stop Multiplier", IsControlType = true)] + + [CommandMeta(Description = "Stop Multiplier", IsControlType = true)] StopMultiplier, - [CommandMeta(Description="None")] - None, - [CommandMeta(Description="Unknown")] - Unknown, - [CommandMeta(Description="Refresh Overlays", IsControlType = true)] + [CommandMeta(Description = "None")] None, + [CommandMeta(Description = "Unknown")] Unknown, + + [CommandMeta(Description = "Refresh Overlays", IsControlType = true)] RefreshOverlays, - [CommandMeta(Description="Add Money", RequiresParameter = true)] + + [CommandMeta(Description = "Add Money", RequiresParameter = true)] AddMoney, - [CommandMeta(Description="Remove Money", RequiresParameter = true)] + + [CommandMeta(Description = "Remove Money", RequiresParameter = true)] SubtractMoney, - [CommandMeta(Description="Set Wheel Spins", RequiresParameter = true)] + + [CommandMeta(Description = "Set Wheel Spins", RequiresParameter = true)] SetSpins, - [CommandMeta(Description="Add Wheel Spins", RequiresParameter = true)] + + [CommandMeta(Description = "Add Wheel Spins", RequiresParameter = true)] AddSpins, - [CommandMeta(Description="Subtract Wheel Spins", RequiresParameter = true)] + + [CommandMeta(Description = "Subtract Wheel Spins", RequiresParameter = true)] SubtractSpins, - [CommandMeta(Description="Spin the Wheel", IsControlType = true)] - SpinWheel, + + [CommandMeta(Description = "Spin the Wheel", IsControlType = true)] + SpinWheel } [ExcludeFromCodeCoverage] -public static class SubathonCommandTypeHelper -{ - private static CommandMetaAttribute? Meta(this SubathonCommandType value) - { +public static class SubathonCommandTypeHelper { + private static CommandMetaAttribute? Meta(this SubathonCommandType value) { var meta = EnumMetaCache.Get(value); return meta; } - public static bool IsParametersRequired(this SubathonCommandType command) => - command.Meta()?.RequiresParameter ?? false; - - public static bool IsControlTypeCommand(this SubathonCommandType command) => - command.Meta()?.IsControlType ?? false; - + public static bool IsParametersRequired(this SubathonCommandType command) { + return command.Meta()?.RequiresParameter ?? false; + } + + public static bool IsControlTypeCommand(this SubathonCommandType command) { + return command.Meta()?.IsControlType ?? false; + } } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonEventSource.cs b/SubathonManager.Core/Enums/SubathonEventSource.cs index 75a8d921..8de249d1 100644 --- a/SubathonManager.Core/Enums/SubathonEventSource.cs +++ b/SubathonManager.Core/Enums/SubathonEventSource.cs @@ -1,84 +1,141 @@ -using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; namespace SubathonManager.Core.Enums; -public enum SubathonEventSource -{ +public enum SubathonEventSource { // some may not actually be event sources in the future, but also integration sources - [EventSourceMeta(Description = "Twitch", SourceGroup = SubathonSourceGroup.Stream, SourceOrder=1, Order=10)] + [EventSourceMeta(Description = "Twitch", SourceGroup = SubathonSourceGroup.Stream, SourceOrder = 1, Order = 10)] Twitch, - [EventSourceMeta(Description = "StreamElements", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=21, Order=20)] + + [EventSourceMeta(Description = "StreamElements", SourceGroup = SubathonSourceGroup.StreamExtension, + SourceOrder = 21, Order = 20)] StreamElements, - [EventSourceMeta(Description = "KoFi", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=41, Order=40)] + + [EventSourceMeta(Description = "KoFi", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder = 41, + Order = 40)] KoFi, - [EventSourceMeta(Description = "YouTube", SourceGroup = SubathonSourceGroup.Stream, SourceOrder=2, Order=11)] + + [EventSourceMeta(Description = "YouTube", SourceGroup = SubathonSourceGroup.Stream, SourceOrder = 2, Order = 11)] YouTube, - [EventSourceMeta(Description = "Commands", SourceGroup = SubathonSourceGroup.Misc, SourceOrder=2000, Order=100)] + + [EventSourceMeta(Description = "Commands", SourceGroup = SubathonSourceGroup.Misc, SourceOrder = 2000, Order = 100)] Command, // can be from any chat - [EventSourceMeta(Description = "Simulated", SourceOrder=8000, Order=199)] + + [EventSourceMeta(Description = "Simulated", SourceOrder = 8000, Order = 199)] Simulated, // buttons to test in UI? - [EventSourceMeta(Description = "Unknown", SourceGroup=SubathonSourceGroup.Misc, SourceOrder=9000, Order=101)] + + [EventSourceMeta(Description = "Unknown", SourceGroup = SubathonSourceGroup.Misc, SourceOrder = 9000, Order = 101)] Unknown, // default - [EventSourceMeta(Description = "StreamLabs", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=22, Order=21)] + + [EventSourceMeta(Description = "StreamLabs", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder = 22, + Order = 21)] StreamLabs, - [EventSourceMeta(Description = "Generic External Services", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=1000, Order=99, IsExternalSource = true)] + + [EventSourceMeta(Description = "Generic External Services", SourceGroup = SubathonSourceGroup.ExternalService, + SourceOrder = 1000, Order = 99, IsExternalSource = true)] External, - [EventSourceMeta(Description = "Blerp", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=81, Order=30, IsExternalSource = true)] + + [EventSourceMeta(Description = "Blerp", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder = 81, + Order = 30, IsExternalSource = true)] Blerp, - [EventSourceMeta(Description = "Picarto", SourceGroup = SubathonSourceGroup.Stream, SourceOrder=3, Order=12)] + + [EventSourceMeta(Description = "Picarto", SourceGroup = SubathonSourceGroup.Stream, SourceOrder = 3, Order = 12)] Picarto, - [EventSourceMeta(Description = "GoAffPro Affiliate Stores", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=61, Order=50, Visible = true)] + + [EventSourceMeta(Description = "GoAffPro Affiliate Stores", SourceGroup = SubathonSourceGroup.ExternalService, + SourceOrder = 61, Order = 50, Visible = true)] GoAffPro, - [EventSourceMeta(Description = "Ko-Fi (Tunnel)", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=42, Visible=false, TrueSource=KoFi, Order=41)] + + [EventSourceMeta(Description = "Ko-Fi (Tunnel)", SourceGroup = SubathonSourceGroup.ExternalService, + SourceOrder = 42, Visible = false, TrueSource = KoFi, Order = 41)] KoFiTunnel, - [EventSourceMeta(Description = "Dev Tunnels", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder=904, Visible=false, Order=903)] + + [EventSourceMeta(Description = "Dev Tunnels", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder = 904, + Visible = false, Order = 903)] DevTunnels, - [EventSourceMeta(Description="FourthWall", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=62, Order=51)] + + [EventSourceMeta(Description = "FourthWall", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder = 62, + Order = 51)] FourthWall, - [EventSourceMeta(Description="OBS", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder=901, Order=900, Visible = false)] + + [EventSourceMeta(Description = "OBS", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder = 901, + Order = 900, Visible = false)] OBS, - [EventSourceMeta(Description="Throne", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=63, Order=52)] + + [EventSourceMeta(Description = "Throne", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder = 63, + Order = 52)] Throne, - [EventSourceMeta(Description="TipeeeStream", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=23, Order=22)] + + [EventSourceMeta(Description = "TipeeeStream", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder = 23, + Order = 22)] TipeeeStream, - [EventSourceMeta(Description="Wheel Spin", Visible = false, Order = 990, SourceGroup = SubathonSourceGroup.WheelSpin)] + + [EventSourceMeta(Description = "Wheel Spin", Visible = false, Order = 990, + SourceGroup = SubathonSourceGroup.WheelSpin)] WheelSpin, - [EventSourceMeta(Description="Tangia", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=82, Order=31)] + + [EventSourceMeta(Description = "Tangia", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder = 82, + Order = 31)] Tangia, - [EventSourceMeta(Description="Stream Deck", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder=903, Order=902, Visible = false, IsExternalSource = true)] + + [EventSourceMeta(Description = "Stream Deck", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder = 903, + Order = 902, Visible = false, IsExternalSource = true)] StreamDeck, - [EventSourceMeta(Description="StreamerBot", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder=902, Order=901, Visible = false, IsExternalSource = true)] + + [EventSourceMeta(Description = "StreamerBot", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder = 902, + Order = 901, Visible = false, IsExternalSource = true)] StreamerBot, - [EventSourceMeta(Description="Pally.GG", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=64, Order=53)] + + [EventSourceMeta(Description = "Pally.GG", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder = 64, + Order = 53)] PallyGG, - [EventSourceMeta(Description="TreatStream", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=24, Order=23)] + + [EventSourceMeta(Description = "TreatStream", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder = 24, + Order = 23)] TreatStream, - [EventSourceMeta(Description="MakeShip", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=65, Order=54)] + + [EventSourceMeta(Description = "MakeShip", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder = 65, + Order = 54)] MakeShip, - [EventSourceMeta(Description="Juniper Creates", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=66, Order=55, Visible = true)] - JuniperCreates // like goaffpro, do lookup on product id to find store name as source + + [EventSourceMeta(Description = "Juniper Creates", SourceGroup = SubathonSourceGroup.ExternalService, + SourceOrder = 66, Order = 55, Visible = true)] + JuniperCreates, // like goaffpro, do lookup on product id to find store name as source + + [EventSourceMeta(Description = "VTube Studio", SourceGroup = SubathonSourceGroup.ExternalSoftware, + SourceOrder = 905, Order = 904, Visible = false)] + VTubeStudio } [ExcludeFromCodeCoverage] -public static class SubathonEventSourceHelper -{ - public static int GetSourceOrder(SubathonEventSource source) => ((SubathonEventSource?)source).Meta()?.SourceOrder ?? 99999; - - private static EventSourceMetaAttribute? Meta(this SubathonEventSource? value) - { +public static class SubathonEventSourceHelper { + public static int GetSourceOrder(SubathonEventSource source) { + return ((SubathonEventSource?)source).Meta()?.SourceOrder ?? 99999; + } + + private static EventSourceMetaAttribute? Meta(this SubathonEventSource? value) { if (!value.HasValue) return null; var meta = EnumMetaCache.Get(value); return meta; } - - public static bool IsExternalSource(this SubathonEventSource? source) => source.Meta()?.IsExternalSource ?? false; - public static bool IsExternalSource(this SubathonEventSource source) => IsExternalSource((SubathonEventSource?)source); - public static SubathonSourceGroup GetGroup(this SubathonEventSource source) => - ((SubathonEventSource?)source).Meta()?.SourceGroup ?? SubathonSourceGroup.Unknown; + public static bool IsExternalSource(this SubathonEventSource? source) { + return source.Meta()?.IsExternalSource ?? false; + } - public static string GetGroupLabel(this SubathonEventSource source) => ((SubathonEventSource?)source).Meta()?.Label ?? source.ToString(); - - public static int GetGroupLabelOrder(this SubathonEventSource source) => ((SubathonEventSource?)source).Meta()?.Order ?? 99999; + public static bool IsExternalSource(this SubathonEventSource source) { + return IsExternalSource((SubathonEventSource?)source); + } + + public static SubathonSourceGroup GetGroup(this SubathonEventSource source) { + return ((SubathonEventSource?)source).Meta()?.SourceGroup ?? SubathonSourceGroup.Unknown; + } + + public static string GetGroupLabel(this SubathonEventSource source) { + return ((SubathonEventSource?)source).Meta()?.Label ?? source.ToString(); + } + + public static int GetGroupLabelOrder(this SubathonEventSource source) { + return ((SubathonEventSource?)source).Meta()?.Order ?? 99999; + } } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonEventSubType.cs b/SubathonManager.Core/Enums/SubathonEventSubType.cs index 981a24c2..457ed90e 100644 --- a/SubathonManager.Core/Enums/SubathonEventSubType.cs +++ b/SubathonManager.Core/Enums/SubathonEventSubType.cs @@ -1,61 +1,74 @@ -namespace SubathonManager.Core.Enums; +using System.Diagnostics.CodeAnalysis; -using System.Diagnostics.CodeAnalysis; -public enum SubathonEventSubType -{ - [EnumMeta(Description="Unknown",Label="Unknown", Order = 200)] +namespace SubathonManager.Core.Enums; + +public enum SubathonEventSubType { + [EnumMeta(Description = "Unknown", Label = "Unknown", Order = 200)] Unknown, - [EnumMeta(Description="Subscriptions", Label="Subscriptions", Order=1)] + + [EnumMeta(Description = "Subscriptions", Label = "Subscriptions", Order = 1)] SubLike, - [EnumMeta(Description="Gift Subscriptions", Label="Gift Subscriptions", Order=2)] + + [EnumMeta(Description = "Gift Subscriptions", Label = "Gift Subscriptions", Order = 2)] GiftSubLike, - [EnumMeta(Description="Donations", Label="Donations", Order=4)] + + [EnumMeta(Description = "Donations", Label = "Donations", Order = 4)] DonationLike, - [EnumMeta(Description="Tokens/Bits", Label="Tokens/Bits", Order=3)] + + [EnumMeta(Description = "Tokens/Bits", Label = "Tokens/Bits", Order = 3)] TokenLike, - [EnumMeta(Description="Follows", Label="Follows", Order=5)] + + [EnumMeta(Description = "Follows", Label = "Follows", Order = 5)] FollowLike, - [EnumMeta(Description="Raids", Label="Raids" , Order=6)] + + [EnumMeta(Description = "Raids", Label = "Raids", Order = 6)] RaidLike, - [EnumMeta(Description="Hype Trains", Label="Hype Trains", Order = 7)] + + [EnumMeta(Description = "Hype Trains", Label = "Hype Trains", Order = 7)] TrainLike, - [EnumMeta(Description="Commands", Label="Commands", Order = 100)] + + [EnumMeta(Description = "Commands", Label = "Commands", Order = 100)] CommandLike, - [EnumMeta(Description="Store Orders", Label="Store Orders", Order=8)] + + [EnumMeta(Description = "Store Orders", Label = "Store Orders", Order = 8)] OrderLike, - [EnumMeta(Description="Generic Events", Label="Generic Events", Order=9)] - EventLike + + [EnumMeta(Description = "Generic Events", Label = "Generic Events", Order = 9)] + EventLike } [ExcludeFromCodeCoverage] -public static class SubathonEventSubTypeHelper -{ +public static class SubathonEventSubTypeHelper { public static readonly List OrderEventTypes = Enum.GetValues() .Where(e => e.GetSubType() == SubathonEventSubType.OrderLike && e.IsEnabled()) .ToList(); + public static readonly List FollowEventTypes = Enum.GetValues() .Where(e => e.GetSubType() == SubathonEventSubType.FollowLike && e.IsEnabled()) .ToList(); + public static readonly List SubEventTypes = Enum.GetValues() .Where(e => e.GetSubType() is SubathonEventSubType.SubLike or SubathonEventSubType.GiftSubLike && e.IsEnabled()) .ToList(); + public static readonly List TokenEventTypes = Enum.GetValues() .Where(e => e.GetSubType() == SubathonEventSubType.TokenLike && e.IsEnabled()) .ToList(); + public static readonly List DonationEventTypes = Enum.GetValues() .Where(e => e.GetSubType() == SubathonEventSubType.DonationLike && e.IsEnabled()) .ToList(); + public static readonly List GenericEventTypes = Enum.GetValues() .Where(e => e.GetSubType() == SubathonEventSubType.EventLike && e.IsEnabled()) .ToList(); - private static readonly SubathonEventSubType[] NotTrueEvent = - [ + private static readonly SubathonEventSubType[] NotTrueEvent = [ SubathonEventSubType.CommandLike, SubathonEventSubType.Unknown ]; - - public static bool IsTrueEvent(this SubathonEventSubType? eventType) => - eventType.HasValue && !NotTrueEvent.Contains(eventType.Value); - + + public static bool IsTrueEvent(this SubathonEventSubType? eventType) { + return eventType.HasValue && !NotTrueEvent.Contains(eventType.Value); + } } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonEventType.cs b/SubathonManager.Core/Enums/SubathonEventType.cs index 6a105e38..e82f7f7e 100644 --- a/SubathonManager.Core/Enums/SubathonEventType.cs +++ b/SubathonManager.Core/Enums/SubathonEventType.cs @@ -1,8 +1,9 @@ using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Models; + namespace SubathonManager.Core.Enums; -public enum SubathonEventType -{ +public enum SubathonEventType { [EventTypeMeta(Label = "Subscription", Source = SubathonEventSource.Twitch, IsSubscription = true, Order = 1)] TwitchSub, // remember subs can be of Value: 1000, 2000, 3000, Prime iirc... damnit looks like the TwitchLib doesnt separate Prime?? @@ -94,6 +95,7 @@ public enum SubathonEventType [GoAffProTypeMeta(Label = "GamerSupps Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 1, LegacySiteId = 165328, Enabled = false)] GamerSuppsOrder, + [Obsolete] [GoAffProTypeMeta(Label = "UwUMarket Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 2, LegacySiteId = 132230, Enabled = false)] @@ -102,6 +104,7 @@ public enum SubathonEventType [GoAffProTypeMeta(Label = "Orchid Eight Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 3, LegacySiteId = 7142837, Enabled = false)] OrchidEightOrder, + [Obsolete] [GoAffProTypeMeta(Label = "KatDragonz Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 4, LegacySiteId = 7160049, Enabled = false)] @@ -134,10 +137,12 @@ public enum SubathonEventType [EventTypeMeta(Label = "Gift Order", Source = SubathonEventSource.FourthWall, IsOrder = true, IsExternal = true, Order = 4)] FourthWallGiftOrder, + [Obsolete] [GoAffProTypeMeta(Label = "Cheeky Soap Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 5, LegacySiteId = 7138531, Enabled = false)] CheekySoapOrder, + [Obsolete] [GoAffProTypeMeta(Label = "Advanced GG Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 6, LegacySiteId = 105752, Enabled = false)] @@ -157,33 +162,40 @@ public enum SubathonEventType IsGenericEvent = true)] // will come in if contribution fully funds, so purely an alert, not event to add. So we do not allow configuration of it ThroneCrowdGiftComplete, // only config in settings is whether or not to display it + [Obsolete] [GoAffProTypeMeta(Label = "Rogue Energy Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 7, LegacySiteId = 7014645, Enabled = false)] RogueEnergyOrder, + [Obsolete] [GoAffProTypeMeta(Label = "Saucy Biz Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 8, LegacySiteId = 7118656, Enabled = false)] SaucyBizOrder, - + // dynamic GoAffPro order type, meta is site id - [GoAffProTypeMeta(Label="GoAffPro Order", Source=SubathonEventSource.GoAffPro, IsOrder=true, Order=1, Enabled=true)] + [GoAffProTypeMeta(Label = "GoAffPro Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 1, + Enabled = true)] GoAffProOrder, + [Obsolete] [GoAffProTypeMeta(Label = "GFuel Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 9, LegacySiteId = 48808, Enabled = false)] GFuelOrder, + [Obsolete] [GoAffProTypeMeta(Label = "Natura Pine Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 10, LegacySiteId = 7132796, Enabled = false)] NaturaPineOrder, [EventTypeMeta(Label = "Donation", Source = SubathonEventSource.TipeeeStream, IsCurrencyDonation = true, Order = 1)] - TipeeeStreamDonation, + TipeeeStreamDonation, + [EventTypeMeta(Label = "Tokens", Source = SubathonEventSource.Tangia, IsToken = true, Order = 1)] TangiaTokens, // no way to distinguish twitch bits or tangia coins - [EventTypeMeta(Label = "Donation", Source = SubathonEventSource.PallyGG, IsCurrencyDonation = true, IsExternal = true, + [EventTypeMeta(Label = "Donation", Source = SubathonEventSource.PallyGG, IsCurrencyDonation = true, + IsExternal = true, Order = 1)] // Pally.gg is USD only :/ PallyGGDonation, @@ -194,78 +206,108 @@ public enum SubathonEventType // items only [EventTypeMeta(Label = "Pledge", Source = SubathonEventSource.MakeShip, IsOrder = true, Order = 1)] MakeShipPledge, + // items only [EventTypeMeta(Label = "Campaign Sale", Source = SubathonEventSource.MakeShip, IsOrder = true, Order = 2)] MakeShipSale, - [EventTypeMeta(Label="Merch Sale", Source=SubathonEventSource.JuniperCreates, IsOrder=true, Order=1, Enabled=true)] - JuniperMerchSale, + + [EventTypeMeta(Label = "Merch Sale", Source = SubathonEventSource.JuniperCreates, IsOrder = true, Order = 1, + Enabled = true)] + JuniperMerchSale // any new must be added after the last } [ExcludeFromCodeCoverage] -public static class SubathonEventTypeHelper -{ - - private static EventTypeMetaAttribute? Meta(this SubathonEventType? value) - { +public static class SubathonEventTypeHelper { + private static EventTypeMetaAttribute? Meta(this SubathonEventType? value) { if (!value.HasValue) return null; var meta = EnumMetaCache.Get(value); if (meta?.Source == SubathonEventSource.GoAffPro) return GoAffProMeta(value); return meta; } - - private static GoAffProTypeMetaAttribute? GoAffProMeta(this SubathonEventType? value) - { + + private static GoAffProTypeMetaAttribute? GoAffProMeta(this SubathonEventType? value) { return value != null ? EnumMetaCache.Get(value) : null; } - public static bool IsCurrencyDonation(this SubathonEventType? value) - => value.Meta()?.IsCurrencyDonation == true; - - public static bool IsSubscription(this SubathonEventType? value) - => value.Meta()?.IsSubscriptionLike == true; - - public static bool IsGift(this SubathonEventType? value) - => value.Meta()?.IsGift == true; - - public static bool IsToken(this SubathonEventType? value) - => value.Meta()?.IsToken == true; - - public static bool IsCommand(this SubathonEventType? value) - => value.Meta()?.IsCommand == true; - - public static bool IsExternal(this SubathonEventType? value) - => value.Meta()?.IsExternal == true; - - public static bool IsRaid(this SubathonEventType? value) - => value.Meta()?.IsRaid == true; - public static bool IsFollow(this SubathonEventType? value) - => value.Meta()?.IsFollow == true; - - public static bool IsTrain(this SubathonEventType? value) => value.Meta()?.IsTrain == true; - public static bool IsOrder(this SubathonEventType? value) => value.Meta()?.IsOrder == true; - - public static bool IsEvent(this SubathonEventType? value) => value.Meta()?.IsGenericEvent == true; - public static bool IsExtension(this SubathonEventType? value) => value.Meta()?.IsExtension == true; - public static bool IsOther(this SubathonEventType? value) - => value.Meta()?.IsOther == true; - - public static bool HasNoValueConfig(this SubathonEventType? value) => value.Meta()?.HasValueConfig == true; - - public static SubathonEventSource GetSource(this SubathonEventType value) => - ((SubathonEventType?)value).GetSource(); - public static SubathonEventSource GetSource(this SubathonEventType? value) - => value.Meta()?.Source ?? SubathonEventSource.Unknown; - - public static string GetLabel(this SubathonEventType? value) => value.Meta()?.Label ?? value.ToString() ?? string.Empty; - - public static SubathonEventSubType? GetSubType(this SubathonEventType eventType) => GetSubType((SubathonEventType?)eventType); - public static SubathonEventSubType GetSubType(this SubathonEventType? eventType) - { + public static bool IsCurrencyDonation(this SubathonEventType? value) { + return value.Meta()?.IsCurrencyDonation == true; + } + + public static bool IsSubscription(this SubathonEventType? value) { + return value.Meta()?.IsSubscriptionLike == true; + } + + public static bool IsGift(this SubathonEventType? value) { + return value.Meta()?.IsGift == true; + } + + public static bool IsToken(this SubathonEventType? value) { + return value.Meta()?.IsToken == true; + } + + public static bool IsCommand(this SubathonEventType? value) { + return value.Meta()?.IsCommand == true; + } + + public static bool IsExternal(this SubathonEventType? value) { + return value.Meta()?.IsExternal == true; + } + + public static bool IsRaid(this SubathonEventType? value) { + return value.Meta()?.IsRaid == true; + } + + public static bool IsFollow(this SubathonEventType? value) { + return value.Meta()?.IsFollow == true; + } + + public static bool IsTrain(this SubathonEventType? value) { + return value.Meta()?.IsTrain == true; + } + + public static bool IsOrder(this SubathonEventType? value) { + return value.Meta()?.IsOrder == true; + } + + public static bool IsEvent(this SubathonEventType? value) { + return value.Meta()?.IsGenericEvent == true; + } + + public static bool IsExtension(this SubathonEventType? value) { + return value.Meta()?.IsExtension == true; + } + + public static bool IsOther(this SubathonEventType? value) { + return value.Meta()?.IsOther == true; + } + + public static bool HasNoValueConfig(this SubathonEventType? value) { + return value.Meta()?.HasValueConfig == true; + } + + public static SubathonEventSource GetSource(this SubathonEventType value) { + return ((SubathonEventType?)value).GetSource(); + } + + public static SubathonEventSource GetSource(this SubathonEventType? value) { + return value.Meta()?.Source ?? SubathonEventSource.Unknown; + } + + public static string GetLabel(this SubathonEventType? value) { + return value.Meta()?.Label ?? value.ToString() ?? string.Empty; + } + + public static SubathonEventSubType? GetSubType(this SubathonEventType eventType) { + return GetSubType((SubathonEventType?)eventType); + } + + public static SubathonEventSubType GetSubType(this SubathonEventType? eventType) { if (eventType == null) return SubathonEventSubType.Unknown; if (eventType.IsGift()) return SubathonEventSubType.GiftSubLike; - if (eventType.IsSubscription()) return SubathonEventSubType.SubLike; // important GiftType is above, so it has priority + if (eventType.IsSubscription()) + return SubathonEventSubType.SubLike; // important GiftType is above, so it has priority if (eventType.IsToken()) return SubathonEventSubType.TokenLike; if (eventType.IsCurrencyDonation()) return SubathonEventSubType.DonationLike; if (eventType.IsOrder()) return SubathonEventSubType.OrderLike; @@ -276,29 +318,28 @@ public static SubathonEventSubType GetSubType(this SubathonEventType? eventType) if (eventType.IsEvent()) return SubathonEventSubType.EventLike; return SubathonEventSubType.Unknown; } - - public static string? GetTypeTrueSource(this SubathonEventType eventType, string? meta = null) => GetTypeTrueSource((SubathonEventType?)eventType, meta); - public static string? GetTypeTrueSource(this SubathonEventType? eventType, string? meta = null) - { + public static string? GetTypeTrueSource(this SubathonEventType eventType, string? meta = null) { + return GetTypeTrueSource((SubathonEventType?)eventType, meta); + } + + public static string? GetTypeTrueSource(this SubathonEventType? eventType, string? meta = null) { if (eventType is null or SubathonEventType.Command) return "Manual"; - if (eventType.GetSource() == SubathonEventSource.GoAffPro) - { - var siteId = eventType == SubathonEventType.GoAffProOrder && meta != null && int.TryParse(meta, out var parsed) + if (eventType.GetSource() == SubathonEventSource.GoAffPro) { + int siteId = eventType == SubathonEventType.GoAffProOrder && meta != null && + int.TryParse(meta, out int parsed) ? parsed : eventType.GoAffProMeta()?.LegacySiteId ?? -1; - return GoAffProStoreRegistry.TryGetBySiteId(siteId, out var store) + return GoAffProStoreRegistry.TryGetBySiteId(siteId, out GoAffProStore? store) ? store.InternalName : nameof(SubathonEventSource.GoAffPro); } - if (eventType.GetSource() == SubathonEventSource.JuniperCreates) - { - return JuniperOrderHelper.GetStoreLabel(meta); - } + if (eventType.GetSource() == SubathonEventSource.JuniperCreates) return JuniperOrderHelper.GetStoreLabel(meta); return eventType.GetSource().ToString(); } - public static int GetLegacyGoAffProSiteId(this SubathonEventType eventType) => - ((SubathonEventType?)eventType).GoAffProMeta()?.LegacySiteId ?? -1; -} + public static int GetLegacyGoAffProSiteId(this SubathonEventType eventType) { + return ((SubathonEventType?)eventType).GoAffProMeta()?.LegacySiteId ?? -1; + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonPromptType.cs b/SubathonManager.Core/Enums/SubathonPromptType.cs index cd22b627..df51299d 100644 --- a/SubathonManager.Core/Enums/SubathonPromptType.cs +++ b/SubathonManager.Core/Enums/SubathonPromptType.cs @@ -3,8 +3,7 @@ namespace SubathonManager.Core.Enums; -public enum SubathonPromptType -{ +public enum SubathonPromptType { Points, Money, Orders, @@ -14,23 +13,21 @@ public enum SubathonPromptType Event } -public enum SubathonPromptSubType -{ +public enum SubathonPromptSubType { Default, // count occurances - + Items, // items on orders - + // subs Default is all subs NormalSubs, // Default but subs only GiftSubs, // Default but giftsubs only - + // Event specific which are Subs // default for any ByTier } -public enum SubathonPromptRunStatus -{ +public enum SubathonPromptRunStatus { Active, Completed, Expired, @@ -38,28 +35,29 @@ public enum SubathonPromptRunStatus } [ExcludeFromCodeCoverage] -public static class SubathonPromptTypeExtensions -{ +public static class SubathonPromptTypeExtensions { private static readonly ReadOnlyDictionary _validSubTypes = - new(new Dictionary - { + new(new Dictionary { [SubathonPromptType.Points] = [SubathonPromptSubType.Default], [SubathonPromptType.Money] = [SubathonPromptSubType.Default], [SubathonPromptType.Orders] = [SubathonPromptSubType.Default, SubathonPromptSubType.Items], [SubathonPromptType.Follows] = [SubathonPromptSubType.Default], [SubathonPromptType.Tokens] = [SubathonPromptSubType.Default], - [SubathonPromptType.Subs] = [SubathonPromptSubType.Default, SubathonPromptSubType.NormalSubs, SubathonPromptSubType.GiftSubs], - [SubathonPromptType.Event] = [SubathonPromptSubType.Default, SubathonPromptSubType.Items, SubathonPromptSubType.ByTier], + [SubathonPromptType.Subs] = + [SubathonPromptSubType.Default, SubathonPromptSubType.NormalSubs, SubathonPromptSubType.GiftSubs], + [SubathonPromptType.Event] = + [SubathonPromptSubType.Default, SubathonPromptSubType.Items, SubathonPromptSubType.ByTier] }); - - public static SubathonPromptSubType[] GetValidSubTypes(this SubathonPromptType type) - => _validSubTypes.TryGetValue(type, out var st) ? st : [SubathonPromptSubType.Default]; - - public static bool IsSubTypeValid(this SubathonPromptType type, SubathonPromptSubType subType) - => type.GetValidSubTypes().Contains(subType); - - public static bool IsSelectableForPromptEvent(this SubathonEventType eventType) - { + + public static SubathonPromptSubType[] GetValidSubTypes(this SubathonPromptType type) { + return _validSubTypes.TryGetValue(type, out SubathonPromptSubType[]? st) ? st : [SubathonPromptSubType.Default]; + } + + public static bool IsSubTypeValid(this SubathonPromptType type, SubathonPromptSubType subType) { + return type.GetValidSubTypes().Contains(subType); + } + + public static bool IsSelectableForPromptEvent(this SubathonEventType eventType) { var type = (SubathonEventType?)eventType; if (type.IsRaid() || type.IsCommand() || type == SubathonEventType.Unknown || type.IsTrain()) return false; return !eventType.IsDisabled(); @@ -67,67 +65,63 @@ public static bool IsSelectableForPromptEvent(this SubathonEventType eventType) public static SubathonPromptSubType[] GetValidSubTypes( this SubathonPromptType type, - SubathonEventType? filterEventType) - { + SubathonEventType? filterEventType) { if (type != SubathonPromptType.Event || filterEventType == null) return type.GetValidSubTypes(); - - if (filterEventType.IsSubscription()) - { + + if (filterEventType.IsSubscription()) { if (filterEventType == SubathonEventType.YouTubeGiftMembership) // no tier selection return [SubathonPromptSubType.Default]; return [SubathonPromptSubType.Default, SubathonPromptSubType.ByTier]; } - if (filterEventType.IsOrder()) - { + + if (filterEventType.IsOrder()) { // makeship and juniper only ever track unit counts between polls, but do it diff // makeship is diff between (ignore first on boot) // juniper is timerange fetched if (filterEventType.GetTypeTrueSource() == $"{SubathonEventSource.MakeShip}" || filterEventType?.GetSource() == SubathonEventSource.JuniperCreates) - { return [SubathonPromptSubType.Items]; - } return [SubathonPromptSubType.Default, SubathonPromptSubType.Items]; } return [SubathonPromptSubType.Default]; } - - public static string DisplayName(this SubathonPromptType type) => type switch - { - SubathonPromptType.Points => "Points", - SubathonPromptType.Money => "Money", - SubathonPromptType.Subs => "Subs", - SubathonPromptType.Orders => "Orders", - SubathonPromptType.Follows => "Follows", - SubathonPromptType.Tokens => "Tokens", - SubathonPromptType.Event => "Specific Event", - _ => type.ToString() - }; - - public static string DisplayName(this SubathonPromptSubType subType, SubathonPromptType type) => subType switch - { - SubathonPromptSubType.Default => type switch{ - SubathonPromptType.Points => "Total", - SubathonPromptType.Money => "Units", - SubathonPromptType.Subs => "Any", - SubathonPromptType.Orders => "Total Orders", - SubathonPromptType.Tokens => "Total", - _ => "Count" - }, - SubathonPromptSubType.Items => "Item Count", - SubathonPromptSubType.NormalSubs => "Normal Subs Only", - SubathonPromptSubType.GiftSubs => "Gift Subs Only", - SubathonPromptSubType.ByTier => "By Tier", - _ => subType.ToString() - }; - - public static string TierMetaDisplayName(this SubathonEventType eventType, string meta) => - eventType switch - { - SubathonEventType.TwitchSub or SubathonEventType.TwitchGiftSub => meta switch - { + + public static string DisplayName(this SubathonPromptType type) { + return type switch { + SubathonPromptType.Points => "Points", + SubathonPromptType.Money => "Money", + SubathonPromptType.Subs => "Subs", + SubathonPromptType.Orders => "Orders", + SubathonPromptType.Follows => "Follows", + SubathonPromptType.Tokens => "Tokens", + SubathonPromptType.Event => "Specific Event", + _ => type.ToString() + }; + } + + public static string DisplayName(this SubathonPromptSubType subType, SubathonPromptType type) { + return subType switch { + SubathonPromptSubType.Default => type switch { + SubathonPromptType.Points => "Total", + SubathonPromptType.Money => "Units", + SubathonPromptType.Subs => "Any", + SubathonPromptType.Orders => "Total Orders", + SubathonPromptType.Tokens => "Total", + _ => "Count" + }, + SubathonPromptSubType.Items => "Item Count", + SubathonPromptSubType.NormalSubs => "Normal Subs Only", + SubathonPromptSubType.GiftSubs => "Gift Subs Only", + SubathonPromptSubType.ByTier => "By Tier", + _ => subType.ToString() + }; + } + + public static string TierMetaDisplayName(this SubathonEventType eventType, string meta) { + return eventType switch { + SubathonEventType.TwitchSub or SubathonEventType.TwitchGiftSub => meta switch { "1000" => "T1", "2000" => "T2", "3000" => "T3", @@ -135,17 +129,18 @@ public static string TierMetaDisplayName(this SubathonEventType eventType, strin }, _ => meta }; - - public static string ValueLabel(this SubathonPromptType type, SubathonPromptSubType subType) => type switch - { - SubathonPromptType.Points => "Target Points", - SubathonPromptType.Money => "Target Money (units)", - SubathonPromptType.Follows => "Target Follows", - SubathonPromptType.Tokens => "Target Tokens", - SubathonPromptType.Orders => subType == SubathonPromptSubType.Items ? "Target Items" : "Target Orders", - SubathonPromptType.Subs => "Target Subs", - SubathonPromptType.Event => "Target Count", - _ => "Target Value" - }; -} - \ No newline at end of file + } + + public static string ValueLabel(this SubathonPromptType type, SubathonPromptSubType subType) { + return type switch { + SubathonPromptType.Points => "Target Points", + SubathonPromptType.Money => "Target Money (units)", + SubathonPromptType.Follows => "Target Follows", + SubathonPromptType.Tokens => "Target Tokens", + SubathonPromptType.Orders => subType == SubathonPromptSubType.Items ? "Target Items" : "Target Orders", + SubathonPromptType.Subs => "Target Subs", + SubathonPromptType.Event => "Target Count", + _ => "Target Value" + }; + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonSourceGroup.cs b/SubathonManager.Core/Enums/SubathonSourceGroup.cs index 5e845824..2ebde5d7 100644 --- a/SubathonManager.Core/Enums/SubathonSourceGroup.cs +++ b/SubathonManager.Core/Enums/SubathonSourceGroup.cs @@ -1,25 +1,27 @@ -using System.ComponentModel; +namespace SubathonManager.Core.Enums; -namespace SubathonManager.Core.Enums; - -public enum SubathonSourceGroup -{ - [EnumMeta(Description="Unknown", Label="Unknown")] +public enum SubathonSourceGroup { + [EnumMeta(Description = "Unknown", Label = "Unknown")] Unknown, - [EnumMeta(Description="Stream Services", Label="Stream Services")] + + [EnumMeta(Description = "Stream Services", Label = "Stream Services")] Stream, - [EnumMeta(Description="Stream Extensions", Label="Stream Extensions")] + + [EnumMeta(Description = "Stream Extensions", Label = "Stream Extensions")] StreamExtension, + // [Description("Chat Extension")] // ChatExtension, - [EnumMeta(Description="Misc", Label="Misc")] + [EnumMeta(Description = "Misc", Label = "Misc")] Misc, - [EnumMeta(Description="External Services", Label="External Services")] + + [EnumMeta(Description = "External Services", Label = "External Services")] ExternalService, - [EnumMeta(Label="")] - UseSource, - [EnumMeta(Description="Wheel Spin", Label="Wheel Spin")] + [EnumMeta(Label = "")] UseSource, + + [EnumMeta(Description = "Wheel Spin", Label = "Wheel Spin")] WheelSpin, - [EnumMeta(Description="External Software", Label="External Software")] - ExternalSoftware, -} + + [EnumMeta(Description = "External Software", Label = "External Software")] + ExternalSoftware +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/VTubeStudioEnums.cs b/SubathonManager.Core/Enums/VTubeStudioEnums.cs new file mode 100644 index 00000000..431a9212 --- /dev/null +++ b/SubathonManager.Core/Enums/VTubeStudioEnums.cs @@ -0,0 +1,25 @@ +namespace SubathonManager.Core.Enums; + +public enum VtsTargetKind { + Expression, + Parameter, + Hotkey +} + +public enum VtsToggleAction { + DoNothing, + On, + Off, + Toggle +} + +public enum VtsParameterAfterAction { + DoNothing, + ResetToOriginal, + SetNewValue +} + +public enum VtsHotkeyAfterAction { + DoNothing, + TriggerAgain +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/WebsocketClientMessageType.cs b/SubathonManager.Core/Enums/WebsocketClientMessageType.cs index d491ae77..55ee72ea 100644 --- a/SubathonManager.Core/Enums/WebsocketClientMessageType.cs +++ b/SubathonManager.Core/Enums/WebsocketClientMessageType.cs @@ -1,8 +1,8 @@ using System.Diagnostics.CodeAnalysis; + namespace SubathonManager.Core.Enums; -public enum WebsocketClientMessageType -{ +public enum WebsocketClientMessageType { None, Generic, // an unconfigured websocket client Widget, // consumes all, but is specifically for a widget @@ -15,21 +15,19 @@ public enum WebsocketClientMessageType } [ExcludeFromCodeCoverage] -public static class WebsocketClientTypeHelper -{ - public static readonly WebsocketClientMessageType[] ConsumersList = new[] - { +public static class WebsocketClientTypeHelper { + public static readonly WebsocketClientMessageType[] ConsumersList = new[] { WebsocketClientMessageType.Widget, WebsocketClientMessageType.IntegrationConsumer }; - - public static readonly WebsocketClientMessageType[] ConfigConsumersList = new[] - { + + public static readonly WebsocketClientMessageType[] ConfigConsumersList = new[] { WebsocketClientMessageType.Widget, WebsocketClientMessageType.IntegrationConsumer, WebsocketClientMessageType.ValueConfig }; - public static bool IsConsumer(this WebsocketClientMessageType varMessageType) => - ConsumersList.Contains(varMessageType); + public static bool IsConsumer(this WebsocketClientMessageType varMessageType) { + return ConsumersList.Contains(varMessageType); + } } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/WheelSpinEnums.cs b/SubathonManager.Core/Enums/WheelSpinEnums.cs index b7af708b..2ceb7f56 100644 --- a/SubathonManager.Core/Enums/WheelSpinEnums.cs +++ b/SubathonManager.Core/Enums/WheelSpinEnums.cs @@ -1,47 +1,64 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; namespace SubathonManager.Core.Enums; -public enum WheelSpinActionType -{ - [WheelSpinActionMeta(HasAction = false, Label="Manual / Other")] +public enum WheelSpinActionType { + [WheelSpinActionMeta(HasAction = false, Label = "Manual / Other")] Manual = 0, - [WheelSpinActionMeta(IsCommand = true, IsDoneImmediately = true, Label = "Add Time")] + + [WheelSpinActionMeta(IsCommand = true, IsDoneImmediately = true, Label = "Add Time")] AddTime = SubathonCommandType.AddTime, + [WheelSpinActionMeta(IsCommand = true, IsDoneImmediately = true, Label = "Subtract Time")] SubtractTime = SubathonCommandType.SubtractTime, + [WheelSpinActionMeta(IsCommand = true, Label = "Multiplier")] SetMultiplier = SubathonCommandType.SetMultiplier, + [WheelSpinActionMeta(IsDoneImmediately = true, Label = "Add Rerolls")] - Reroll = 1000 + Reroll = 1000, + + [WheelSpinActionMeta(Label = "VTube Studio")] + VTubeStudio = 1001 } [ExcludeFromCodeCoverage] -public static class WheelSpinActionTypeHelper -{ - public static string GetLabel(this WheelSpinActionType type) - => EnumMetaCache.Get(type)?.Label ?? $"{type}"; - - public static bool HasAction(this WheelSpinActionType type) - => EnumMetaCache.Get(type)?.HasAction ?? true; - - public static bool IsCommand(this WheelSpinActionType type) - => EnumMetaCache.Get(type)?.IsCommand ?? false; - - public static bool IsDoneImmediately(this WheelSpinActionType type) - => EnumMetaCache.Get(type)?.IsDoneImmediately ?? false; - - public static SubathonCommandType ToCommandType(this WheelSpinActionType type) - { +public static class WheelSpinActionTypeHelper { + public static string GetLabel(this WheelSpinActionType type) { + return EnumMetaCache.Get(type)?.Label ?? $"{type}"; + } + + public static bool HasAction(this WheelSpinActionType type) { + return EnumMetaCache.Get(type)?.HasAction ?? true; + } + + public static bool IsCommand(this WheelSpinActionType type) { + return EnumMetaCache.Get(type)?.IsCommand ?? false; + } + + public static bool IsDoneImmediately(this WheelSpinActionType type) { + return EnumMetaCache.Get(type)?.IsDoneImmediately ?? false; + } + + public static bool IsAvailable(this WheelSpinActionType type) { + if (type == WheelSpinActionType.VTubeStudio && !FeatureFlags.VTubeStudioEnabled) + return false; + return true; + } + + public static bool HasPlayAction(this WheelSpinActionType type) { + return type.IsAvailable() && (type.IsCommand() || type == WheelSpinActionType.VTubeStudio); + } + + public static SubathonCommandType ToCommandType(this WheelSpinActionType type) { if (!type.IsCommand()) return SubathonCommandType.Unknown; return (SubathonCommandType)(int)type; } } -public enum WheelSpinHistoryStatus -{ +public enum WheelSpinHistoryStatus { Pending, Done, Cancelled -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/WidgetType.cs b/SubathonManager.Core/Enums/WidgetType.cs index c57be4b5..29409fb3 100644 --- a/SubathonManager.Core/Enums/WidgetType.cs +++ b/SubathonManager.Core/Enums/WidgetType.cs @@ -1,14 +1,12 @@ namespace SubathonManager.Core.Enums; -public enum WidgetType -{ +public enum WidgetType { Html = 0, Image = 1, Video = 2 } -public enum WidgetContextAction -{ +public enum WidgetContextAction { ToggleVisibility, Clone, Delete, @@ -16,37 +14,35 @@ public enum WidgetContextAction Refresh } -public enum WidgetDisplayKind -{ - Widget,// - UnpackedWidget,// +public enum WidgetDisplayKind { + Widget, // + UnpackedWidget, // Image, Video } -public enum WidgetCatalogSource -{ +public enum WidgetCatalogSource { Imported, Preset } -public static class WidgetTypeHelper -{ +public static class WidgetTypeHelper { private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".bmp", ".svg" }; private static readonly HashSet VideoExtensions = new(StringComparer.OrdinalIgnoreCase) { ".mp4", ".m4v", ".webm", ".ogm", ".mkv", ".mov" }; - public static WidgetType DetectFromPath(string path) - { - var ext = Path.GetExtension(path); - + public static WidgetType DetectFromPath(string path) { + string ext = Path.GetExtension(path); + if (ImageExtensions.Contains(ext)) return WidgetType.Image; if (VideoExtensions.Contains(ext)) return WidgetType.Video; - + return WidgetType.Html; } - public static bool IsAsset(this WidgetType type) => type != WidgetType.Html; -} + public static bool IsAsset(this WidgetType type) { + return type != WidgetType.Html; + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/WidgetVariableType.cs b/SubathonManager.Core/Enums/WidgetVariableType.cs index 57c94c01..9ed31923 100644 --- a/SubathonManager.Core/Enums/WidgetVariableType.cs +++ b/SubathonManager.Core/Enums/WidgetVariableType.cs @@ -1,8 +1,8 @@ using System.Diagnostics.CodeAnalysis; + namespace SubathonManager.Core.Enums; -public enum WidgetVariableType -{ +public enum WidgetVariableType { Int, String, Float, @@ -31,95 +31,99 @@ public enum WidgetVariableType } [ExcludeFromCodeCoverage] -public static class WidgetVariableTypeHelper -{ - private static readonly WidgetVariableType[] FileVariables = - [ +public static class WidgetVariableTypeHelper { + private static readonly WidgetVariableType[] FileVariables = [ WidgetVariableType.AnyFile, WidgetVariableType.ImageFile, WidgetVariableType.VideoFile, WidgetVariableType.SoundFile, WidgetVariableType.FolderPath ]; - - public static readonly WidgetVariableType[] FontVariables = - [ + + public static readonly WidgetVariableType[] FontVariables = [ WidgetVariableType.CdnFont, WidgetVariableType.GoogleFont, - WidgetVariableType.BunnyFont, + WidgetVariableType.BunnyFont ]; - public static bool IsFontVariable(this WidgetVariableType type) => FontVariables.Contains(type); - - public static bool IsFileVariable(this WidgetVariableType? varType) => - varType.HasValue && FileVariables.Contains(varType.Value); - - public static bool IsEnumVariable(this WidgetVariableType? varType) => - varType.HasValue && GetClsSingleType(varType.Value).IsEnum; - - public static List GetFilteredEventTypes(this WidgetVariableType varType) => varType switch - { - WidgetVariableType.OrderEventTypeList => SubathonEventSubTypeHelper.OrderEventTypes, - WidgetVariableType.SubEventTypeList => SubathonEventSubTypeHelper.SubEventTypes, - WidgetVariableType.TokenEventTypeList => SubathonEventSubTypeHelper.TokenEventTypes, - WidgetVariableType.FollowEventTypeList => SubathonEventSubTypeHelper.FollowEventTypes, - WidgetVariableType.DonationEventTypeList => SubathonEventSubTypeHelper.DonationEventTypes, - WidgetVariableType.GenericEventTypeList => SubathonEventSubTypeHelper.GenericEventTypes, - WidgetVariableType.EventTypeList => Enum.GetValues().ToList(), - _ => [] - }; - - public static bool IsListType(this WidgetVariableType varType) => varType switch - { - WidgetVariableType.OrderEventTypeList => true, - WidgetVariableType.SubEventTypeList => true, - WidgetVariableType.TokenEventTypeList => true, - WidgetVariableType.FollowEventTypeList => true, - WidgetVariableType.DonationEventTypeList => true, - WidgetVariableType.EventTypeList => true, - WidgetVariableType.StringList => true, - WidgetVariableType.EventSubTypeList => true, - WidgetVariableType.StringSelect => true, - WidgetVariableType.EventTypeSelect => true, - WidgetVariableType.EventSubTypeSelect => true, - WidgetVariableType.GenericEventTypeList => true, - _ => false - }; - public static Type GetClsSingleType(this WidgetVariableType varType) => varType switch - { - WidgetVariableType.Int => typeof(int), - WidgetVariableType.String => typeof(string), - WidgetVariableType.Float => typeof(float), - WidgetVariableType.Boolean => typeof(bool), - WidgetVariableType.Percent => typeof(int), // short - - WidgetVariableType.StringList => typeof(string), - WidgetVariableType.EventTypeList => typeof(SubathonEventType), - WidgetVariableType.EventSubTypeList => typeof(SubathonEventSubType), - WidgetVariableType.StringSelect => typeof(string), - WidgetVariableType.EventTypeSelect => typeof(SubathonEventType), - WidgetVariableType.EventSubTypeSelect => typeof(SubathonEventSubType), - - WidgetVariableType.AnyFile => typeof(string), - WidgetVariableType.ImageFile => typeof(string), - WidgetVariableType.VideoFile => typeof(string), - WidgetVariableType.SoundFile => typeof(string), - WidgetVariableType.FolderPath => typeof(string), - - WidgetVariableType.OrderEventTypeList => typeof(SubathonEventType), - WidgetVariableType.TokenEventTypeList => typeof(SubathonEventType), - WidgetVariableType.SubEventTypeList => typeof(SubathonEventType), - WidgetVariableType.FollowEventTypeList => typeof(SubathonEventType), - WidgetVariableType.DonationEventTypeList => typeof(SubathonEventType), - - _ => typeof(string) - }; + public static bool IsFontVariable(this WidgetVariableType type) { + return FontVariables.Contains(type); + } + + public static bool IsFileVariable(this WidgetVariableType? varType) { + return varType.HasValue && FileVariables.Contains(varType.Value); + } + + public static bool IsEnumVariable(this WidgetVariableType? varType) { + return varType.HasValue && GetClsSingleType(varType.Value).IsEnum; + } + + public static List GetFilteredEventTypes(this WidgetVariableType varType) { + return varType switch { + WidgetVariableType.OrderEventTypeList => SubathonEventSubTypeHelper.OrderEventTypes, + WidgetVariableType.SubEventTypeList => SubathonEventSubTypeHelper.SubEventTypes, + WidgetVariableType.TokenEventTypeList => SubathonEventSubTypeHelper.TokenEventTypes, + WidgetVariableType.FollowEventTypeList => SubathonEventSubTypeHelper.FollowEventTypes, + WidgetVariableType.DonationEventTypeList => SubathonEventSubTypeHelper.DonationEventTypes, + WidgetVariableType.GenericEventTypeList => SubathonEventSubTypeHelper.GenericEventTypes, + WidgetVariableType.EventTypeList => Enum.GetValues().ToList(), + _ => [] + }; + } + + public static bool IsListType(this WidgetVariableType varType) { + return varType switch { + WidgetVariableType.OrderEventTypeList => true, + WidgetVariableType.SubEventTypeList => true, + WidgetVariableType.TokenEventTypeList => true, + WidgetVariableType.FollowEventTypeList => true, + WidgetVariableType.DonationEventTypeList => true, + WidgetVariableType.EventTypeList => true, + WidgetVariableType.StringList => true, + WidgetVariableType.EventSubTypeList => true, + WidgetVariableType.StringSelect => true, + WidgetVariableType.EventTypeSelect => true, + WidgetVariableType.EventSubTypeSelect => true, + WidgetVariableType.GenericEventTypeList => true, + _ => false + }; + } + + public static Type GetClsSingleType(this WidgetVariableType varType) { + return varType switch { + WidgetVariableType.Int => typeof(int), + WidgetVariableType.String => typeof(string), + WidgetVariableType.Float => typeof(float), + WidgetVariableType.Boolean => typeof(bool), + WidgetVariableType.Percent => typeof(int), // short + + WidgetVariableType.StringList => typeof(string), + WidgetVariableType.EventTypeList => typeof(SubathonEventType), + WidgetVariableType.EventSubTypeList => typeof(SubathonEventSubType), + WidgetVariableType.StringSelect => typeof(string), + WidgetVariableType.EventTypeSelect => typeof(SubathonEventType), + WidgetVariableType.EventSubTypeSelect => typeof(SubathonEventSubType), + + WidgetVariableType.AnyFile => typeof(string), + WidgetVariableType.ImageFile => typeof(string), + WidgetVariableType.VideoFile => typeof(string), + WidgetVariableType.SoundFile => typeof(string), + WidgetVariableType.FolderPath => typeof(string), + + WidgetVariableType.OrderEventTypeList => typeof(SubathonEventType), + WidgetVariableType.TokenEventTypeList => typeof(SubathonEventType), + WidgetVariableType.SubEventTypeList => typeof(SubathonEventType), + WidgetVariableType.FollowEventTypeList => typeof(SubathonEventType), + WidgetVariableType.DonationEventTypeList => typeof(SubathonEventType), + + _ => typeof(string) + }; + } } -public enum WidgetCssVariableType -{ +public enum WidgetCssVariableType { Default, // same as string - String, + String, Color, Alignment, Size, @@ -130,14 +134,17 @@ public enum WidgetCssVariableType } [ExcludeFromCodeCoverage] -public static class WidgetCssVariableTypeHelper -{ - public static List GetOptions(this WidgetCssVariableType varType) => varType switch - { - WidgetCssVariableType.Alignment => ["left", "center", "right"], - WidgetCssVariableType.Size => ["px", "%", "pt", "rem", "em", "vh", "vw", "vmin", "vmax", "cm", "mm", "in", "ch", "ex"], - WidgetCssVariableType.Weight => ["normal", "bold", "100", "200", "300", "400", "500", "600", "700", "800", "900", - "bolder", "lighter", "initial", "inherit"], - _ => [] - }; +public static class WidgetCssVariableTypeHelper { + public static List GetOptions(this WidgetCssVariableType varType) { + return varType switch { + WidgetCssVariableType.Alignment => ["left", "center", "right"], + WidgetCssVariableType.Size => + ["px", "%", "pt", "rem", "em", "vh", "vw", "vmin", "vmax", "cm", "mm", "in", "ch", "ex"], + WidgetCssVariableType.Weight => [ + "normal", "bold", "100", "200", "300", "400", "500", "600", "700", "800", "900", + "bolder", "lighter", "initial", "inherit" + ], + _ => [] + }; + } } \ No newline at end of file diff --git a/SubathonManager.Core/Events/ErrorMessageEvents.cs b/SubathonManager.Core/Events/ErrorMessageEvents.cs index fd9f256a..badd9e52 100644 --- a/SubathonManager.Core/Events/ErrorMessageEvents.cs +++ b/SubathonManager.Core/Events/ErrorMessageEvents.cs @@ -1,18 +1,17 @@ using System.Diagnostics.CodeAnalysis; + namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class ErrorMessageEvents -{ +public static class ErrorMessageEvents { public static event Action? ErrorEventOccured; public static event Action? SendCustomEvent; - - public static void RaiseErrorEvent(string level, string source, string message, DateTime time) - { + + public static void RaiseErrorEvent(string level, string source, string message, DateTime time) { ErrorEventOccured?.Invoke(level, source, message, time); } - public static void RaiseCustomEvent(string message) - { + + public static void RaiseCustomEvent(string message) { SendCustomEvent?.Invoke(message); } } \ No newline at end of file diff --git a/SubathonManager.Core/Events/IntegrationEvents.cs b/SubathonManager.Core/Events/IntegrationEvents.cs index 85edce9a..5f41bed1 100644 --- a/SubathonManager.Core/Events/IntegrationEvents.cs +++ b/SubathonManager.Core/Events/IntegrationEvents.cs @@ -4,25 +4,21 @@ namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class IntegrationEvents -{ +public static class IntegrationEvents { public static event Action? ConnectionUpdated; // status, src, acc name, service public static event Action>? FourthWallMembershipsSynced; public static event Action? DevTunnelLegacyNotification; - public static void RaiseConnectionUpdate(IntegrationConnection connection) - { + public static void RaiseConnectionUpdate(IntegrationConnection connection) { Utils.UpdateConnection(connection); ConnectionUpdated?.Invoke(connection); } - public static void RaiseFourthWallMembershipsSynced(Dictionary synced) - { + public static void RaiseFourthWallMembershipsSynced(Dictionary synced) { FourthWallMembershipsSynced?.Invoke(synced); } - public static void RaiseDevTunnelLegacyNotification() - { + public static void RaiseDevTunnelLegacyNotification() { DevTunnelLegacyNotification?.Invoke(); } } \ No newline at end of file diff --git a/SubathonManager.Core/Events/OverlayEvents.cs b/SubathonManager.Core/Events/OverlayEvents.cs index 5803145d..5fe1957b 100644 --- a/SubathonManager.Core/Events/OverlayEvents.cs +++ b/SubathonManager.Core/Events/OverlayEvents.cs @@ -4,26 +4,26 @@ namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class OverlayEvents -{ +public static class OverlayEvents { public static event Action? OverlayRefreshRequested; public static event Action? WidgetRefreshRequested; public static event Action, IEnumerable>? WidgetVarsUpdated; - public static void RaiseOverlayRefreshRequested(Guid routeGuid) - { + public static void RaiseOverlayRefreshRequested(Guid routeGuid) { OverlayRefreshRequested?.Invoke(routeGuid); } - public static void RaiseOverlayRefreshAllRequested() - { + public static void RaiseOverlayRefreshAllRequested() { OverlayRefreshRequested?.Invoke(Guid.Empty); } public static void RaiseWidgetVarsUpdated(Guid widgetId, - IEnumerable cssVars, IEnumerable jsVars) => + IEnumerable cssVars, IEnumerable jsVars) { WidgetVarsUpdated?.Invoke(widgetId, cssVars, jsVars); - - public static void RaiseWidgetRefreshRequested(Guid widgetId, float x, float y, int width, int height, float scaleX, float scaleY) - => WidgetRefreshRequested?.Invoke(widgetId, x, y, width, height, scaleX, scaleY); + } + + public static void RaiseWidgetRefreshRequested(Guid widgetId, float x, float y, int width, int height, float scaleX, + float scaleY) { + WidgetRefreshRequested?.Invoke(widgetId, x, y, width, height, scaleX, scaleY); + } } \ No newline at end of file diff --git a/SubathonManager.Core/Events/SettingsEvents.cs b/SubathonManager.Core/Events/SettingsEvents.cs index 5d24deff..963593b8 100644 --- a/SubathonManager.Core/Events/SettingsEvents.cs +++ b/SubathonManager.Core/Events/SettingsEvents.cs @@ -4,30 +4,25 @@ namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class SettingsEvents -{ +public static class SettingsEvents { public static event Action? SettingsUnsavedChanges; public static event Action? EventVisibilityChanged; public static event Action? HotLinkToDevTunnelsRequested; public static event Action? HotLinkToSourceRequested; - public static void RaiseSettingsUnsavedChanges(bool hasPendingChanges) - { + public static void RaiseSettingsUnsavedChanges(bool hasPendingChanges) { SettingsUnsavedChanges?.Invoke(hasPendingChanges); } - public static void RaiseEventVisibilityChanged() - { + public static void RaiseEventVisibilityChanged() { EventVisibilityChanged?.Invoke(); } - public static void RaiseHotLinkToDevTunnelsRequest() - { + public static void RaiseHotLinkToDevTunnelsRequest() { HotLinkToDevTunnelsRequested?.Invoke(); } - public static void RaiseHotLinkToSourceRequest(SubathonEventSource source, string? detail = null) - { + public static void RaiseHotLinkToSourceRequest(SubathonEventSource source, string? detail = null) { HotLinkToSourceRequested?.Invoke(source, detail); } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Events/SubathonEvents.cs b/SubathonManager.Core/Events/SubathonEvents.cs index 2b494f33..66df15f8 100644 --- a/SubathonManager.Core/Events/SubathonEvents.cs +++ b/SubathonManager.Core/Events/SubathonEvents.cs @@ -1,26 +1,28 @@ -using SubathonManager.Core.Models; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; using SubathonManager.Core.Objects; namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class SubathonEvents -{ +public static class SubathonEvents { public static event Action? SubathonEventCreated; - public static event Action? SubathonEventProcessed; // Run through queue, processed or not to subathon + + public static event Action? + SubathonEventProcessed; // Run through queue, processed or not to subathon + public static event Action>? SubathonEventsDeleted; public static event Action? SubathonDataUpdate; - + public static event Action, long, GoalsType>? SubathonGoalListUpdated; public static event Action? SubathonGoalCompleted; public static event Action? SubathonValueConfigRequested; public static event Action? SubathonValueConfigUpdatedRemote; - + public static event Action>? SubathonValuesPatched; - + public static event Action? SubathonTotalsUpdated; public static event Action? SubscriptionTotalsUpdated; @@ -30,83 +32,78 @@ public static class SubathonEvents public static event Action? PromptRunCancelRequested; public static event Action? PromptRunNowRequested; - public static void RaiseSubathonTotalsUpdated(SubathonTotals totals) - { + public static void RaiseSubathonTotalsUpdated(SubathonTotals totals) { SubathonTotalsUpdated?.Invoke(totals); } - public static void RaiseSubscriptionTotalsUpdated(SubscriptionTotals totals) - { + public static void RaiseSubscriptionTotalsUpdated(SubscriptionTotals totals) { SubscriptionTotalsUpdated?.Invoke(totals); } - - public static void RaisePromptRunStarted(SubathonPromptRun run, SubathonPrompt? prompt) - => PromptRunStarted?.Invoke(run, prompt); - - public static void RaisePromptRunUpdate(SubathonPromptRun run, SubathonPrompt? prompt) - => PromptRunUpdate?.Invoke(run, prompt); - - public static void RaisePromptRunProgressUpdated(SubathonPromptRun run, long progress) - => PromptRunProgressUpdated?.Invoke(run, progress); - - public static void RaisePromptRunNowRequested(Guid guid) - => PromptRunNowRequested?.Invoke(guid); - - - public static void RaiseSubathonValuesPatched(List values) - { + + public static void RaisePromptRunStarted(SubathonPromptRun run, SubathonPrompt? prompt) { + PromptRunStarted?.Invoke(run, prompt); + } + + public static void RaisePromptRunUpdate(SubathonPromptRun run, SubathonPrompt? prompt) { + PromptRunUpdate?.Invoke(run, prompt); + } + + public static void RaisePromptRunProgressUpdated(SubathonPromptRun run, long progress) { + PromptRunProgressUpdated?.Invoke(run, progress); + } + + public static void RaisePromptRunNowRequested(Guid guid) { + PromptRunNowRequested?.Invoke(guid); + } + + + public static void RaiseSubathonValuesPatched(List values) { SubathonValuesPatched?.Invoke(values); } - - public static void RaisePromptRunCancelRequested() - => PromptRunCancelRequested?.Invoke(); - + + public static void RaisePromptRunCancelRequested() { + PromptRunCancelRequested?.Invoke(); + } + public static event Action? PromptSetEnabledChanged; - - public static void RaisePromptSetEnabledChanged(bool enabled) - => PromptSetEnabledChanged?.Invoke(enabled); + + public static void RaisePromptSetEnabledChanged(bool enabled) { + PromptSetEnabledChanged?.Invoke(enabled); + } - - public static void RaiseSubathonEventsDeleted(List subathonEvent) - { + public static void RaiseSubathonEventsDeleted(List subathonEvent) { SubathonEventsDeleted?.Invoke(subathonEvent); } - public static void RaiseSubathonEventCreated(SubathonEvent subathonEvent) - { + public static void RaiseSubathonEventCreated(SubathonEvent subathonEvent) { SubathonEventCreated?.Invoke(subathonEvent); } - - public static void RaiseSubathonEventProcessed(SubathonEvent subathonEvent, bool wasEffective) - { + + public static void RaiseSubathonEventProcessed(SubathonEvent subathonEvent, bool wasEffective) { SubathonEventProcessed?.Invoke(subathonEvent, wasEffective); } - public static void RaiseSubathonValueConfigRequested(string jsonData) - { + public static void RaiseSubathonValueConfigRequested(string jsonData) { SubathonValueConfigRequested?.Invoke(jsonData); } - - public static void RaiseSubathonValueConfigUpdatedRemote() - { + + public static void RaiseSubathonValueConfigUpdatedRemote() { SubathonValueConfigUpdatedRemote?.Invoke(); } - - public static void RaiseSubathonDataUpdate(SubathonData data, DateTime time) - { + + public static void RaiseSubathonDataUpdate(SubathonData data, DateTime time) { SubathonDataUpdate?.Invoke(data, time); } - public static void RaiseSubathonGoalCompleted(SubathonGoal goal, long points) - { + public static void RaiseSubathonGoalCompleted(SubathonGoal goal, long points) { // do we want all goals? SubathonGoalCompleted?.Invoke(goal, points); } - public static void RaiseSubathonGoalListUpdated(List goals, long points, GoalsType type = GoalsType.Points) - { + public static void RaiseSubathonGoalListUpdated(List goals, long points, + GoalsType type = GoalsType.Points) { SubathonGoalListUpdated?.Invoke(goals, points, type); } } \ No newline at end of file diff --git a/SubathonManager.Core/Events/TimerEvents.cs b/SubathonManager.Core/Events/TimerEvents.cs index b3fffc2e..ce4ef761 100644 --- a/SubathonManager.Core/Events/TimerEvents.cs +++ b/SubathonManager.Core/Events/TimerEvents.cs @@ -1,13 +1,12 @@ using System.Diagnostics.CodeAnalysis; + namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class TimerEvents -{ +public static class TimerEvents { public static event Action? TimerTickEvent; - - public static void RaiseTimerTickEvent(TimeSpan time) - { + + public static void RaiseTimerTickEvent(TimeSpan time) { TimerTickEvent?.Invoke(time); } } \ No newline at end of file diff --git a/SubathonManager.Core/Events/WebServerEvents.cs b/SubathonManager.Core/Events/WebServerEvents.cs index 4379202a..153ecef1 100644 --- a/SubathonManager.Core/Events/WebServerEvents.cs +++ b/SubathonManager.Core/Events/WebServerEvents.cs @@ -5,22 +5,18 @@ namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class WebServerEvents -{ +public static class WebServerEvents { public static event Action? WebServerStatusChanged; public static event Action? WebSocketIntegrationSourceChange; - - public static void RaiseWebServerStatusChange(bool status) - { + + public static void RaiseWebServerStatusChange(bool status) { WebServerStatusChanged?.Invoke(status); } - public static void RaiseWebSocketIntegrationSourceChange(string integrationSource, bool status) - { + public static void RaiseWebSocketIntegrationSourceChange(string integrationSource, bool status) { WebSocketIntegrationSourceChange?.Invoke(integrationSource, status); if (!Enum.TryParse(integrationSource, out SubathonEventSource subathonEventSource)) return; - IntegrationConnection conn = new IntegrationConnection - { + var conn = new IntegrationConnection { Name = "External", Status = status, Source = subathonEventSource, diff --git a/SubathonManager.Core/Events/WheelEvents.cs b/SubathonManager.Core/Events/WheelEvents.cs index e3cffac0..7182b0cb 100644 --- a/SubathonManager.Core/Events/WheelEvents.cs +++ b/SubathonManager.Core/Events/WheelEvents.cs @@ -4,8 +4,7 @@ namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class WheelEvents -{ +public static class WheelEvents { public static event Action? WheelSpinStarted; public static event Action? WheelSpinResult; public static event Action? WheelSpinStatusChanged; @@ -16,29 +15,36 @@ public static class WheelEvents public static event Action? WheelSpinTriggersChanged; public static event Action? WheelSpinRequested; - public static void RaiseSpinsOwedUpdateFromEvent(int amount) - { + public static void RaiseSpinsOwedUpdateFromEvent(int amount) { OnSpinsOwedUpdateFromEvent?.Invoke(amount); } - public static void RaiseWheelSpinStarted(WheelSet wheel, int delaySeconds) - => WheelSpinStarted?.Invoke(wheel, delaySeconds); + public static void RaiseWheelSpinStarted(WheelSet wheel, int delaySeconds) { + WheelSpinStarted?.Invoke(wheel, delaySeconds); + } - public static void RaiseWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int spinsOwed) - => WheelSpinResult?.Invoke(wheel, item, history, spinsOwed); + public static void RaiseWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int spinsOwed) { + WheelSpinResult?.Invoke(wheel, item, history, spinsOwed); + } - public static void RaiseWheelSpinStatusChanged(WheelSpinHistory history, int spinsOwed) - => WheelSpinStatusChanged?.Invoke(history, spinsOwed); + public static void RaiseWheelSpinStatusChanged(WheelSpinHistory history, int spinsOwed) { + WheelSpinStatusChanged?.Invoke(history, spinsOwed); + } - public static void RaiseWheelDataChanged(WheelSet wheel, int spinsOwed) - => WheelDataChanged?.Invoke(wheel, spinsOwed); + public static void RaiseWheelDataChanged(WheelSet wheel, int spinsOwed) { + WheelDataChanged?.Invoke(wheel, spinsOwed); + } - public static void RaiseWheelSpinTriggerFired(WheelSpinTrigger trigger, WheelSpinTriggerHistory history, int newSpinsOwed) - => WheelSpinTriggerFired?.Invoke(trigger, history, newSpinsOwed); + public static void RaiseWheelSpinTriggerFired(WheelSpinTrigger trigger, WheelSpinTriggerHistory history, + int newSpinsOwed) { + WheelSpinTriggerFired?.Invoke(trigger, history, newSpinsOwed); + } - public static void RaiseWheelSpinTriggersChanged() - => WheelSpinTriggersChanged?.Invoke(); + public static void RaiseWheelSpinTriggersChanged() { + WheelSpinTriggersChanged?.Invoke(); + } - public static void RaiseWheelSpinRequested() - => WheelSpinRequested?.Invoke(); -} + public static void RaiseWheelSpinRequested() { + WheelSpinRequested?.Invoke(); + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Events/WidgetEvents.cs b/SubathonManager.Core/Events/WidgetEvents.cs index 360b0f60..8d87328a 100644 --- a/SubathonManager.Core/Events/WidgetEvents.cs +++ b/SubathonManager.Core/Events/WidgetEvents.cs @@ -1,39 +1,34 @@ -using SubathonManager.Core.Enums; +using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Enums; using SubathonManager.Core.Models; -using System.Diagnostics.CodeAnalysis; + namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class WidgetEvents -{ +public static class WidgetEvents { public static event Action? WidgetPositionUpdated; public static event Action? WidgetScaleUpdated; public static event Action? WidgetSizeUpdated; public static event Action? SelectEditorWidget; public static event Action? WidgetActionRequested; - public static void RaiseWidgetAction(Guid widgetId, WidgetContextAction action) - { + public static void RaiseWidgetAction(Guid widgetId, WidgetContextAction action) { WidgetActionRequested?.Invoke(widgetId, action); } - public static void RaisePositionUpdated(Widget widget) - { + public static void RaisePositionUpdated(Widget widget) { WidgetPositionUpdated?.Invoke(widget); } - public static void RaiseSelectEditorWidget(Guid widgetId) - { + public static void RaiseSelectEditorWidget(Guid widgetId) { SelectEditorWidget?.Invoke(widgetId); } - - public static void RaiseScaleUpdated(Widget widget) - { + + public static void RaiseScaleUpdated(Widget widget) { WidgetScaleUpdated?.Invoke(widget); } - - public static void RaiseSizeUpdated(Widget widget) - { + + public static void RaiseSizeUpdated(Widget widget) { WidgetSizeUpdated?.Invoke(widget); } } \ No newline at end of file diff --git a/SubathonManager.Core/FeatureFlags.cs b/SubathonManager.Core/FeatureFlags.cs index 34677cab..6cd2427c 100644 --- a/SubathonManager.Core/FeatureFlags.cs +++ b/SubathonManager.Core/FeatureFlags.cs @@ -1,6 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace SubathonManager.Core; -public static class FeatureFlags -{ +[ExcludeFromCodeCoverage] +public static class FeatureFlags { public static readonly bool KoFiStreamerBotSetupEnabled = false; -} + public static readonly bool VTubeStudioEnabled = true; + public static readonly bool VTubeStudioMarkAsExperimental = true; + public static readonly bool GoAffProBackfillPollingEnabled = true; +} \ No newline at end of file diff --git a/SubathonManager.Core/GoAffProConfigMigration.cs b/SubathonManager.Core/GoAffProConfigMigration.cs index d086e301..b9bb9b65 100644 --- a/SubathonManager.Core/GoAffProConfigMigration.cs +++ b/SubathonManager.Core/GoAffProConfigMigration.cs @@ -1,22 +1,20 @@ using System.Diagnostics.CodeAnalysis; +using IniParser.Model; using SubathonManager.Core.Enums; using SubathonManager.Core.Interfaces; namespace SubathonManager.Core; [ExcludeFromCodeCoverage] -public static class GoAffProConfigMigration -{ - public static bool Run(IConfig config) - { - bool migrated = false; - var discord = config.GetSection("Discord"); +public static class GoAffProConfigMigration { + public static bool Run(IConfig config) { + var migrated = false; + KeyDataCollection? discord = config.GetSection("Discord"); if (discord == null) return false; - foreach (var legacyType in Enum.GetValues() + foreach (SubathonEventType legacyType in Enum.GetValues() .Where(t => t.GetSource() == SubathonEventSource.GoAffPro - && t.GetLegacyGoAffProSiteId() > 0)) - { + && t.GetLegacyGoAffProSiteId() > 0)) { var oldKey = $"Events.Log.{legacyType}"; if (!discord.ContainsKey(oldKey)) continue; @@ -30,4 +28,4 @@ public static bool Run(IConfig config) if (migrated) config.Save(); return migrated; } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Interfaces/IAppService.cs b/SubathonManager.Core/Interfaces/IAppService.cs index ada6b235..7b4118e1 100644 --- a/SubathonManager.Core/Interfaces/IAppService.cs +++ b/SubathonManager.Core/Interfaces/IAppService.cs @@ -1,8 +1,6 @@ namespace SubathonManager.Core.Interfaces; -public interface IAppService -{ +public interface IAppService { Task StartAsync(CancellationToken ct = default); Task StopAsync(CancellationToken ct = default); - } \ No newline at end of file diff --git a/SubathonManager.Core/Interfaces/IConfig.cs b/SubathonManager.Core/Interfaces/IConfig.cs index ecd3f696..f0d5209a 100644 --- a/SubathonManager.Core/Interfaces/IConfig.cs +++ b/SubathonManager.Core/Interfaces/IConfig.cs @@ -1,26 +1,28 @@ -using SubathonManager.Core.Enums; +using IniParser.Model; +using SubathonManager.Core.Enums; namespace SubathonManager.Core.Interfaces; -public interface IConfig -{ +public interface IConfig { string GetDatabasePath(); void Save(); void LoadOrCreateDefault(); - + bool MigrateConfig(); - - IniParser.Model.KeyDataCollection? GetSection(string sectionName); + + KeyDataCollection? GetSection(string sectionName); string? Get(string section, string key, string? defaultValue = ""); bool GetBool(string section, string key, bool defaultValue = false); string? GetFromEncoded(string section, string key, string? defaultValue = ""); - - bool Set(string section, string key, string value); /** Returns true if successfully changed, false if it was already equal **/ + bool Set(string section, string key, string value); + + /** Returns true if successfully changed, false if it was already equal **/ bool SetBool(string section, string key, bool? value); + bool SetEncoded(string section, string key, string value); - + OrderTypeModes GetOrderTypeMode(string section, string orderEnumName, OrderTypeModes modeDefault); bool SetOrderTypeMode(string section, string orderEnumName, OrderTypeModes mode); diff --git a/SubathonManager.Core/Interfaces/ITimerService.cs b/SubathonManager.Core/Interfaces/ITimerService.cs index 7cb6e9cc..990449b4 100644 --- a/SubathonManager.Core/Interfaces/ITimerService.cs +++ b/SubathonManager.Core/Interfaces/ITimerService.cs @@ -1,8 +1,7 @@ namespace SubathonManager.Core.Interfaces; -public interface ITimerService -{ +public interface ITimerService { IDisposable Register(string key, TimeSpan interval, Func callback); IDisposable Register(string key, TimeSpan interval, Action callback); void Unregister(string key); -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Interfaces/IWebhookIntegration.cs b/SubathonManager.Core/Interfaces/IWebhookIntegration.cs index c3872399..9f2bfe82 100644 --- a/SubathonManager.Core/Interfaces/IWebhookIntegration.cs +++ b/SubathonManager.Core/Interfaces/IWebhookIntegration.cs @@ -1,8 +1,8 @@ namespace SubathonManager.Core.Interfaces; -public interface IWebhookIntegration : IAppService -{ +public interface IWebhookIntegration : IAppService { string WebhookPath { get; } - Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, CancellationToken ct = default); -} + Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs b/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs index be1e5920..e962922f 100644 --- a/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs +++ b/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs @@ -1,7 +1,6 @@ namespace SubathonManager.Core.Interfaces; -public interface IWidgetFileSystem -{ +public interface IWidgetFileSystem { bool Exists(string path); string? ReadAllText(string path); byte[]? ReadAllBytes(string path); @@ -10,21 +9,34 @@ public interface IWidgetFileSystem IEnumerable EnumerateFiles(string directory); } -public sealed class DiskWidgetFileSystem : IWidgetFileSystem -{ - public bool Exists(string path) => File.Exists(path); - public string? ReadAllText(string path) => File.Exists(path) ? File.ReadAllText(path) : null; - public byte[]? ReadAllBytes(string path) => File.Exists(path) ? File.ReadAllBytes(path) : null; - public bool IsPacked(string path) => false; - public string? GetRealFilePath(string path) => File.Exists(path) ? path : null; +public sealed class DiskWidgetFileSystem : IWidgetFileSystem { + public bool Exists(string path) { + return File.Exists(path); + } - public IEnumerable EnumerateFiles(string directory) => - Directory.Exists(directory) + public string? ReadAllText(string path) { + return File.Exists(path) ? File.ReadAllText(path) : null; + } + + public byte[]? ReadAllBytes(string path) { + return File.Exists(path) ? File.ReadAllBytes(path) : null; + } + + public bool IsPacked(string path) { + return false; + } + + public string? GetRealFilePath(string path) { + return File.Exists(path) ? path : null; + } + + public IEnumerable EnumerateFiles(string directory) { + return Directory.Exists(directory) ? Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) : []; + } } -public static class WidgetFiles -{ +public static class WidgetFiles { public static IWidgetFileSystem Current { get; set; } = new DiskWidgetFileSystem(); -} +} \ No newline at end of file diff --git a/SubathonManager.Core/MakeShipTrackingRegistry.cs b/SubathonManager.Core/MakeShipTrackingRegistry.cs index aeb0612d..b73a40c2 100644 --- a/SubathonManager.Core/MakeShipTrackingRegistry.cs +++ b/SubathonManager.Core/MakeShipTrackingRegistry.cs @@ -1,57 +1,57 @@ - using System.Globalization; using SubathonManager.Core.Enums; using SubathonManager.Core.Models; namespace SubathonManager.Core; -public static class MakeShipTrackingRegistry -{ +public static class MakeShipTrackingRegistry { public const string PetitionUrlPrefix = "https://www.makeship.com/petitions/"; public const string ProductUrlPrefix = "https://www.makeship.com/products/"; - - private static TextInfo _textInfo = new CultureInfo("en-US",false).TextInfo; + + private static readonly TextInfo _textInfo = new CultureInfo("en-US", false).TextInfo; private static readonly Lock Lock = new(); private static readonly Dictionary ById = new(); public static event Action? TrackingUpdated; - public static void Initialize(IEnumerable trackings) - { - lock (Lock) - { + public static void Initialize(IEnumerable trackings) { + lock (Lock) { ById.Clear(); - foreach (var tracking in trackings) + foreach (MakeShipTracking tracking in trackings) ById[tracking.Id] = tracking; } } - public static List All() - { - lock (Lock) { return ById.Values.ToList(); } + public static List All() { + lock (Lock) { + return ById.Values.ToList(); + } } - public static bool TryGet(Guid id, out MakeShipTracking? tracking) - { - lock (Lock) { return ById.TryGetValue(id, out tracking); } + public static bool TryGet(Guid id, out MakeShipTracking? tracking) { + lock (Lock) { + return ById.TryGetValue(id, out tracking); + } } - public static void Upsert(MakeShipTracking tracking) - { - lock (Lock) { ById[tracking.Id] = tracking; } + public static void Upsert(MakeShipTracking tracking) { + lock (Lock) { + ById[tracking.Id] = tracking; + } } - public static void Remove(Guid id) - { - lock (Lock) { ById.Remove(id); } + public static void Remove(Guid id) { + lock (Lock) { + ById.Remove(id); + } } - public static void RaiseTrackingUpdated(MakeShipTracking tracking) - => TrackingUpdated?.Invoke(tracking); + public static void RaiseTrackingUpdated(MakeShipTracking tracking) { + TrackingUpdated?.Invoke(tracking); + } - public static MakeShipProductType ClassifyUrl(string? url) - { + public static MakeShipProductType ClassifyUrl(string? url) { if (string.IsNullOrWhiteSpace(url)) return MakeShipProductType.Invalid; url = url.Trim(); if (url.StartsWith(PetitionUrlPrefix, StringComparison.OrdinalIgnoreCase) @@ -63,17 +63,15 @@ public static MakeShipProductType ClassifyUrl(string? url) return MakeShipProductType.Invalid; } - public static string GetSlug(string? url) - { + public static string GetSlug(string? url) { if (string.IsNullOrWhiteSpace(url)) return ""; - var trimmed = url.Trim().Split('?')[0].Split('#')[0].TrimEnd('/'); + string trimmed = url.Trim().Split('?')[0].Split('#')[0].TrimEnd('/'); int idx = trimmed.LastIndexOf('/'); return idx >= 0 && idx < trimmed.Length - 1 ? trimmed[(idx + 1)..] : ""; } - public static string GetDisplayNameFromSlug(string? url) - { - var slug = GetSlug(url); + public static string GetDisplayNameFromSlug(string? url) { + string slug = GetSlug(url); return string.IsNullOrEmpty(slug) ? "" : _textInfo.ToTitleCase(slug.Replace('-', ' ').Replace('_', ' ')); } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Models/GoAffProStore.cs b/SubathonManager.Core/Models/GoAffProStore.cs index 5c07b7ac..a6ca7f52 100644 --- a/SubathonManager.Core/Models/GoAffProStore.cs +++ b/SubathonManager.Core/Models/GoAffProStore.cs @@ -3,16 +3,16 @@ namespace SubathonManager.Core.Models; -public class GoAffProStore -{ +public class GoAffProStore { [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int RowId { get; set; } + public int SiteId { get; init; } public string StoreName { get; set; } = ""; public string EventName { get; set; } = ""; public bool Enabled { get; set; } = true; - public string InternalName => StoreName.Replace(" ", ""); - public string InternalEventName => EventName.Replace(" ", ""); + public string InternalName => StoreName.Replace(" ", ""); + public string InternalEventName => EventName.Replace(" ", ""); } \ No newline at end of file diff --git a/SubathonManager.Core/Models/JuniperStore.cs b/SubathonManager.Core/Models/JuniperStore.cs index af8ac216..e28f77b7 100644 --- a/SubathonManager.Core/Models/JuniperStore.cs +++ b/SubathonManager.Core/Models/JuniperStore.cs @@ -6,34 +6,29 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class JuniperStore -{ - [Key] - public Guid RowId { get; set; } +public class JuniperStore { + [Key] public Guid RowId { get; set; } + public required string StoreName { get; init; } public bool Enabled { get; set; } = true; - + public List Products { get; set; } = new(); - + public DateTime LastFetched { get; set; } = DateTime.UtcNow; - } + [ExcludeFromCodeCoverage] -public class JuniperProduct -{ - [Key] - public required BigInteger ProductId { get; init; } +public class JuniperProduct { + [Key] public required BigInteger ProductId { get; init; } public string ProductName { get; set; } = "Product"; - - [ForeignKey(nameof(Store))] - public required Guid StoreId { get; init; } + + [ForeignKey(nameof(Store))] public required Guid StoreId { get; init; } public required JuniperStore Store { get; init; } - + public DateTime LastFetched { get; set; } = DateTime.UtcNow; public bool Valid { get; set; } = true; - + // i assume we do not care about variants for purposes of subathons... - } \ No newline at end of file diff --git a/SubathonManager.Core/Models/MakeShipTracking.cs b/SubathonManager.Core/Models/MakeShipTracking.cs index 38a5f65e..583264bb 100644 --- a/SubathonManager.Core/Models/MakeShipTracking.cs +++ b/SubathonManager.Core/Models/MakeShipTracking.cs @@ -4,10 +4,9 @@ namespace SubathonManager.Core.Models; -public class MakeShipTracking -{ - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); +public class MakeShipTracking { + [Key] public Guid Id { get; set; } = Guid.NewGuid(); + public string Url { get; set; } = ""; public string Name { get; set; } = ""; public string ShopifyProductId { get; set; } = ""; @@ -16,6 +15,5 @@ public class MakeShipTracking public int Sales { get; set; } public int Orders { get; set; } - [NotMapped] - public bool PollFailing { get; set; } -} + [NotMapped] public bool PollFailing { get; set; } +} \ No newline at end of file diff --git a/SubathonManager.Core/Models/MultiplierData.cs b/SubathonManager.Core/Models/MultiplierData.cs index ee09bc05..7d884175 100644 --- a/SubathonManager.Core/Models/MultiplierData.cs +++ b/SubathonManager.Core/Models/MultiplierData.cs @@ -5,26 +5,24 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class MultiplierData -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); - +public class MultiplierData { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); + public double Multiplier { get; set; } = 1; - - public TimeSpan? Duration {get; set;} = null; + + public TimeSpan? Duration { get; set; } = null; public DateTime? Started { get; set; } = null; - - [ForeignKey("SubathonData")] - public Guid? SubathonId { get; set; } - public SubathonData? LinkedSubathon { get; set; } - + + [ForeignKey("SubathonData")] public Guid? SubathonId { get; set; } + + public SubathonData? LinkedSubathon { get; set; } + public bool ApplyToSeconds { get; set; } = true; public bool ApplyToPoints { get; set; } = true; - + public bool FromHypeTrain { get; set; } = false; - public bool IsRunning() - { + public bool IsRunning() { return (ApplyToSeconds || ApplyToPoints) && !Multiplier.Equals(1); } } \ No newline at end of file diff --git a/SubathonManager.Core/Models/Route.cs b/SubathonManager.Core/Models/Route.cs index 39180088..33795ca4 100644 --- a/SubathonManager.Core/Models/Route.cs +++ b/SubathonManager.Core/Models/Route.cs @@ -1,31 +1,26 @@ -using SubathonManager.Core.Interfaces; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Interfaces; namespace SubathonManager.Core.Models; -using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; [ExcludeFromCodeCoverage] -public class Route -{ - - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); +public class Route { + [Key] public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = ""; public List Widgets { get; set; } = new(); public int Width { get; set; } = 1920; public int Height { get; set; } = 1080; - + public DateTime CreatedTimestamp { get; set; } = DateTime.UtcNow; public DateTime UpdatedTimestamp { get; set; } = DateTime.UtcNow; - public string GetRouteUrl(IConfig config, bool editMode = false) - { + public string GetRouteUrl(IConfig config, bool editMode = false) { string qString = editMode ? "?edit=true" : ""; return $"http://localhost:{config.Get("Server", "Port", "14040")}/route/{Id}{qString}"; } - + /* public JsonElement ToJson() { @@ -50,12 +45,12 @@ public JsonElement ToJson() return JsonSerializer.SerializeToElement(obj); } - + public static Route? FromJson(JsonElement json) { var name = json.GetProperty("name").GetString() ?? string.Empty; if (string.IsNullOrWhiteSpace(name)) return null; - + var route = new Route { Name = name @@ -69,7 +64,7 @@ public JsonElement ToJson() { route.Widgets.Add(widget); } - + return route; } */ diff --git a/SubathonManager.Core/Models/StateValue.cs b/SubathonManager.Core/Models/StateValue.cs index 24caa1c6..ed7c0971 100644 --- a/SubathonManager.Core/Models/StateValue.cs +++ b/SubathonManager.Core/Models/StateValue.cs @@ -4,19 +4,19 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class StateValue -{ +public class StateValue { [Key] public string Name { get; set; } = ""; public string Value { get; set; } = ""; public string TypeName { get; set; } = "String"; } -public static class StateKeys -{ +public static class StateKeys { public const string WheelSpinsOwed = "WheelSpinsOwed"; - + public const string WidgetBrowserCollapsed = "WidgetBrowserCollapsed"; public const string WidgetBrowserSkipDeleteConfirm = "WidgetBrowserSkipDeleteConfirm"; public const string WidgetBrowserAllVersions = "WidgetBrowserAllVersions"; public const string EditorPreviewLightBg = "EditorPreviewLightBg"; -} + + public const string GoalAutoIncrement = "GoalAutoIncrement"; +} \ No newline at end of file diff --git a/SubathonManager.Core/Models/SubathonData.cs b/SubathonManager.Core/Models/SubathonData.cs index 1473c145..dcf5c72d 100644 --- a/SubathonManager.Core/Models/SubathonData.cs +++ b/SubathonManager.Core/Models/SubathonData.cs @@ -5,39 +5,36 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class SubathonData -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); +public class SubathonData { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = "New Subathon"; - + public long MillisecondsCumulative { get; set; } = 0; public long MillisecondsElapsed { get; set; } = 0; - public int Points { get; set; } = 0; - + public int Points { get; set; } = 0; + public bool IsPaused { get; set; } = true; public bool IsActive { get; set; } = true; - + // continue existing but do not accept new events public bool IsLocked { get; set; } = true; - + // proper power hour value is here public MultiplierData Multiplier { get; set; } = new(); - + public string? Currency { get; set; } = ""; // load from default if not set - public double? MoneySum { get; set; } = 0; - + public double? MoneySum { get; set; } = 0; + public bool? ReversedTime { get; set; } = false; - + public DateTime? CapDateTime { get; set; } = null; - public bool IsSubathonReversed() - { + public bool IsSubathonReversed() { return ReversedTime ?? false; } - public bool IsCapInEffect() - { + public bool IsCapInEffect() { if (CapDateTime == null) return false; long value = 0; if (IsSubathonReversed()) @@ -47,43 +44,38 @@ public bool IsCapInEffect() return CapDateTime.Value < DateTime.Now + TimeSpan.FromMilliseconds(value); } - public long MillisecondsRemaining() - { + public long MillisecondsRemaining() { long value = 0; if (IsSubathonReversed()) value = MillisecondsCumulative + MillisecondsElapsed; else value = MillisecondsCumulative - MillisecondsElapsed; - if (CapDateTime != null) - { + if (CapDateTime != null) { double timeUntilCap = 0; - var x = DateTime.Now; + DateTime x = DateTime.Now; if (CapDateTime.Value > x) timeUntilCap = (CapDateTime.Value - x).TotalMilliseconds; value = Math.Min(value, (long)timeUntilCap); } + return value; } - public TimeSpan TimeRemaining() - { + public TimeSpan TimeRemaining() { return TimeSpan.FromMilliseconds(MillisecondsRemaining()); } - public TimeSpan TimeRemainingRounded() - { + public TimeSpan TimeRemainingRounded() { double totalSeconds = TimeRemaining().TotalSeconds; return TimeSpan.FromSeconds(Math.Floor(totalSeconds)); } - public long GetRoundedMoneySum() - { + public long GetRoundedMoneySum() { return (long)Math.Floor(MoneySum ?? 0); } - - public double GetRoundedMoneySumWithCents() - { + + public double GetRoundedMoneySumWithCents() { return Math.Round(MoneySum ?? 0, 2); } } \ No newline at end of file diff --git a/SubathonManager.Core/Models/SubathonEvent.cs b/SubathonManager.Core/Models/SubathonEvent.cs index a1d72b73..03cd060e 100644 --- a/SubathonManager.Core/Models/SubathonEvent.cs +++ b/SubathonManager.Core/Models/SubathonEvent.cs @@ -1,22 +1,22 @@ -using SubathonManager.Core.Enums; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Enums; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class SubathonEvent -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); +public class SubathonEvent { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); // override with a guid from actual original source if possible, like message-id. // If not valid guid, hash the value into a valid guid // we can use this to ensure there aren't double read events - + // do we want a bool in here for "added to timer success y/n?" - [Key, Column(Order = 1)] public SubathonEventSource Source { get; set; } = SubathonEventSource.Unknown; + [Key] [Column(Order = 1)] public SubathonEventSource Source { get; set; } = SubathonEventSource.Unknown; public DateTime EventTimestamp { get; set; } = DateTime.Now; @@ -30,54 +30,62 @@ public class SubathonEvent public int? PointsValue { get; set; } = 0; public string? User { get; set; } = ""; public string Value { get; set; } = ""; - + public string SecondaryValue { get; set; } = ""; // often for commission and currency amounts public string TertiaryValue { get; set; } = ""; // often for item names public SubathonCommandType Command { get; set; } = SubathonCommandType.None; public int Amount { get; set; } = 1; // how many times to multiply everything for amount, only used for giftsubs - + public bool ProcessedToSubathon { get; set; } = false; - + public Guid? SubathonId { get; set; } - public SubathonData? LinkedSubathon { get; set; } - + public SubathonData? LinkedSubathon { get; set; } + [CurrencyValidation] public string? Currency { get; set; } = ""; // For determining if power hour (or negative power hour) is enabled, the multiplier. // when adding time to timer, take SecondsValue and always multiply by Multiplier public double MultiplierPoints { get; set; } = 1; public double MultiplierSeconds { get; set; } = 1; - + public bool WasReversed { get; set; } = false; - + public string? EventTypeMeta { get; set; } - + // do we want to later finetune power hour to be for selectable events? - private int GetAmountMultiplier() - { + private int GetAmountMultiplier() { return EventType.IsOrder() ? 1 : Amount; } - - public double GetFinalSecondsValue() => Math.Ceiling(GetFinalSecondsValueRaw()); - public double GetFinalSecondsValueRaw() => GetAmountMultiplier() * SecondsValue * (Source == SubathonEventSource.Command ? 1 : MultiplierSeconds) ?? 0; - public double GetFinalPointsValue() => Math.Floor(GetAmountMultiplier() * PointsValue * (Source == SubathonEventSource.Command ? 1 : Math.Round(MultiplierPoints+0.001)) ?? 0); - - public SubathonEvent ShallowClone() - { - return (SubathonEvent)this.MemberwiseClone(); + + public double GetFinalSecondsValue() { + return Math.Ceiling(GetFinalSecondsValueRaw()); + } + + public double GetFinalSecondsValueRaw() { + return GetAmountMultiplier() * SecondsValue * (Source == SubathonEventSource.Command ? 1 : MultiplierSeconds) ?? + 0; + } + + public double GetFinalPointsValue() { + return Math.Floor(GetAmountMultiplier() * PointsValue * + (Source == SubathonEventSource.Command ? 1 : Math.Round(MultiplierPoints + 0.001)) ?? 0); + } + + public SubathonEvent ShallowClone() { + return (SubathonEvent)MemberwiseClone(); } } [ExcludeFromCodeCoverage] -public class CurrencyValidationAttribute : ValidationAttribute -{ - protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) - { +public class CurrencyValidationAttribute : ValidationAttribute { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) { if (value == null) return ValidationResult.Success; - string s = value.ToString()!; - var allowed = new HashSet(StringComparer.OrdinalIgnoreCase) { "bits", "sub", "beets", "kudos", "", "???", - "order", "items", "member", "viewers" }; + var s = value.ToString()!; + var allowed = new HashSet(StringComparer.OrdinalIgnoreCase) { + "bits", "sub", "beets", "kudos", "", "???", + "order", "items", "member", "viewers" + }; if (allowed.Contains(s)) return ValidationResult.Success; diff --git a/SubathonManager.Core/Models/SubathonGoal.cs b/SubathonManager.Core/Models/SubathonGoal.cs index 1d981671..e81ee846 100644 --- a/SubathonManager.Core/Models/SubathonGoal.cs +++ b/SubathonManager.Core/Models/SubathonGoal.cs @@ -5,15 +5,13 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class SubathonGoal -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); - +public class SubathonGoal { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); + public string Text { get; set; } = "New Goal"; public long Points { get; set; } = 1; - - [ForeignKey("SubathonGoalSet")] - public Guid? GoalSetId { get; set; } - public SubathonGoalSet? LinkedGoalSet { get; set; } - + + [ForeignKey("SubathonGoalSet")] public Guid? GoalSetId { get; set; } + + public SubathonGoalSet? LinkedGoalSet { get; set; } } \ No newline at end of file diff --git a/SubathonManager.Core/Models/SubathonGoalSet.cs b/SubathonManager.Core/Models/SubathonGoalSet.cs index f059e06d..8081b655 100644 --- a/SubathonManager.Core/Models/SubathonGoalSet.cs +++ b/SubathonManager.Core/Models/SubathonGoalSet.cs @@ -6,12 +6,11 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class SubathonGoalSet -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); +public class SubathonGoalSet { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = "New Goal List"; public bool IsActive { get; set; } = true; - + public List Goals { get; set; } = new(); public GoalsType? Type { get; set; } = GoalsType.Points; diff --git a/SubathonManager.Core/Models/SubathonPrompt.cs b/SubathonManager.Core/Models/SubathonPrompt.cs index 9440909c..374c8502 100644 --- a/SubathonManager.Core/Models/SubathonPrompt.cs +++ b/SubathonManager.Core/Models/SubathonPrompt.cs @@ -6,10 +6,9 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class SubathonPrompt -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); - +public class SubathonPrompt { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); + public string Text { get; set; } = "New Prompt"; public long Value { get; set; } = 10; public TimeSpan CompletionDuration { get; set; } = TimeSpan.FromMinutes(5); @@ -23,47 +22,49 @@ public class SubathonPrompt public SubathonEventType? FilterEventType { get; set; } = null; public string? FilterMeta { get; set; } = null; public SubathonEventSubType? FilterSubType { get; set; } = null; - - [ForeignKey("SubathonPromptSet")] - public Guid? SetId { get; set; } - public SubathonPromptSet? LinkedSet { get; set; } - + + [ForeignKey("SubathonPromptSet")] public Guid? SetId { get; set; } + + public SubathonPromptSet? LinkedSet { get; set; } + public int Index { get; set; } = 0; - - public bool HasStock() => IsInfinite || Quantity > 0; - public bool IsPickable() => Enabled && HasStock(); + + public bool HasStock() { + return IsInfinite || Quantity > 0; + } + + public bool IsPickable() { + return Enabled && HasStock(); + } } [ExcludeFromCodeCoverage] -public class SubathonPromptRun -{ +public class SubathonPromptRun { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - - [ForeignKey(nameof(SubathonPrompt))] - public Guid PromptId { get; set; } + + [ForeignKey(nameof(SubathonPrompt))] public Guid PromptId { get; set; } + public SubathonPrompt? LinkedPrompt { get; set; } - + [ForeignKey(nameof(SubathonPromptSet))] public Guid SetId { get; set; } + public SubathonPromptSet? LinkedSet { get; set; } - + public DateTime StartedAt { get; set; } = DateTime.Now; public DateTime ExpiresAt { get; set; } public DateTime? EndedAt { get; set; } - + public SubathonPromptRunStatus Status { get; set; } = SubathonPromptRunStatus.Active; public long SnapshotTargetValue { get; set; } public long BaselineCount { get; set; } = 0; - + public bool IsActive => Status == SubathonPromptRunStatus.Active; public bool IsExpired => DateTime.Now >= ExpiresAt && Status == SubathonPromptRunStatus.Active; - - public TimeSpan TimeRemaining() - { + + public TimeSpan TimeRemaining() { if (Status != SubathonPromptRunStatus.Active) return TimeSpan.Zero; - var remaining = ExpiresAt - DateTime.Now; + TimeSpan remaining = ExpiresAt - DateTime.Now; return remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining; } - - } \ No newline at end of file diff --git a/SubathonManager.Core/Models/SubathonPromptSet.cs b/SubathonManager.Core/Models/SubathonPromptSet.cs index 84cb2f73..c3d06f96 100644 --- a/SubathonManager.Core/Models/SubathonPromptSet.cs +++ b/SubathonManager.Core/Models/SubathonPromptSet.cs @@ -5,26 +5,25 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class SubathonPromptSet -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); +public class SubathonPromptSet { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = "My Prompts"; public bool Enabled { get; set; } = true; public bool IsActive { get; set; } = true; - + public TimeSpan Interval { get; set; } = TimeSpan.FromMinutes(20); public TimeSpan RandomOffset { get; set; } = TimeSpan.Zero; public TimeSpan Cooldown { get; set; } = TimeSpan.FromMinutes(20); // time after one ends before a new one can start - + public List Prompts { get; set; } = []; - - - public void ClampRandomOffset() - { + + + public void ClampRandomOffset() { if (RandomOffset > Interval) RandomOffset = Interval; } - - public IEnumerable PickablePrompts() - => Prompts.Where(p => p.IsPickable()); + + public IEnumerable PickablePrompts() { + return Prompts.Where(p => p.IsPickable()); + } } \ No newline at end of file diff --git a/SubathonManager.Core/Models/SubathonValue.cs b/SubathonManager.Core/Models/SubathonValue.cs index fc59b8cb..a676407f 100644 --- a/SubathonManager.Core/Models/SubathonValue.cs +++ b/SubathonManager.Core/Models/SubathonValue.cs @@ -1,30 +1,27 @@ -using SubathonManager.Core.Enums; -using System.Text.Json.Serialization; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using SubathonManager.Core.Enums; namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class SubathonValue -{ - [Key, Column(Order = 0)] public SubathonEventType EventType { get; set; } - [Key, Column(Order = 1)] public string Meta { get; set; } = ""; - +public class SubathonValue { + [Key] [Column(Order = 0)] public SubathonEventType EventType { get; set; } + [Key] [Column(Order = 1)] public string Meta { get; set; } = ""; + // EventType + Meta for settings. Most will have empty Meta. // Meta will be for like TwitchSub and TwitchGiftSub, where it will be the Tier (from Value field). string 1000 2000 3000 - + // in future, may want to add a condition column to compare stuff with? i.e., raids of min viewer count - - public double Seconds { get; set; } = 0; - public double Points { get; set; } = 0; - - - public SubathonValueDto ToObject() - { - return new SubathonValueDto - { + + public double Seconds { get; set; } + public double Points { get; set; } + + + public SubathonValueDto ToObject() { + return new SubathonValueDto { EventType = EventType, Source = ((SubathonEventType?)EventType).GetSource(), Meta = Meta, @@ -35,9 +32,8 @@ public SubathonValueDto ToObject() }; } - public bool PatchByObject(SubathonValueDto dto) - { - bool modified = false; + public bool PatchByObject(SubathonValueDto dto) { + var modified = false; if (dto.EventType != EventType) return false; if (dto.Source != ((SubathonEventType?)EventType).GetSource()) @@ -46,16 +42,14 @@ public bool PatchByObject(SubathonValueDto dto) return false; if (dto.Seconds == null && dto.Points == null) return false; - - if (dto.Seconds is >= 0 && !Seconds.Equals(dto.Seconds)) - { - Seconds = (double) dto.Seconds; + + if (dto.Seconds is >= 0 && !Seconds.Equals(dto.Seconds)) { + Seconds = (double)dto.Seconds; modified = true; } - if (dto.Points is >= 0 && !Points.Equals(dto.Points)) - { - Points = (double) dto.Points; + if (dto.Points is >= 0 && !Points.Equals(dto.Points)) { + Points = (double)dto.Points; modified = true; } @@ -63,13 +57,13 @@ public bool PatchByObject(SubathonValueDto dto) } } -public class SubathonValueDto -{ +public class SubathonValueDto { [JsonConverter(typeof(JsonStringEnumConverter))] public SubathonEventType EventType { get; set; } - + [JsonConverter(typeof(JsonStringEnumConverter))] public SubathonEventSource Source { get; set; } + public string Meta { get; set; } = ""; public double? Seconds { get; set; } public double? Points { get; set; } @@ -77,15 +71,13 @@ public class SubathonValueDto public string? Key { get; set; } public string? Label { get; set; } - public override string ToString() - { + public override string ToString() { return $"{Source} {EventType} [{Meta}]: {Seconds}s, {Points}pts"; } - public string ToValueString() - { - var secondsStr = Seconds == null ? string.Empty : $"{Seconds}s"; - var pointsStr = Points == null ? string.Empty : $"{Points}pts"; + public string ToValueString() { + string secondsStr = Seconds == null ? string.Empty : $"{Seconds}s"; + string pointsStr = Points == null ? string.Empty : $"{Points}pts"; return $"{secondsStr} {pointsStr}".Trim(); } } \ No newline at end of file diff --git a/SubathonManager.Core/Models/WheelSpinTrigger.cs b/SubathonManager.Core/Models/WheelSpinTrigger.cs index 0be8b657..1db6a9f1 100644 --- a/SubathonManager.Core/Models/WheelSpinTrigger.cs +++ b/SubathonManager.Core/Models/WheelSpinTrigger.cs @@ -6,8 +6,7 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class WheelSpinTrigger -{ +public class WheelSpinTrigger { [Key] public Guid Id { get; set; } = Guid.NewGuid(); public bool IsEnabled { get; set; } = true; public int SpinsToAdd { get; set; } = 1; @@ -16,25 +15,26 @@ public class WheelSpinTrigger // For sub/membership events, matches ev.Value (meta tier) public string? TierValue { get; set; } + // by item or reg count like subs or tokens public int? CountThreshold { get; set; } + // by money only public double? MoneyThreshold { get; set; } public string? Currency { get; set; } public List History { get; set; } = []; - + // for future thought, consider a "every" progress mode, useful for say, // 100 followers, or non gift subs. But then attribution doesn't make sense } [ExcludeFromCodeCoverage] -public class WheelSpinTriggerHistory -{ +public class WheelSpinTriggerHistory { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [ForeignKey("WheelSpinTrigger")] - public Guid TriggerId { get; set; } + [ForeignKey("WheelSpinTrigger")] public Guid TriggerId { get; set; } + public WheelSpinTrigger? Trigger { get; set; } public DateTime TriggeredAt { get; set; } = DateTime.Now; @@ -45,4 +45,4 @@ public class WheelSpinTriggerHistory // Loose reference to the source event, survives event deletion public Guid? SubathonEventId { get; set; } public SubathonEventType? SubathonEventType { get; set; } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Models/Wheels.cs b/SubathonManager.Core/Models/Wheels.cs index 2fa1e29b..2b50e3ec 100644 --- a/SubathonManager.Core/Models/Wheels.cs +++ b/SubathonManager.Core/Models/Wheels.cs @@ -6,23 +6,22 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class WheelSet -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); +public class WheelSet { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = "My Wheel"; public int SpinCount { get; set; } = 0; public bool IsActive { get; set; } = true; public List WheelItems { get; set; } = []; - public IEnumerable SpinnableItems() - => WheelItems.Where(i => i.IsSpinnable()); + public IEnumerable SpinnableItems() { + return WheelItems.Where(i => i.IsSpinnable()); + } } [ExcludeFromCodeCoverage] -public class WheelItem -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); +public class WheelItem { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); public string Text { get; set; } = "New Item"; public int Weight { get; set; } = 1; @@ -31,44 +30,47 @@ public class WheelItem public bool Enabled { get; set; } = false; public int Index { get; set; } = 0; - [ForeignKey("WheelSet")] - public Guid? WheelId { get; set; } + [ForeignKey("WheelSet")] public Guid? WheelId { get; set; } + public WheelSet? LinkedWheel { get; set; } public WheelSpinAction? Action { get; set; } - public bool HasStock() => IsInfinite || Quantity > 0; - public bool IsSpinnable() => Enabled && HasStock(); + public bool HasStock() { + return IsInfinite || Quantity > 0; + } + + public bool IsSpinnable() { + return Enabled && HasStock(); + } } [ExcludeFromCodeCoverage] -public class WheelSpinAction -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); +public class WheelSpinAction { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); public WheelSpinActionType ActionType { get; set; } = WheelSpinActionType.Manual; public string Parameter { get; set; } = ""; - [ForeignKey("WheelItem")] - public Guid WheelItemId { get; set; } + [ForeignKey("WheelItem")] public Guid WheelItemId { get; set; } + public WheelItem? LinkedItem { get; set; } } [ExcludeFromCodeCoverage] -public class WheelSpinHistory -{ +public class WheelSpinHistory { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [ForeignKey("WheelSet")] - public Guid WheelId { get; set; } + [ForeignKey("WheelSet")] public Guid WheelId { get; set; } + public WheelSet? LinkedWheel { get; set; } - [ForeignKey("WheelItem")] - public Guid WheelItemId { get; set; } + [ForeignKey("WheelItem")] public Guid WheelItemId { get; set; } + public WheelItem? LinkedItem { get; set; } public DateTime CreatedAt { get; set; } = DateTime.Now; public DateTime UpdatedAt { get; set; } = DateTime.Now; public WheelSpinHistoryStatus Status { get; set; } = WheelSpinHistoryStatus.Pending; -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Models/Widget.cs b/SubathonManager.Core/Models/Widget.cs index 0ba46370..49bf7280 100644 --- a/SubathonManager.Core/Models/Widget.cs +++ b/SubathonManager.Core/Models/Widget.cs @@ -1,63 +1,56 @@ using System.ComponentModel; -using SubathonManager.Core.Enums; -using SubathonManager.Core.Interfaces; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class CssVariable : INotifyPropertyChanged -{ +public class CssVariable : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; - private void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string? name = null) - => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); - - [Key] - public int Id { get; set; } + + private void OnPropertyChanged([CallerMemberName] string? name = null) { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + } + + [Key] public int Id { get; set; } public string Name { get; set; } = string.Empty; - + // to assist the live change for editing private string _value = string.Empty; - public string Value - { + + public string Value { get => _value; - set - { + set { if (_value == value) return; _value = value; - if (string.IsNullOrWhiteSpace(_value)) - { + if (string.IsNullOrWhiteSpace(_value)) { if (Type is WidgetCssVariableType.Int or WidgetCssVariableType.Float) - { _value = "0"; - } - else if (Type is WidgetCssVariableType.Size) - { - _value = "0px"; - } + else if (Type is WidgetCssVariableType.Size) _value = "0px"; } + OnPropertyChanged(); } } - - - [ForeignKey("Widget")] - public Guid WidgetId { get; set; } + + + [ForeignKey("Widget")] public Guid WidgetId { get; set; } public Widget Widget { get; set; } = null!; - - public WidgetCssVariableType Type { get; set; } = WidgetCssVariableType.Default; + + public WidgetCssVariableType Type { get; set; } = WidgetCssVariableType.Default; public string Description { get; set; } = string.Empty; - - public CssVariable Clone(Guid newWidgetId) - { - return new CssVariable - { + + public CssVariable Clone(Guid newWidgetId) { + return new CssVariable { Name = Name, Value = Value, WidgetId = newWidgetId, @@ -65,24 +58,20 @@ public CssVariable Clone(Guid newWidgetId) Description = Description }; } - - public static CssVariable? FromJson(JsonElement json, Guid widgetId) - { - var name = json.GetProperty("name").GetString() ?? string.Empty; - var value = json.GetProperty("value").GetString() ?? string.Empty; + + public static CssVariable? FromJson(JsonElement json, Guid widgetId) { + string name = json.GetProperty("name").GetString() ?? string.Empty; + string value = json.GetProperty("value").GetString() ?? string.Empty; if (string.IsNullOrEmpty(name)) return null; - return new CssVariable - { + return new CssVariable { Name = name, Value = value, WidgetId = widgetId }; } - - public JsonElement ToJson() - { - var obj = new - { + + public JsonElement ToJson() { + var obj = new { name = Name, value = Value }; @@ -92,61 +81,57 @@ public JsonElement ToJson() } [ExcludeFromCodeCoverage] -public class JsVariable -{ - [Key] - public int Id { get; set; } +public class JsVariable { + [Key] public int Id { get; set; } public string Name { get; set; } = string.Empty; public string Value { get; set; } = string.Empty; - public WidgetVariableType Type { get; set; } = WidgetVariableType.String; - - [ForeignKey("Widget")] - public Guid WidgetId { get; set; } + public WidgetVariableType Type { get; set; } = WidgetVariableType.String; + + [ForeignKey("Widget")] public Guid WidgetId { get; set; } public Widget Widget { get; set; } = null!; - + public string Description { get; set; } = string.Empty; - public string GetInjectLine() - { + public string GetInjectLine() { StringBuilder sb = new(); - if (Type.IsFontVariable()) - { - var fnName = Type switch - { + if (Type.IsFontVariable()) { + string fnName = Type switch { WidgetVariableType.GoogleFont => "loadGoogleFont", WidgetVariableType.CdnFont => "loadCdnFont", WidgetVariableType.BunnyFont => "loadBunnyFont", _ => "" }; if (string.IsNullOrWhiteSpace(Value) || string.IsNullOrWhiteSpace(fnName)) return "\n"; - foreach (var font in Value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { + foreach (string font in Value.Split(',', + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) sb.Append($"{fnName}(\"{font.Trim()}\");\n"); - } return sb.ToString(); } + sb.Append($"const {Name.Replace(' ', '_')} = "); - if (string.IsNullOrEmpty(Value) || string.IsNullOrWhiteSpace(Value)) + if (string.IsNullOrEmpty(Value) || string.IsNullOrWhiteSpace(Value)) { sb.Append("\"\""); - else if (Type == WidgetVariableType.Boolean && bool.TryParse(Value, out var b)) + } + else if (Type == WidgetVariableType.Boolean && bool.TryParse(Value, out bool b)) { sb.Append($"{b.ToString().ToLower()}"); - else if (Type == WidgetVariableType.Float && float.TryParse(Value, out var f)) + } + else if (Type == WidgetVariableType.Float && float.TryParse(Value, out float f)) { sb.Append($"{f}"); - else if (Type == WidgetVariableType.Int && Int32.TryParse(Value, out var intValue)) + } + else if (Type == WidgetVariableType.Int && int.TryParse(Value, out int intValue)) { sb.Append($"{intValue}"); - else if (Type == WidgetVariableType.Percent && Int16.TryParse(Value, out var pctValue)) + } + else if (Type == WidgetVariableType.Percent && short.TryParse(Value, out short pctValue)) { sb.Append($"{Math.Clamp((int)pctValue, 0, 100)}"); - else if (Type == WidgetVariableType.StringSelect) - { + } + else if (Type == WidgetVariableType.StringSelect) { string val = Value.Split(',')[0].Trim(); sb.Append($"\"{val}\""); } else if (Type is WidgetVariableType.EventTypeList or WidgetVariableType.StringList or - WidgetVariableType.EventSubTypeList) - { - string val = string.Join(",", Value.Split(',').Select(s => - { + WidgetVariableType.EventSubTypeList) { + string val = string.Join(",", Value.Split(',').Select(s => { s = s.Trim(); if (!s.StartsWith('"')) s = '"' + s; if (!s.EndsWith('"')) s += '"'; @@ -154,32 +139,26 @@ WidgetVariableType.StringList or })); sb.Append($"[{val}]"); } - else if (((WidgetVariableType?)Type).IsFileVariable()) - { + else if (((WidgetVariableType?)Type).IsFileVariable()) { if (ResourcePaths.IsResourceUrl(Value)) - { sb.Append($"\"{Value}\""); - } else if (!Value.StartsWith("./") && !string.IsNullOrWhiteSpace(Value)) - { sb.Append($"\"externalPath/{Value}\""); - } else - { sb.Append($"\"{Value}\""); - } } else // default as always string. Incl TypeSelects + { sb.Append($"\"{Value}\""); + } + sb.Append(";\n"); return sb.ToString(); } - public JsVariable Clone(Guid newWidgetId) - { - return new JsVariable - { + public JsVariable Clone(Guid newWidgetId) { + return new JsVariable { Name = Name, Value = Value, Type = Type, @@ -187,35 +166,25 @@ public JsVariable Clone(Guid newWidgetId) Description = Description }; } - - public static JsVariable? FromJson(JsonElement json, Guid widgetId) - { - var name = json.GetProperty("name").GetString() ?? string.Empty; + + public static JsVariable? FromJson(JsonElement json, Guid widgetId) { + string name = json.GetProperty("name").GetString() ?? string.Empty; if (string.IsNullOrEmpty(name)) return null; - - var typeEl = json.GetProperty("type"); + + JsonElement typeEl = json.GetProperty("type"); WidgetVariableType type; - + var description = string.Empty; - json.TryGetProperty("description", out var descriptEl); + json.TryGetProperty("description", out JsonElement descriptEl); + + if (descriptEl.ValueKind == JsonValueKind.String) description = descriptEl.GetString(); - if (descriptEl.ValueKind == JsonValueKind.String) - { - description = descriptEl.GetString(); - } - if (typeEl.ValueKind == JsonValueKind.Number - && typeEl.TryGetInt32(out var typeInt) + && typeEl.TryGetInt32(out int typeInt) && Enum.IsDefined(typeof(WidgetVariableType), typeInt)) - { type = (WidgetVariableType)typeInt; - } - else if (!Enum.TryParse(typeEl.GetString() ?? string.Empty, out type)) - { - return null; - } - return new JsVariable - { + else if (!Enum.TryParse(typeEl.GetString() ?? string.Empty, out type)) return null; + return new JsVariable { Name = name, Value = json.GetProperty("value").GetString() ?? string.Empty, Type = type, @@ -223,11 +192,9 @@ public JsVariable Clone(Guid newWidgetId) Description = description ?? string.Empty }; } - - public JsonElement ToJson() - { - var obj = new - { + + public JsonElement ToJson() { + var obj = new { name = Name, value = Value, type = Type, @@ -239,28 +206,25 @@ public JsonElement ToJson() } [ExcludeFromCodeCoverage] -public partial class Widget -{ - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); +public partial class Widget { + [Key] public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } public string HtmlPath { get; set; } - - public float X { get; set; } = 0; - public float Y { get; set; } = 0; - public int Z { get; set; } = 0; + + public float X { get; set; } + public float Y { get; set; } + public int Z { get; set; } public int Width { get; set; } = 400; public int Height { get; set; } = 400; - + public float ScaleX { get; set; } = 1; public float ScaleY { get; set; } = 1; public List CssVariables { get; set; } = new(); public List JsVariables { get; set; } = new(); - - [ForeignKey("Route")] - public Guid RouteId { get; set; } + + [ForeignKey("Route")] public Guid RouteId { get; set; } public Route Route { get; set; } = null!; public bool Visibility { get; set; } = true; @@ -269,15 +233,13 @@ public partial class Widget public WidgetType Type { get; set; } = WidgetType.Html; - public Widget(string name, string htmlPath) - { + public Widget(string name, string htmlPath) { Name = name; HtmlPath = htmlPath; } - public Widget Clone(Guid? routeId, string? newName, int? newZ) - { - Widget widget = new Widget(string.IsNullOrEmpty(newName) ? Name : newName, HtmlPath); + public Widget Clone(Guid? routeId, string? newName, int? newZ) { + var widget = new Widget(string.IsNullOrEmpty(newName) ? Name : newName, HtmlPath); widget.RouteId = routeId ?? RouteId; widget.Visibility = Visibility; @@ -290,33 +252,32 @@ public Widget Clone(Guid? routeId, string? newName, int? newZ) widget.ScaleY = ScaleY; widget.Type = Type; - foreach (var jsVariable in JsVariables) + foreach (JsVariable jsVariable in JsVariables) widget.JsVariables.Add(jsVariable.Clone(widget.Id)); - foreach (var cssVariable in CssVariables) + foreach (CssVariable cssVariable in CssVariables) widget.CssVariables.Add(cssVariable.Clone(widget.Id)); return widget; } - - public string GetPath() => Path.GetDirectoryName(HtmlPath) ?? HtmlPath; - public void ScanCssVariables() - { + public string GetPath() { + return Path.GetDirectoryName(HtmlPath) ?? HtmlPath; + } + + public void ScanCssVariables() { CssVariables = ExtractCssVariablesFromFiles(); } - - public List GetLinkedCssPaths() - { + + public List GetLinkedCssPaths() { var paths = new List(); - var html = WidgetFiles.Current.ReadAllText(HtmlPath); + string? html = WidgetFiles.Current.ReadAllText(HtmlPath); if (html == null) return paths; string baseDir = Path.GetDirectoryName(HtmlPath)!; - foreach (Match match in CssLinkMatches(html)) - { + foreach (Match match in CssLinkMatches(html)) { string cssFile = match.Groups[1].Value; string cssPath = Path.IsPathRooted(cssFile) ? cssFile @@ -333,63 +294,57 @@ public List GetLinkedCssPaths() return paths; } - public static MatchCollection CssLinkMatches(string htmlContent) => CssLinkRegex().Matches(htmlContent); + public static MatchCollection CssLinkMatches(string htmlContent) { + return CssLinkRegex().Matches(htmlContent); + } - public List ExtractCssVariablesFromFiles() - { + public List ExtractCssVariablesFromFiles() { var extractedVars = new List(); - foreach (var cssPath in GetLinkedCssPaths()) - { + foreach (string cssPath in GetLinkedCssPaths()) { string? cssContent = WidgetFiles.Current.ReadAllText(cssPath); if (cssContent == null) continue; - var varMatches = CssVarRegex().Matches(cssContent); + MatchCollection varMatches = CssVarRegex().Matches(cssContent); string metaPath = cssPath + ".json"; Dictionary>? varTypes = null; - var metaJson = WidgetFiles.Current.ReadAllText(metaPath); + string? metaJson = WidgetFiles.Current.ReadAllText(metaPath); if (metaJson != null) - { varTypes = JsonSerializer.Deserialize>>(metaJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = false }); - } - foreach (Match varMatch in varMatches) - { + foreach (Match varMatch in varMatches) { string name = varMatch.Groups[1].Value.Trim(); string value = varMatch.Groups[2].Value.Trim(); - string? description = string.Empty; + var description = string.Empty; if (extractedVars.Any(v => v.Name == name)) continue; - WidgetCssVariableType cssType = WidgetCssVariableType.Default; + var cssType = WidgetCssVariableType.Default; Dictionary? metaValue = null; if (varTypes is { Count: > 0 }) varTypes.TryGetValue(name, out metaValue); - - if (metaValue != null) - { - metaValue.TryGetValue("type", out var typeName); - Enum.TryParse(typeName, ignoreCase: true, out cssType); + + if (metaValue != null) { + metaValue.TryGetValue("type", out string? typeName); + Enum.TryParse(typeName, true, out cssType); metaValue.TryGetValue("description", out description); } - - extractedVars.Add(new CssVariable - { + + extractedVars.Add(new CssVariable { Name = name, Value = value, WidgetId = Id, Type = cssType, Description = string.IsNullOrWhiteSpace(description) ? string.Empty : description }); - } } + return extractedVars; } - public override string ToString() - { + public override string ToString() { return $"Widget {Name} (Instance {Id})"; } @@ -399,29 +354,24 @@ public override string ToString() [GeneratedRegex(@"--([a-zA-Z0-9-_]+)\s*:\s*([^;]+);")] private static partial Regex CssVarRegex(); - public JsonElement ToJson(string htmlRelPath) - { - var obj = new - { + public JsonElement ToJson(string htmlRelPath) { + var obj = new { name = Name, htmlPath = htmlRelPath, type = Type.ToString(), - position = new - { + position = new { x = X, y = Y, z = Z }, - size = new - { + size = new { width = Width, height = Height }, - scale = new - { + scale = new { x = ScaleX, y = ScaleY }, @@ -435,14 +385,14 @@ public JsonElement ToJson(string htmlRelPath) return JsonSerializer.SerializeToElement(obj); } - + /* public static Widget? FromJson(JsonElement json, string rootPath, Guid routeId) { var htmlPath = Path.Join(rootPath, json.GetProperty("htmlPath").GetString()); var name = json.GetProperty("name").GetString() ?? string.Empty; if (string.IsNullOrEmpty(name)) return null; - + var widget = new Widget(name, htmlPath) { RouteId = routeId, @@ -463,13 +413,13 @@ public JsonElement ToJson(string htmlRelPath) widget.ScaleX = scale.GetProperty("x").GetSingle(); widget.ScaleY = scale.GetProperty("y").GetSingle(); - foreach (var cssVar in json.GetProperty("cssVariables").EnumerateArray().Select(css => + foreach (var cssVar in json.GetProperty("cssVariables").EnumerateArray().Select(css => CssVariable.FromJson(css, widget.Id)).OfType()) { widget.CssVariables.Add(cssVar); } - foreach (var jsVar in json.GetProperty("jsVariables").EnumerateArray().Select(js => + foreach (var jsVar in json.GetProperty("jsVariables").EnumerateArray().Select(js => JsVariable.FromJson(js, widget.Id)).OfType()) { widget.JsVariables.Add(jsVar); @@ -478,4 +428,4 @@ public JsonElement ToJson(string htmlRelPath) return widget; } */ -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Models/WidgetCatalogEntry.cs b/SubathonManager.Core/Models/WidgetCatalogEntry.cs index 73280e61..5c5c66ce 100644 --- a/SubathonManager.Core/Models/WidgetCatalogEntry.cs +++ b/SubathonManager.Core/Models/WidgetCatalogEntry.cs @@ -5,10 +5,8 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class WidgetCatalogEntry -{ - [Key] - public int Id { get; set; } +public class WidgetCatalogEntry { + [Key] public int Id { get; set; } public string PackPath { get; set; } = string.Empty; public long FileSize { get; set; } @@ -31,4 +29,4 @@ public class WidgetCatalogEntry public float ScaleX { get; set; } = 1; public float ScaleY { get; set; } = 1; -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Objects/IntegrationConnection.cs b/SubathonManager.Core/Objects/IntegrationConnection.cs index 10a0fdfa..b8a292fd 100644 --- a/SubathonManager.Core/Objects/IntegrationConnection.cs +++ b/SubathonManager.Core/Objects/IntegrationConnection.cs @@ -2,8 +2,7 @@ namespace SubathonManager.Core.Objects; -public class IntegrationConnection -{ +public class IntegrationConnection { public SubathonEventSource Source { get; init; } public string Service { get; init; } = ""; public string Name { get; init; } = ""; @@ -11,5 +10,7 @@ public class IntegrationConnection public bool Status { get; init; } public bool Configured { get; init; } = true; - public override string ToString() => $"[{Source}:{Service}] [{Name}] Connected: {Status}"; + public override string ToString() { + return $"[{Source}:{Service}] [{Name}] Connected: {Status}"; + } } \ No newline at end of file diff --git a/SubathonManager.Core/Objects/OAuthCallback.cs b/SubathonManager.Core/Objects/OAuthCallback.cs index 5a2348ad..804acb12 100644 --- a/SubathonManager.Core/Objects/OAuthCallback.cs +++ b/SubathonManager.Core/Objects/OAuthCallback.cs @@ -3,8 +3,7 @@ namespace SubathonManager.Core.Objects; [ExcludeFromCodeCoverage] -public class OAuthCallback -{ +public class OAuthCallback { public string Provider { get; set; } = string.Empty; public string? AccessToken { get; set; } public string? RefreshToken { get; set; } diff --git a/SubathonManager.Core/Objects/SubathonTotals.cs b/SubathonManager.Core/Objects/SubathonTotals.cs index 279b5cc3..035bca1b 100644 --- a/SubathonManager.Core/Objects/SubathonTotals.cs +++ b/SubathonManager.Core/Objects/SubathonTotals.cs @@ -2,8 +2,7 @@ namespace SubathonManager.Core.Objects; -public class SubathonTotals -{ +public class SubathonTotals { public double MoneySum { get; init; } = 0; public string? Currency { get; init; } = "USD"; @@ -23,8 +22,7 @@ public class SubathonTotals public SubathonSimulatedTotals Simulated { get; init; } = new(); } -public class SubathonSimulatedTotals -{ +public class SubathonSimulatedTotals { public int SubLikeTotal { get; init; } = 0; public Dictionary SubLikeByEvent { get; init; } = new(); diff --git a/SubathonManager.Core/Objects/SubscriptionTotals.cs b/SubathonManager.Core/Objects/SubscriptionTotals.cs index 2d21ae14..e582f4f9 100644 --- a/SubathonManager.Core/Objects/SubscriptionTotals.cs +++ b/SubathonManager.Core/Objects/SubscriptionTotals.cs @@ -2,8 +2,7 @@ namespace SubathonManager.Core.Objects; -public class SubscriptionTotals -{ +public class SubscriptionTotals { public int SubTotal { get; init; } = 0; public Dictionary SubTotalByEvent { get; init; } = new(); @@ -12,9 +11,8 @@ public class SubscriptionTotals public SubscriptionSimulatedTotals Simulated { get; init; } = new(); } -public class SubscriptionSimulatedTotals -{ +public class SubscriptionSimulatedTotals { public int SubTotal { get; init; } = 0; public Dictionary SubTotalByEvent { get; init; } = new(); public Dictionary> SubTotalByEventTier { get; init; } = new(); -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Objects/VTSWheelAction.cs b/SubathonManager.Core/Objects/VTSWheelAction.cs new file mode 100644 index 00000000..57449b1b --- /dev/null +++ b/SubathonManager.Core/Objects/VTSWheelAction.cs @@ -0,0 +1,178 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using SubathonManager.Core.Enums; + +namespace SubathonManager.Core.Objects; + +[ExcludeFromCodeCoverage] +public sealed class VTSWheelAction { + public const string ParameterSeparator = "|"; + + public VtsTargetKind Kind { get; set; } = VtsTargetKind.Expression; + public string Target { get; set; } = ""; + public VtsToggleAction ToggleAction { get; set; } = VtsToggleAction.On; + public double Value { get; set; } + public TimeSpan Duration { get; set; } = TimeSpan.Zero; + + public VtsToggleAction AfterToggle { get; set; } = VtsToggleAction.DoNothing; + public VtsParameterAfterAction AfterParameter { get; set; } = VtsParameterAfterAction.DoNothing; + public VtsHotkeyAfterAction AfterHotkey { get; set; } = VtsHotkeyAfterAction.DoNothing; + + public double AfterValue { get; set; } + + public bool HasRevert => Duration > TimeSpan.Zero && Kind switch { + VtsTargetKind.Expression => AfterToggle != VtsToggleAction.DoNothing, + VtsTargetKind.Parameter => true, + VtsTargetKind.Hotkey => AfterHotkey != VtsHotkeyAfterAction.DoNothing, + _ => false + }; + + public string TimerKey => $"vts-wheel-{Kind}-{Target}".ToLowerInvariant(); + + public static bool TryParse(string? parameter, [NotNullWhen(true)] out VTSWheelAction? action) { + action = null; + if (string.IsNullOrWhiteSpace(parameter)) return false; + + string[] parts = parameter.Split(ParameterSeparator); + if (parts.Length < 5) return false; + if (!Enum.TryParse(parts[0], true, out VtsTargetKind kind)) return false; + + var parsed = new VTSWheelAction { + Kind = kind, + Target = parts[1].Trim() + }; + + switch (kind) { + case VtsTargetKind.Expression: + if (!Enum.TryParse(parts[2], true, out VtsToggleAction toggle)) return false; + parsed.ToggleAction = toggle; + break; + case VtsTargetKind.Parameter: + if (!TryParseNumber(parts[2], out double value)) return false; + parsed.Value = value; + break; + case VtsTargetKind.Hotkey: + break; + default: + return false; + } + + parsed.Duration = int.TryParse(parts[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out int seconds) + && seconds > 0 + ? TimeSpan.FromSeconds(seconds) + : TimeSpan.Zero; + + switch (kind) { + case VtsTargetKind.Expression: + parsed.AfterToggle = Enum.TryParse(parts[4], true, out VtsToggleAction afterToggle) + ? afterToggle + : VtsToggleAction.DoNothing; + break; + case VtsTargetKind.Parameter: + parsed.AfterParameter = Enum.TryParse(parts[4], true, out VtsParameterAfterAction afterParam) + ? afterParam + : VtsParameterAfterAction.DoNothing; + break; + case VtsTargetKind.Hotkey: + parsed.AfterHotkey = Enum.TryParse(parts[4], true, out VtsHotkeyAfterAction afterHotkey) + ? afterHotkey + : VtsHotkeyAfterAction.DoNothing; + break; + } + + if (parts.Length >= 6 && TryParseNumber(parts[5], out double afterValue)) + parsed.AfterValue = afterValue; + + action = parsed; + return true; + } + + public string ToParameterString() { + string initial = Kind switch { + VtsTargetKind.Expression => ToggleAction.ToString(), + VtsTargetKind.Parameter => Value.ToString(CultureInfo.InvariantCulture), + _ => "Trigger" + }; + + string after = Kind switch { + VtsTargetKind.Expression => AfterToggle.ToString(), + VtsTargetKind.Parameter => AfterParameter.ToString(), + _ => AfterHotkey.ToString() + }; + + string afterValue = Kind == VtsTargetKind.Parameter && AfterParameter == VtsParameterAfterAction.SetNewValue + ? AfterValue.ToString(CultureInfo.InvariantCulture) + : ""; + + return string.Join(ParameterSeparator, + Kind.ToString(), + Target.Trim(), + initial, + ((int)Duration.TotalSeconds).ToString(CultureInfo.InvariantCulture), + after, + afterValue); + } + + public bool IsValid(out string error) { + if (string.IsNullOrWhiteSpace(Target)) { + error = Kind switch { + VtsTargetKind.Expression => "Pick an expression file", + VtsTargetKind.Parameter => "Pick a parameter name", + _ => "Pick a hotkey" + }; + return false; + } + + switch (Kind) { + // if not do nothing but no duration is set + case VtsTargetKind.Expression when ToggleAction == VtsToggleAction.DoNothing: + error = "The rolled action cannot be 'Do Nothing' - it would never do anything"; + return false; + case VtsTargetKind.Parameter when AfterParameter == VtsParameterAfterAction.SetNewValue + && Duration <= TimeSpan.Zero: + error = "A duration is required to set a value after the timer"; + return false; + case VtsTargetKind.Expression when AfterToggle != VtsToggleAction.DoNothing + && Duration <= TimeSpan.Zero: + error = "A duration is required for the action after the timer"; + return false; + case VtsTargetKind.Hotkey when AfterHotkey != VtsHotkeyAfterAction.DoNothing + && Duration <= TimeSpan.Zero: + error = "A duration is required to trigger the hotkey again"; + return false; + default: + error = ""; + return true; + } + } + + public string Describe() { + string initial = Kind switch { + VtsTargetKind.Expression => ToggleAction.ToString(), + VtsTargetKind.Parameter => $"= {Value.ToString(CultureInfo.InvariantCulture)}", + _ => "trigger" + }; + + if (!HasRevert) return $"{Target} {initial}"; + + string after = Kind switch { + VtsTargetKind.Expression => AfterToggle.ToString(), + VtsTargetKind.Parameter => AfterParameter == VtsParameterAfterAction.SetNewValue + ? $"= {AfterValue.ToString(CultureInfo.InvariantCulture)}" + : AfterParameter.ToString(), + _ => AfterHotkey.ToString() + }; + + return $"{Target} {initial} for {FormatDuration(Duration)} then {after}"; + } + + private static string FormatDuration(TimeSpan duration) { + if (duration.TotalHours >= 1) return $"{(int)duration.TotalHours}h{duration.Minutes:00}m"; + if (duration.TotalMinutes >= 1) return $"{(int)duration.TotalMinutes}m{duration.Seconds:00}s"; + return $"{(int)duration.TotalSeconds}s"; + } + + private static bool TryParseNumber(string text, out double value) { + return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value); + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Objects/WidgetMeta.cs b/SubathonManager.Core/Objects/WidgetMeta.cs index 7ad96841..b0f6e00e 100644 --- a/SubathonManager.Core/Objects/WidgetMeta.cs +++ b/SubathonManager.Core/Objects/WidgetMeta.cs @@ -5,8 +5,7 @@ namespace SubathonManager.Core.Objects; -public class WidgetMeta -{ +public class WidgetMeta { public string Author { get; set; } = string.Empty; public string Url { get; set; } = string.Empty; public int Width { get; set; } = 300; @@ -15,20 +14,17 @@ public class WidgetMeta } [ExcludeFromCodeCoverage] -public class WidgetMetaVar -{ - [JsonIgnore] - public string Name { get; set; } = string.Empty; +public class WidgetMetaVar { + [JsonIgnore] public string Name { get; set; } = string.Empty; + public WidgetVariableType Type { get; set; } = WidgetVariableType.String; public string Description { get; set; } = string.Empty; - + public object Value { get; set; } = string.Empty; public List? Options { get; set; } - public string ValueToString() - { - var value = Value switch - { + public string ValueToString() { + string value = Value switch { JsonElement { ValueKind: JsonValueKind.String } el => el.GetString() ?? string.Empty, JsonElement { ValueKind: JsonValueKind.Number } el => el.GetRawText(), JsonElement { ValueKind: JsonValueKind.True } => "true", diff --git a/SubathonManager.Core/OrderEventHelper.cs b/SubathonManager.Core/OrderEventHelper.cs index 56684ba0..f365502b 100644 --- a/SubathonManager.Core/OrderEventHelper.cs +++ b/SubathonManager.Core/OrderEventHelper.cs @@ -6,8 +6,7 @@ namespace SubathonManager.Core; -public static class JuniperStoreRegistry -{ +public static class JuniperStoreRegistry { private static readonly Dictionary ByProductId = new(); private static readonly Dictionary ByStoreId = new(); private static readonly object Lock = new(); @@ -16,137 +15,131 @@ public static class JuniperStoreRegistry public static event Action? StoreUpdated; public static event Action? ProductUpdated; - public static void Initialize(IEnumerable stores) - { - lock (Lock) - { + public static void Initialize(IEnumerable stores) { + lock (Lock) { ByProductId.Clear(); ByStoreId.Clear(); - foreach (var store in stores) + foreach (JuniperStore store in stores) RegisterCore(store); } } - public static void RegisterStore(JuniperStore store) - { - lock (Lock) { RegisterCore(store); } + public static void RegisterStore(JuniperStore store) { + lock (Lock) { + RegisterCore(store); + } } - private static void RegisterCore(JuniperStore store) - { + private static void RegisterCore(JuniperStore store) { ByStoreId[store.RowId] = store; - foreach (var product in store.Products) + foreach (JuniperProduct product in store.Products) ByProductId[product.ProductId] = product; } - public static void RegisterProduct(JuniperProduct product) - { - lock (Lock) - { + public static void RegisterProduct(JuniperProduct product) { + lock (Lock) { ByProductId[product.ProductId] = product; - if (ByStoreId.TryGetValue(product.StoreId, out var store) && + if (ByStoreId.TryGetValue(product.StoreId, out JuniperStore? store) && store.Products.All(p => p.ProductId != product.ProductId)) store.Products.Add(product); } } - - public static bool TryParseProductId(string? value, out BigInteger productId) - { + + public static bool TryParseProductId(string? value, out BigInteger productId) { productId = default; return !string.IsNullOrWhiteSpace(value) && BigInteger.TryParse(value.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out productId) && productId > 0; } - public static bool TryGetProduct(BigInteger productId, [NotNullWhen(true)] out JuniperProduct? product) - { - lock (Lock) { return ByProductId.TryGetValue(productId, out product); } + public static bool TryGetProduct(BigInteger productId, [NotNullWhen(true)] out JuniperProduct? product) { + lock (Lock) { + return ByProductId.TryGetValue(productId, out product); + } } - public static bool TryGetProduct(string? productId, [NotNullWhen(true)] out JuniperProduct? product) - { + public static bool TryGetProduct(string? productId, [NotNullWhen(true)] out JuniperProduct? product) { product = null; - return TryParseProductId(productId, out var id) && TryGetProduct(id, out product); + return TryParseProductId(productId, out BigInteger id) && TryGetProduct(id, out product); } - - public static List AllValidProductIds() - { - lock (Lock) - { + + public static List AllValidProductIds() { + lock (Lock) { return ByProductId.Values.Where(p => p.Valid).Select(p => p.ProductId).ToList(); } } - public static bool TryGetStore(Guid rowId, [NotNullWhen(true)] out JuniperStore? store) - { - lock (Lock) { return ByStoreId.TryGetValue(rowId, out store); } + public static bool TryGetStore(Guid rowId, [NotNullWhen(true)] out JuniperStore? store) { + lock (Lock) { + return ByStoreId.TryGetValue(rowId, out store); + } } - - public static string GetStoreName(Guid rowId) - { - return TryGetStore(rowId, out var store) ? store.StoreName : string.Empty; + + public static string GetStoreName(Guid rowId) { + return TryGetStore(rowId, out JuniperStore? store) ? store.StoreName : string.Empty; } - public static IReadOnlyList AllStores() - { - lock (Lock) { return ByStoreId.Values.OrderBy(s => s.StoreName, StringComparer.OrdinalIgnoreCase).ToList(); } + public static IReadOnlyList AllStores() { + lock (Lock) { + return ByStoreId.Values.OrderBy(s => s.StoreName, StringComparer.OrdinalIgnoreCase).ToList(); + } } - public static IReadOnlyList AllProducts() - { - lock (Lock) { return ByProductId.Values.OrderBy(p => p.ProductName, StringComparer.OrdinalIgnoreCase).ToList(); } + public static IReadOnlyList AllProducts() { + lock (Lock) { + return ByProductId.Values.OrderBy(p => p.ProductName, StringComparer.OrdinalIgnoreCase).ToList(); + } } - public static void RemoveProduct(BigInteger productId) - { - lock (Lock) - { - if (!ByProductId.Remove(productId, out var product)) return; - if (ByStoreId.TryGetValue(product.StoreId, out var store)) + public static void RemoveProduct(BigInteger productId) { + lock (Lock) { + if (!ByProductId.Remove(productId, out JuniperProduct? product)) return; + if (ByStoreId.TryGetValue(product.StoreId, out JuniperStore? store)) store.Products.RemoveAll(p => p.ProductId == productId); } } - public static void RemoveStore(Guid rowId) - { - lock (Lock) - { - if (!ByStoreId.Remove(rowId, out var store)) return; - foreach (var product in store.Products) + public static void RemoveStore(Guid rowId) { + lock (Lock) { + if (!ByStoreId.Remove(rowId, out JuniperStore? store)) return; + foreach (JuniperProduct product in store.Products) ByProductId.Remove(product.ProductId); } } - public static JuniperStore GetOrProvisionStore(string storeName) - { + public static JuniperStore GetOrProvisionStore(string storeName) { JuniperStore store; - lock (Lock) - { - var existing = ByStoreId.Values.FirstOrDefault(s => + lock (Lock) { + JuniperStore? existing = ByStoreId.Values.FirstOrDefault(s => string.Equals(s.StoreName, storeName, StringComparison.OrdinalIgnoreCase)); if (existing != null) return existing; store = new JuniperStore { RowId = Guid.NewGuid(), StoreName = storeName }; RegisterCore(store); } + StoreDiscovered?.Invoke(store); return store; } - public static void RaiseStoreUpdated(JuniperStore store) => StoreUpdated?.Invoke(store); - public static void RaiseProductUpdated(JuniperProduct product) => ProductUpdated?.Invoke(product); + public static void RaiseStoreUpdated(JuniperStore store) { + StoreUpdated?.Invoke(store); + } - public static bool TryParseProductUrl(string? url, out string storeName, out BigInteger productId) - { + public static void RaiseProductUpdated(JuniperProduct product) { + ProductUpdated?.Invoke(product); + } + + public static bool TryParseProductUrl(string? url, out string storeName, out BigInteger productId) { storeName = ""; productId = default; if (string.IsNullOrWhiteSpace(url)) return false; - if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) || + if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out Uri? uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) return false; - var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + string[] segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); int pIndex = Array.FindIndex(segments, s => string.Equals(s, "p", StringComparison.OrdinalIgnoreCase)); if (pIndex < 0 || pIndex + 1 >= segments.Length) return false; if (!TryParseProductId(segments[pIndex + 1], out productId)) return false; @@ -157,26 +150,21 @@ public static bool TryParseProductUrl(string? url, out string storeName, out Big } [ExcludeFromCodeCoverage] -public static class JuniperOrderHelper -{ - public static bool TryGetProduct(string? meta, [NotNullWhen(true)] out JuniperProduct? product) - { +public static class JuniperOrderHelper { + public static bool TryGetProduct(string? meta, [NotNullWhen(true)] out JuniperProduct? product) { product = null; return !string.IsNullOrWhiteSpace(meta) && JuniperStoreRegistry.TryGetProduct(meta, out product); } - public static string GetProductLabel(string? meta) - { - if (!TryGetProduct(meta, out var product)) + public static string GetProductLabel(string? meta) { + if (!TryGetProduct(meta, out JuniperProduct? product)) return $"Juniper Product ({meta})"; return product.ProductName; } - public static string GetStoreLabel(string? meta) - { - if (TryGetProduct(meta, out var product)) - { - var name = JuniperStoreRegistry.GetStoreName(product.StoreId); + public static string GetStoreLabel(string? meta) { + if (TryGetProduct(meta, out JuniperProduct? product)) { + string name = JuniperStoreRegistry.GetStoreName(product.StoreId); if (!string.IsNullOrWhiteSpace(name)) return name; } @@ -184,49 +172,41 @@ public static string GetStoreLabel(string? meta) } } - [ExcludeFromCodeCoverage] -public static class OrderMetaFilter -{ - public static bool Matches(SubathonEventType? eventType, string? eventMeta, string? filter) - { +public static class OrderMetaFilter { + public static bool Matches(SubathonEventType? eventType, string? eventMeta, string? filter) { if (string.IsNullOrEmpty(filter)) return true; if (string.Equals(filter, eventMeta, StringComparison.OrdinalIgnoreCase)) return true; - if (eventType == SubathonEventType.JuniperMerchSale && Guid.TryParse(filter, out var storeId) - && JuniperOrderHelper.TryGetProduct(eventMeta, out var product)) + if (eventType == SubathonEventType.JuniperMerchSale && Guid.TryParse(filter, out Guid storeId) + && JuniperOrderHelper.TryGetProduct(eventMeta, + out JuniperProduct? product)) return product.StoreId == storeId; return false; } - public static int Specificity(SubathonEventType? eventType, string? eventMeta, string? filter) - { + public static int Specificity(SubathonEventType? eventType, string? eventMeta, string? filter) { if (string.IsNullOrEmpty(filter)) return 0; if (string.Equals(filter, eventMeta, StringComparison.OrdinalIgnoreCase)) return 2; return Matches(eventType, eventMeta, filter) ? 1 : -1; } - public static string Describe(SubathonEventType? eventType, string? filter) - { + public static string Describe(SubathonEventType? eventType, string? filter) { if (string.IsNullOrEmpty(filter)) - { - return eventType switch - { + return eventType switch { SubathonEventType.MakeShipPledge => "Any Pledge", SubathonEventType.MakeShipSale or SubathonEventType.JuniperMerchSale => "Any Sale", SubathonEventType.GoAffProOrder => "Any Order", _ => "Any" }; - } - if (eventType == SubathonEventType.JuniperMerchSale) - { - if (Guid.TryParse(filter, out var storeId)) - { - var storeName = JuniperStoreRegistry.TryGetStore(storeId, out var store) + if (eventType == SubathonEventType.JuniperMerchSale) { + if (Guid.TryParse(filter, out Guid storeId)) { + string storeName = JuniperStoreRegistry.TryGetStore(storeId, out JuniperStore? store) ? store.StoreName : "Unknown Store"; return $"{storeName} - Any Sale"; } + return $"{JuniperOrderHelper.GetStoreLabel(filter)} - {JuniperOrderHelper.GetProductLabel(filter)}"; } @@ -237,8 +217,7 @@ public static string Describe(SubathonEventType? eventType, string? filter) } } -public static class GoAffProStoreRegistry -{ +public static class GoAffProStoreRegistry { private static readonly Dictionary BySiteId = new(); private static readonly Dictionary ByInternalName = new(); private static readonly HashSet ActiveSiteIds = new(); @@ -246,64 +225,59 @@ public static class GoAffProStoreRegistry public static event Action? StoreDiscovered; - public static void Initialize(IEnumerable stores) - { - lock (Lock) - { + public static void Initialize(IEnumerable stores) { + lock (Lock) { BySiteId.Clear(); ByInternalName.Clear(); - foreach (var store in stores) + foreach (GoAffProStore store in stores) Register(store); } } - public static void Register(GoAffProStore store) - { - lock (Lock) - { + public static void Register(GoAffProStore store) { + lock (Lock) { BySiteId[store.SiteId] = store; ByInternalName[store.InternalName] = store; } } - public static bool TryGetBySiteId(int siteId, [NotNullWhen(true)] out GoAffProStore? store) - { - lock (Lock) { return BySiteId.TryGetValue(siteId, out store); } + public static bool TryGetBySiteId(int siteId, [NotNullWhen(true)] out GoAffProStore? store) { + lock (Lock) { + return BySiteId.TryGetValue(siteId, out store); + } } - public static bool TryGetByInternalName(string name, [NotNullWhen(true)] out GoAffProStore? store) - { - lock (Lock) { return ByInternalName.TryGetValue(name, out store); } + public static bool TryGetByInternalName(string name, [NotNullWhen(true)] out GoAffProStore? store) { + lock (Lock) { + return ByInternalName.TryGetValue(name, out store); + } } - public static IReadOnlyList All() - { - lock (Lock) { return BySiteId.Values.OrderBy(s => s.RowId).ToList(); } + public static IReadOnlyList All() { + lock (Lock) { + return BySiteId.Values.OrderBy(s => s.RowId).ToList(); + } } - public static void MarkActiveOnAccount(int siteId, bool active = true) - { - lock (Lock) - { + public static void MarkActiveOnAccount(int siteId, bool active = true) { + lock (Lock) { if (active) ActiveSiteIds.Add(siteId); else ActiveSiteIds.Remove(siteId); } } - public static bool IsActiveOnAccount(int siteId) - { - lock (Lock) { return ActiveSiteIds.Contains(siteId); } + public static bool IsActiveOnAccount(int siteId) { + lock (Lock) { + return ActiveSiteIds.Contains(siteId); + } } - public static GoAffProStore GetOrProvision(int siteId, string fallbackName = "") - { - lock (Lock) - { - if (BySiteId.TryGetValue(siteId, out var existing)) + public static GoAffProStore GetOrProvision(int siteId, string fallbackName = "") { + lock (Lock) { + if (BySiteId.TryGetValue(siteId, out GoAffProStore? existing)) return existing; - var store = new GoAffProStore - { + var store = new GoAffProStore { SiteId = siteId, StoreName = string.IsNullOrWhiteSpace(fallbackName) ? $"Unknown Store ({siteId})" @@ -322,57 +296,49 @@ public static GoAffProStore GetOrProvision(int siteId, string fallbackName = "") } [ExcludeFromCodeCoverage] -public static class GoAffProOrderHelper -{ - public static bool TryParseMeta(string? meta, out int siteId) - { +public static class GoAffProOrderHelper { + public static bool TryParseMeta(string? meta, out int siteId) { siteId = -1; return meta != null && int.TryParse(meta, out siteId); } - public static bool TryGetStore(string? meta, [NotNullWhen(true)] out GoAffProStore? store) - { + public static bool TryGetStore(string? meta, [NotNullWhen(true)] out GoAffProStore? store) { store = null; - return TryParseMeta(meta, out var siteId) && GoAffProStoreRegistry.TryGetBySiteId(siteId, out store); + return TryParseMeta(meta, out int siteId) && GoAffProStoreRegistry.TryGetBySiteId(siteId, out store); } - public static string GetOrderLabel(string? meta) - { - if (!TryGetStore(meta, out var store)) + public static string GetOrderLabel(string? meta) { + if (!TryGetStore(meta, out GoAffProStore? store)) return $"GoAffPro Order ({meta})"; return store.EventName; } - public static string GetLabel(string? meta) - { - if (!TryGetStore(meta, out var store)) + public static string GetLabel(string? meta) { + if (!TryGetStore(meta, out GoAffProStore? store)) return $"GoAffPro Store ({meta})"; return store.StoreName; } - public static string GetOrderKey(SubathonEventType? eventType, string? meta = null) - { + public static string GetOrderKey(SubathonEventType? eventType, string? meta = null) { if (eventType != SubathonEventType.GoAffProOrder) return eventType?.ToString() ?? string.Empty; if (string.IsNullOrEmpty(meta)) return "GoAffProOrder"; - if (TryGetStore(meta, out var store)) + if (TryGetStore(meta, out GoAffProStore? store)) return store.InternalEventName; return $"GoAffProOrder_{meta}"; } - public static bool TryGetStoreByOrderKey(string key, [NotNullWhen(true)] out GoAffProStore? store) - { + public static bool TryGetStoreByOrderKey(string key, [NotNullWhen(true)] out GoAffProStore? store) { store = GoAffProStoreRegistry.All() .FirstOrDefault(s => string.Equals(s.InternalEventName, key, StringComparison.OrdinalIgnoreCase)); return store != null; } - public static string GetOrderEventDisplayLabel(SubathonEventType? eventType, string? meta = null) - { + public static string GetOrderEventDisplayLabel(SubathonEventType? eventType, string? meta = null) { if (eventType != SubathonEventType.GoAffProOrder) return eventType.GetLabel(); return string.IsNullOrEmpty(meta) @@ -380,12 +346,11 @@ public static string GetOrderEventDisplayLabel(SubathonEventType? eventType, str : GetOrderLabel(meta); } - public static string GetOrderEventDisplayDescription(SubathonEventType? eventType, string? meta = null) - { + public static string GetOrderEventDisplayDescription(SubathonEventType? eventType, string? meta = null) { if (eventType != SubathonEventType.GoAffProOrder) return eventType?.GetDescription() ?? string.Empty; return string.IsNullOrEmpty(meta) ? "GoAffPro Order" : GetOrderLabel(meta); } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/ResourcePaths.cs b/SubathonManager.Core/ResourcePaths.cs index 3a4a5633..7e59a2cc 100644 --- a/SubathonManager.Core/ResourcePaths.cs +++ b/SubathonManager.Core/ResourcePaths.cs @@ -2,29 +2,26 @@ namespace SubathonManager.Core; -public static partial class ResourcePaths -{ +public static partial class ResourcePaths { public const string UrlPrefix = "/resources/"; public const string BundleFolder = "resources"; + public static readonly string[] DefaultFolders = ["images", "images/logos", "audio"]; // think of more later? public static string Root => Path.GetFullPath("./resources"); - public static readonly string[] DefaultFolders = ["images", "images/logos", "audio"]; // think of more later? - public static void EnsureCreated() - { - try - { + public static void EnsureCreated() { + try { Directory.CreateDirectory(Root); - foreach (var folder in DefaultFolders) + foreach (string folder in DefaultFolders) Directory.CreateDirectory(Path.Combine(Root, folder.Replace('/', Path.DirectorySeparatorChar))); } - catch { /**/ } + catch { + /**/ + } } - public static List EnumerateRelative() - { - try - { + public static List EnumerateRelative() { + try { string root = Root; if (!Directory.Exists(root)) return []; @@ -34,26 +31,24 @@ public static List EnumerateRelative() .OrderBy(rel => rel, StringComparer.OrdinalIgnoreCase) .ToList(); } - catch { return []; } + catch { + return []; + } } - public static IEnumerable FindReferences(string? text) - { + public static IEnumerable FindReferences(string? text) { if (string.IsNullOrEmpty(text)) yield break; - foreach (Match m in ReferenceRegex().Matches(text)) - { + foreach (Match m in ReferenceRegex().Matches(text)) { string? rel = NormalizeReference(m.Groups[1].Value); if (rel != null) yield return rel; } } - public static string RewriteReferences(string text, Func prefixFor) - { + public static string RewriteReferences(string text, Func prefixFor) { if (string.IsNullOrEmpty(text)) return text; - return ReferenceRegex().Replace(text, m => - { + return ReferenceRegex().Replace(text, m => { string? rel = NormalizeReference(m.Groups[1].Value); if (rel == null) return m.Value; @@ -62,33 +57,34 @@ public static string RewriteReferences(string text, Func prefix }); } - private static string? NormalizeReference(string captured) - { + private static string? NormalizeReference(string captured) { string rel = captured.Split('?')[0].Split('#')[0].Trim('/'); if (rel.Length == 0) return null; - try { return Uri.UnescapeDataString(rel); } - catch { return rel; } + try { + return Uri.UnescapeDataString(rel); + } + catch { + return rel; + } } - public static string? RelativeFromUrl(string? value) - { + public static string? RelativeFromUrl(string? value) { if (!IsResourceUrl(value)) return null; string rel = value!.Replace('\\', '/')[UrlPrefix.Length..].Split('?')[0].Split('#')[0].Trim('/'); return rel.Length == 0 ? null : rel; } - public static bool IsResourceUrl(string? value) - => !string.IsNullOrWhiteSpace(value) - && value.Replace('\\', '/').StartsWith(UrlPrefix, StringComparison.OrdinalIgnoreCase); + public static bool IsResourceUrl(string? value) { + return !string.IsNullOrWhiteSpace(value) + && value.Replace('\\', '/').StartsWith(UrlPrefix, StringComparison.OrdinalIgnoreCase); + } - public static string? ToResourceUrl(string? localPath) - { + public static string? ToResourceUrl(string? localPath) { if (string.IsNullOrWhiteSpace(localPath)) return null; if (IsResourceUrl(localPath)) return localPath.Replace('\\', '/'); - try - { + try { string root = Root; string full = Path.GetFullPath(localPath); @@ -98,15 +94,15 @@ public static bool IsResourceUrl(string? value) string relative = full[(root.Length + 1)..].Replace('\\', '/').Trim('/'); return relative.Length == 0 ? null : UrlPrefix + relative; } - catch { return null; } + catch { + return null; + } } - public static string? ToLocalPath(string? resourceUrl) - { + public static string? ToLocalPath(string? resourceUrl) { if (!IsResourceUrl(resourceUrl)) return null; - try - { + try { string relative = resourceUrl!.Replace('\\', '/')[UrlPrefix.Length..].Trim('/'); string root = Root; string full = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar))); @@ -115,14 +111,15 @@ public static bool IsResourceUrl(string? value) ? full : null; } - catch { return null; } + catch { + return null; + } } [GeneratedRegex(@"(?)]+)", RegexOptions.IgnoreCase)] private static partial Regex ReferenceRegex(); - public static string? ResolveRequestPath(string? requestPath) - { + public static string? ResolveRequestPath(string? requestPath) { if (string.IsNullOrWhiteSpace(requestPath)) return null; string relative = requestPath.Split('?')[0]; @@ -132,8 +129,7 @@ public static bool IsResourceUrl(string? value) if (relative.Length == 0) return null; - try - { + try { relative = Uri.UnescapeDataString(relative); if (relative.IndexOfAny(Path.GetInvalidPathChars()) >= 0) return null; @@ -145,6 +141,8 @@ public static bool IsResourceUrl(string? value) return File.Exists(full) ? full : null; } - catch { return null; } + catch { + return null; + } } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/RotatingFileLoggerProvider.cs b/SubathonManager.Core/RotatingFileLoggerProvider.cs index aff84e4e..08f3d8b2 100644 --- a/SubathonManager.Core/RotatingFileLoggerProvider.cs +++ b/SubathonManager.Core/RotatingFileLoggerProvider.cs @@ -1,37 +1,40 @@ -using System.Text; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; using Microsoft.Extensions.Logging; -using System.Diagnostics.CodeAnalysis; - -namespace SubathonManager.Core -{ - [ExcludeFromCodeCoverage] - public class RotatingFileLoggerProvider : ILoggerProvider - { - private readonly string _logDir; - private readonly TimeSpan _retention; - private readonly SemaphoreSlim _lock = new(1, 1); - private string _currentLogFile; - private DateTime _lastRotationDate = DateTime.Now.Date; - - public RotatingFileLoggerProvider(string logDirectory, int retentionDays = 30) - { - _logDir = logDirectory; - _retention = TimeSpan.FromDays(retentionDays); - Directory.CreateDirectory(_logDir); - _currentLogFile = GetLogFilePath(DateTime.Now); - _ = Task.Run(CleanupOldLogsAsync); - } - public ILogger CreateLogger(string categoryName) => - new RotatingFileLogger(categoryName, this); +namespace SubathonManager.Core; + +[ExcludeFromCodeCoverage] +public class RotatingFileLoggerProvider : ILoggerProvider { + private readonly SemaphoreSlim _lock = new(1, 1); + private readonly string _logDir; + private readonly TimeSpan _retention; + private string _currentLogFile; + private DateTime _lastRotationDate = DateTime.Now.Date; + + public RotatingFileLoggerProvider(string logDirectory, int retentionDays = 30) { + _logDir = logDirectory; + _retention = TimeSpan.FromDays(retentionDays); + Directory.CreateDirectory(_logDir); + _currentLogFile = GetLogFilePath(DateTime.Now); + _ = Task.Run(CleanupOldLogsAsync); + } + + public ILogger CreateLogger(string categoryName) { + return new RotatingFileLogger(categoryName, this); + } - public void Dispose() => _lock.Dispose(); + public void Dispose() { + _lock.Dispose(); + } - private string GetLogFilePath(DateTime date) => - Path.Combine(_logDir, $"app_{date:yyyy-MM-dd}.log"); + private string GetLogFilePath(DateTime date) { + return Path.Combine(_logDir, $"app_{date:yyyy-MM-dd}.log"); + } - private static string GetShortLevel(LogLevel level) => level switch - { + private static string GetShortLevel(LogLevel level) { + return level switch { LogLevel.Trace => "TRACE", LogLevel.Debug => "DEBUG", LogLevel.Information => "INFO", @@ -40,144 +43,129 @@ private string GetLogFilePath(DateTime date) => LogLevel.Critical => "CRIT", _ => "NONE" }; - - internal async Task WriteAsync(string category, LogLevel level, string message, Exception? ex) - { - await _lock.WaitAsync(); - try - { - RotateIfNeeded(); - - var sb = new StringBuilder() - .Append('[').Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")).Append("] ") - .Append('[').Append(GetShortLevel(level)).Append("] ") - .Append('[').Append(category).Append("] ") - .Append(message); - - if (ex != null) - sb.Append(" Exception: ").Append(ex); - - sb.AppendLine(); - - await File.AppendAllTextAsync(_currentLogFile, sb.ToString(), Encoding.UTF8); - } - finally - { - _lock.Release(); - } + } + + internal async Task WriteAsync(string category, LogLevel level, string message, Exception? ex) { + await _lock.WaitAsync(); + try { + RotateIfNeeded(); + + StringBuilder sb = new StringBuilder() + .Append('[').Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")).Append("] ") + .Append('[').Append(GetShortLevel(level)).Append("] ") + .Append('[').Append(category).Append("] ") + .Append(message); + + if (ex != null) + sb.Append(" Exception: ").Append(ex); + + sb.AppendLine(); + + await File.AppendAllTextAsync(_currentLogFile, sb.ToString(), Encoding.UTF8); } + finally { + _lock.Release(); + } + } - private void RotateIfNeeded() - { - var now = DateTime.Now; - if (now.Date != _lastRotationDate) - { - _currentLogFile = GetLogFilePath(now); - _lastRotationDate = now.Date; - _ = Task.Run(CleanupOldLogsAsync); - } + private void RotateIfNeeded() { + DateTime now = DateTime.Now; + if (now.Date != _lastRotationDate) { + _currentLogFile = GetLogFilePath(now); + _lastRotationDate = now.Date; + _ = Task.Run(CleanupOldLogsAsync); } + } - private async Task CleanupOldLogsAsync() - { - try - { - var files = Directory.GetFiles(_logDir, "app_*.log"); - var cutoff = DateTime.Now - _retention; - - foreach (var file in files) - { - var name = Path.GetFileNameWithoutExtension(file); - if (name.StartsWith("app_") && - DateTime.TryParseExact(name[4..], "yyyy-MM-dd", null, - System.Globalization.DateTimeStyles.None, out var date) && - date < cutoff) - { - try { File.Delete(file); } catch { /**/ } + private async Task CleanupOldLogsAsync() { + try { + string[] files = Directory.GetFiles(_logDir, "app_*.log"); + DateTime cutoff = DateTime.Now - _retention; + + foreach (string file in files) { + string name = Path.GetFileNameWithoutExtension(file); + if (name.StartsWith("app_") && + DateTime.TryParseExact(name[4..], "yyyy-MM-dd", null, + DateTimeStyles.None, out DateTime date) && + date < cutoff) + try { + File.Delete(file); + } + catch { + /**/ } - } - - await Task.CompletedTask; } - catch {/**/} + + await Task.CompletedTask; + } + catch { + /**/ } } +} - [ExcludeFromCodeCoverage] - internal class RotatingFileLogger : ILogger - { - private readonly string _category; - private readonly RotatingFileLoggerProvider _provider; +[ExcludeFromCodeCoverage] +internal class RotatingFileLogger : ILogger { + private readonly string _category; + private readonly RotatingFileLoggerProvider _provider; - public RotatingFileLogger(string category, RotatingFileLoggerProvider provider) - { - _category = category; - _provider = provider; - } + public RotatingFileLogger(string category, RotatingFileLoggerProvider provider) { + _category = category; + _provider = provider; + } - public IDisposable? BeginScope(TState state) where TState : notnull - => null; - - public bool IsEnabled(LogLevel logLevel) - { - if (_category.StartsWith("Microsoft.EntityFrameworkCore", StringComparison.OrdinalIgnoreCase)) - return logLevel >= LogLevel.Warning; - return logLevel != LogLevel.None; - } + public IDisposable? BeginScope(TState state) where TState : notnull { + return null; + } - public void Log(LogLevel logLevel, EventId eventId, TState state, - Exception? exception, Func formatter) - { - if (!IsEnabled(logLevel)) return; + public bool IsEnabled(LogLevel logLevel) { + if (_category.StartsWith("Microsoft.EntityFrameworkCore", StringComparison.OrdinalIgnoreCase)) + return logLevel >= LogLevel.Warning; + return logLevel != LogLevel.None; + } - var message = formatter(state, exception); - - if (_category.StartsWith("Streamlabs.SocketClient", StringComparison.OrdinalIgnoreCase)) - { - // ignore many StreamLabs errors as we only use it for tips, but it still ingests all events?? - // won't ignore logging for dev/console, but will for file output at least - var text = (message + " " + exception?.Message); - - if (text.Contains("JSON property 'type' could not be mapped", StringComparison.OrdinalIgnoreCase) || - text.Contains("The input string '' was not in a correct format", StringComparison.OrdinalIgnoreCase) || - text.Contains("The JSON value could not be converted to ", StringComparison.OrdinalIgnoreCase)) - logLevel = LogLevel.Debug; - - if (text.Contains("deserializing event", StringComparison.OrdinalIgnoreCase) && - !text.Contains("\"type\":\"donation\"", StringComparison.OrdinalIgnoreCase)) - logLevel = LogLevel.Debug; - - if (!IsEnabled(logLevel)) return; - } - else if (_category.Contains("PicartoEventsLib", StringComparison.OrdinalIgnoreCase)) - { - var text = (message + " " + exception?.Message); - if (text.Contains("WebSocket receive loop error", StringComparison.OrdinalIgnoreCase) && - text.Contains("without completing the close handshake")) - { - logLevel = LogLevel.Debug; - } - if (!IsEnabled(logLevel)) return; - } - else if (_category.Contains("YTLiveChat", StringComparison.OrdinalIgnoreCase)) - { - var text = (message + " " + exception?.Message); - if (text.Contains("Live Stream canonical link not found", StringComparison.OrdinalIgnoreCase) - || text.Contains("Failed to get or parse initial options") - || text.Contains("Initial Continuation token not found", StringComparison.OrdinalIgnoreCase)) - { - logLevel = LogLevel.None; - } - else if (text.Contains("Listener task finished execution") || text.Contains("Fetching initial options") || - text.Contains("Polling task completed normally") || text.Contains("Attempting to start listener") - || text.Contains("Listener task starting initialization")) - { - logLevel = LogLevel.Debug; - } - if (!IsEnabled(logLevel)) return; - } - - _ = _provider.WriteAsync(_category, logLevel, message, exception); + public void Log(LogLevel logLevel, EventId eventId, TState state, + Exception? exception, Func formatter) { + if (!IsEnabled(logLevel)) return; + + string message = formatter(state, exception); + + if (_category.StartsWith("Streamlabs.SocketClient", StringComparison.OrdinalIgnoreCase)) { + // ignore many StreamLabs errors as we only use it for tips, but it still ingests all events?? + // won't ignore logging for dev/console, but will for file output at least + string text = message + " " + exception?.Message; + + if (text.Contains("JSON property 'type' could not be mapped", StringComparison.OrdinalIgnoreCase) || + text.Contains("The input string '' was not in a correct format", StringComparison.OrdinalIgnoreCase) || + text.Contains("The JSON value could not be converted to ", StringComparison.OrdinalIgnoreCase)) + logLevel = LogLevel.Debug; + + if (text.Contains("deserializing event", StringComparison.OrdinalIgnoreCase) && + !text.Contains("\"type\":\"donation\"", StringComparison.OrdinalIgnoreCase)) + logLevel = LogLevel.Debug; + + if (!IsEnabled(logLevel)) return; } + else if (_category.Contains("PicartoEventsLib", StringComparison.OrdinalIgnoreCase)) { + string text = message + " " + exception?.Message; + if (text.Contains("WebSocket receive loop error", StringComparison.OrdinalIgnoreCase) && + text.Contains("without completing the close handshake")) + logLevel = LogLevel.Debug; + if (!IsEnabled(logLevel)) return; + } + else if (_category.Contains("YTLiveChat", StringComparison.OrdinalIgnoreCase)) { + string text = message + " " + exception?.Message; + if (text.Contains("Live Stream canonical link not found", StringComparison.OrdinalIgnoreCase) + || text.Contains("Failed to get or parse initial options") + || text.Contains("Initial Continuation token not found", StringComparison.OrdinalIgnoreCase)) + logLevel = LogLevel.None; + else if (text.Contains("Listener task finished execution") || text.Contains("Fetching initial options") || + text.Contains("Polling task completed normally") || text.Contains("Attempting to start listener") + || text.Contains("Listener task starting initialization")) + logLevel = LogLevel.Debug; + if (!IsEnabled(logLevel)) return; + } + + _ = _provider.WriteAsync(_category, logLevel, message, exception); } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/SafeFileName.cs b/SubathonManager.Core/SafeFileName.cs index 4b9db491..c93c0bf5 100644 --- a/SubathonManager.Core/SafeFileName.cs +++ b/SubathonManager.Core/SafeFileName.cs @@ -3,46 +3,43 @@ namespace SubathonManager.Core; -public static class SafeFileName -{ +public static class SafeFileName { public const string DefaultReplacement = "_"; public const int MaxLength = 255; private static readonly HashSet InvalidChars = BuildInvalidChars(); - private static HashSet BuildInvalidChars() - { + private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) { + "CON", "PRN", "AUX", "NUL", + "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }; + + private static HashSet BuildInvalidChars() { var chars = new HashSet("\"<>|:*?\\/"); - for (char c = (char)0; c <= (char)31; c++) chars.Add(c); + for (var c = (char)0; c <= (char)31; c++) chars.Add(c); chars.Add((char)127); foreach (char c in Path.GetInvalidFileNameChars()) chars.Add(c); return chars; } - private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) - { - "CON", "PRN", "AUX", "NUL", - "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" - }; - - public static bool IsInvalidChar(char value) => InvalidChars.Contains(value); + public static bool IsInvalidChar(char value) { + return InvalidChars.Contains(value); + } - public static bool IsSafe([NotNullWhen(true)] string? value) - => !string.IsNullOrWhiteSpace(value) && string.Equals(Sanitize(value), value, StringComparison.Ordinal); + public static bool IsSafe([NotNullWhen(true)] string? value) { + return !string.IsNullOrWhiteSpace(value) && string.Equals(Sanitize(value), value, StringComparison.Ordinal); + } public static string Sanitize(string? value, string replacement = DefaultReplacement, - string fallback = "") - { + string fallback = "") { if (string.IsNullOrWhiteSpace(value)) return fallback; var sb = new StringBuilder(value.Length); foreach (char c in value.Trim()) - { if (IsInvalidChar(c)) sb.Append(replacement); else sb.Append(c); - } string result = sb.ToString().TrimEnd(' ', '.'); @@ -52,9 +49,8 @@ public static string Sanitize(string? value, string replacement = DefaultReplace return IsReserved(result) ? DefaultReplacement + result : result; } - private static bool IsReserved(string name) - { + private static bool IsReserved(string name) { int dot = name.IndexOf('.'); return ReservedNames.Contains(dot < 0 ? name : name[..dot]); } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Security/AesFileSecureStorage.cs b/SubathonManager.Core/Security/AesFileSecureStorage.cs index 57e491c4..de4815bb 100644 --- a/SubathonManager.Core/Security/AesFileSecureStorage.cs +++ b/SubathonManager.Core/Security/AesFileSecureStorage.cs @@ -8,135 +8,121 @@ namespace SubathonManager.Core.Security; [ExcludeFromCodeCoverage] -public sealed class AesFileSecureStorage : ISecureStorage -{ +public sealed class AesFileSecureStorage : ISecureStorage { private const int KeySize = 32; private const int NonceSize = 12; private const int TagSize = 16; private readonly string _filePath; + private readonly byte[] _key; private readonly string _keyPath; private readonly Lock _lock = new(); - private readonly byte[] _key; - private Dictionary _store; - private ILogger? _logger; + private readonly ILogger? _logger; + private readonly Dictionary _store; - public AesFileSecureStorage(ILogger? logger, string? filePath = null, string? keyPath = null) - { + public AesFileSecureStorage(ILogger? logger, string? filePath = null, string? keyPath = null) { _logger = logger; _filePath = filePath - ?? Path.GetFullPath(Path.Combine(string.Empty - , "data/secure_store.bin")); + ?? Path.GetFullPath(Path.Combine(string.Empty + , "data/secure_store.bin")); _keyPath = keyPath - ?? Path.ChangeExtension(_filePath, ".key"); + ?? Path.ChangeExtension(_filePath, ".key"); _key = LoadOrCreateKey(); _store = Load(); } - public bool Set(string key, string value) - { + public bool Set(string key, string value) { ArgumentException.ThrowIfNullOrWhiteSpace(key); - bool hasUpdated = false; - if (string.IsNullOrWhiteSpace(value)) - { + var hasUpdated = false; + if (string.IsNullOrWhiteSpace(value)) { Delete(key); return false; } + if (string.Equals(GetOrDefault(key), value)) return false; - lock (_lock) - { + lock (_lock) { _store[key] = value; Flush(); hasUpdated = true; } - if (hasUpdated) - { - _logger?.LogInformation("[SecureStorage] Saved {Key}", key); - } + if (hasUpdated) _logger?.LogInformation("[SecureStorage] Saved {Key}", key); return hasUpdated; } - public string? Get(string key) - { + public string? Get(string key) { ArgumentException.ThrowIfNullOrWhiteSpace(key); - lock (_lock) + lock (_lock) { return _store.GetValueOrDefault(key); + } } - public string? GetOrDefault(string key, string defaultValue = "") - { + public string? GetOrDefault(string key, string defaultValue = "") { ArgumentException.ThrowIfNullOrWhiteSpace(key); - lock (_lock) + lock (_lock) { return _store.GetValueOrDefault(key, defaultValue); + } } - public bool Delete(string key) - { + public bool Delete(string key) { ArgumentException.ThrowIfNullOrWhiteSpace(key); - lock (_lock) - { - if (_store.Remove(key)) - { + lock (_lock) { + if (_store.Remove(key)) { Flush(); _logger?.LogInformation("[SecureStorage] Removed {Key}", key); return true; } } + return false; } - public bool Exists(string key) - { - lock (_lock) + public bool Exists(string key) { + lock (_lock) { return _store.ContainsKey(key); + } } - private byte[] LoadOrCreateKey() - { - if (File.Exists(_keyPath)) - { - var existing = File.ReadAllBytes(_keyPath); + private byte[] LoadOrCreateKey() { + if (File.Exists(_keyPath)) { + byte[] existing = File.ReadAllBytes(_keyPath); if (existing.Length == KeySize) return existing; throw new InvalidOperationException( $"Secure store key file is invalid ({_keyPath}). " + "Delete secure_store.bin and secure_store.key and re-authenticate all services."); } - var key = RandomNumberGenerator.GetBytes(KeySize); - var dir = Path.GetDirectoryName(_keyPath); + byte[] key = RandomNumberGenerator.GetBytes(KeySize); + string? dir = Path.GetDirectoryName(_keyPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); File.WriteAllBytes(_keyPath, key); RestrictToOwner(_keyPath); return key; } - private Dictionary Load() - { + private Dictionary Load() { if (!File.Exists(_filePath)) return new Dictionary(); - try - { - var raw = File.ReadAllBytes(_filePath); + try { + byte[] raw = File.ReadAllBytes(_filePath); if (raw.Length < NonceSize + TagSize) throw new CryptographicException("Secure store file is too short to be valid."); - var nonce = raw.AsSpan(0, NonceSize); - var tag = raw.AsSpan(NonceSize, TagSize); - var ciphertext = raw.AsSpan(NonceSize + TagSize); + Span nonce = raw.AsSpan(0, NonceSize); + Span tag = raw.AsSpan(NonceSize, TagSize); + Span ciphertext = raw.AsSpan(NonceSize + TagSize); var plaintext = new byte[ciphertext.Length]; using var aes = new AesGcm(_key, TagSize); aes.Decrypt(nonce, ciphertext, tag, plaintext); return JsonSerializer.Deserialize>( - Encoding.UTF8.GetString(plaintext)) - ?? new Dictionary(); + Encoding.UTF8.GetString(plaintext)) + ?? new Dictionary(); } - catch (CryptographicException ex) - { + catch (CryptographicException ex) { throw new InvalidOperationException( "Secure store could not be decrypted. " + "If you moved the app between machines or lost secure_store.key, " + @@ -145,12 +131,11 @@ private Dictionary Load() } } - private void Flush() - { - var json = JsonSerializer.Serialize(_store); - var plaintext = Encoding.UTF8.GetBytes(json); + private void Flush() { + string json = JsonSerializer.Serialize(_store); + byte[] plaintext = Encoding.UTF8.GetBytes(json); - var nonce = RandomNumberGenerator.GetBytes(NonceSize); + byte[] nonce = RandomNumberGenerator.GetBytes(NonceSize); var tag = new byte[TagSize]; var ciphertext = new byte[plaintext.Length]; @@ -162,15 +147,14 @@ private void Flush() tag.CopyTo(raw, NonceSize); ciphertext.CopyTo(raw, NonceSize + TagSize); - var tmp = _filePath + ".tmp"; + string tmp = _filePath + ".tmp"; File.WriteAllBytes(tmp, raw); RestrictToOwner(tmp); - File.Move(tmp, _filePath, overwrite: true); + File.Move(tmp, _filePath, true); } - private static void RestrictToOwner(string path) - { + private static void RestrictToOwner(string path) { if (OperatingSystem.IsWindows()) return; File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Security/DpapiSecureStorage.cs b/SubathonManager.Core/Security/DpapiSecureStorage.cs index d8beab70..168c23df 100644 --- a/SubathonManager.Core/Security/DpapiSecureStorage.cs +++ b/SubathonManager.Core/Security/DpapiSecureStorage.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -7,101 +8,89 @@ namespace SubathonManager.Core.Security; -[System.Runtime.Versioning.SupportedOSPlatform("windows")] +[SupportedOSPlatform("windows")] [ExcludeFromCodeCoverage] -public sealed class DpapiSecureStorage : ISecureStorage -{ +public sealed class DpapiSecureStorage : ISecureStorage { private readonly string _filePath; private readonly Lock _lock = new(); - private Dictionary _store; - private ILogger? _logger; + private readonly ILogger? _logger; + private readonly Dictionary _store; - public DpapiSecureStorage(ILogger? logger, string? filePath = null) - { + public DpapiSecureStorage(ILogger? logger, string? filePath = null) { _logger = logger; _filePath = filePath - ?? Path.GetFullPath(Path.Combine(string.Empty - , "data/secure_store.bin")); + ?? Path.GetFullPath(Path.Combine(string.Empty + , "data/secure_store.bin")); _store = Load(); } - public bool Set(string key, string value) - { + public bool Set(string key, string value) { ArgumentException.ThrowIfNullOrWhiteSpace(key); - bool hasUpdated = false; - if (string.IsNullOrWhiteSpace(value)) - { + var hasUpdated = false; + if (string.IsNullOrWhiteSpace(value)) { Delete(key); return false; } + if (string.Equals(GetOrDefault(key), value)) return false; - lock (_lock) - { + lock (_lock) { _store[key] = value; Flush(); hasUpdated = true; } - if (hasUpdated) - { - _logger?.LogInformation("[SecureStorage] Saved {Key}", key); - } + if (hasUpdated) _logger?.LogInformation("[SecureStorage] Saved {Key}", key); return hasUpdated; } - public string? Get(string key) - { + public string? Get(string key) { ArgumentException.ThrowIfNullOrWhiteSpace(key); - lock (_lock) + lock (_lock) { return _store.GetValueOrDefault(key); + } } - - public string? GetOrDefault(string key, string defaultValue = "") - { + + public string? GetOrDefault(string key, string defaultValue = "") { ArgumentException.ThrowIfNullOrWhiteSpace(key); - lock (_lock) + lock (_lock) { return _store.GetValueOrDefault(key, defaultValue); + } } - public bool Delete(string key) - { + public bool Delete(string key) { ArgumentException.ThrowIfNullOrWhiteSpace(key); - lock (_lock) - { - if (_store.Remove(key)) - { + lock (_lock) { + if (_store.Remove(key)) { Flush(); _logger?.LogInformation("[SecureStorage] Removed {Key}", key); return true; } } + return false; } - public bool Exists(string key) - { - lock (_lock) + public bool Exists(string key) { + lock (_lock) { return _store.ContainsKey(key); + } } - private Dictionary Load() - { + private Dictionary Load() { if (!File.Exists(_filePath)) return new Dictionary(); - try - { - var encrypted = File.ReadAllBytes(_filePath); - var plaintext = ProtectedData.Unprotect( + try { + byte[] encrypted = File.ReadAllBytes(_filePath); + byte[] plaintext = ProtectedData.Unprotect( encrypted, null, DataProtectionScope.CurrentUser); return JsonSerializer.Deserialize>( - Encoding.UTF8.GetString(plaintext)) - ?? new Dictionary(); + Encoding.UTF8.GetString(plaintext)) + ?? new Dictionary(); } - catch (CryptographicException ex) - { + catch (CryptographicException ex) { throw new InvalidOperationException( "Secure store could not be decrypted. " + "If you moved the app between user accounts, delete secure_store.bin and re-authenticate all services.", @@ -109,15 +98,14 @@ private Dictionary Load() } } - private void Flush() - { - var json = JsonSerializer.Serialize(_store); - var plaintext = Encoding.UTF8.GetBytes(json); - var encrypted = ProtectedData.Protect( + private void Flush() { + string json = JsonSerializer.Serialize(_store); + byte[] plaintext = Encoding.UTF8.GetBytes(json); + byte[] encrypted = ProtectedData.Protect( plaintext, null, DataProtectionScope.CurrentUser); - var tmp = _filePath + ".tmp"; + string tmp = _filePath + ".tmp"; File.WriteAllBytes(tmp, encrypted); - File.Move(tmp, _filePath, overwrite: true); + File.Move(tmp, _filePath, true); } } \ No newline at end of file diff --git a/SubathonManager.Core/Security/Interfaces/ISecureStorage.cs b/SubathonManager.Core/Security/Interfaces/ISecureStorage.cs index c822cc36..7ef2a002 100644 --- a/SubathonManager.Core/Security/Interfaces/ISecureStorage.cs +++ b/SubathonManager.Core/Security/Interfaces/ISecureStorage.cs @@ -1,13 +1,12 @@ namespace SubathonManager.Core.Security.Interfaces; -public interface ISecureStorage -{ +public interface ISecureStorage { /// Store or overwrite a value. Flushes to disk immediately. bool Set(string key, string value); /// Returns the value, or null if the key doesn't exist. string? Get(string key); - + string? GetOrDefault(string key, string defaultValue); /// Remove a key. No-op if it doesn't exist. Flushes to disk. diff --git a/SubathonManager.Core/Security/SignatureVerification.cs b/SubathonManager.Core/Security/SignatureVerification.cs index b7946548..c5205132 100644 --- a/SubathonManager.Core/Security/SignatureVerification.cs +++ b/SubathonManager.Core/Security/SignatureVerification.cs @@ -4,26 +4,22 @@ namespace SubathonManager.Core.Security; -public static class SignatureVerification -{ +public static class SignatureVerification { private static readonly ConcurrentDictionary KeyCache = new(); - private static PublicKey ImportFromPem(string pem) - { - return KeyCache.GetOrAdd(pem, p => - { - var b64 = p + private static PublicKey ImportFromPem(string pem) { + return KeyCache.GetOrAdd(pem, p => { + string b64 = p .Replace("-----BEGIN PUBLIC KEY-----", "") .Replace("-----END PUBLIC KEY-----", "") .Replace("\n", "").Replace("\r", "").Trim(); - var spki = Convert.FromBase64String(b64); + byte[] spki = Convert.FromBase64String(b64); return PublicKey.Import(SignatureAlgorithm.Ed25519, spki[^32..], KeyBlobFormat.RawPublicKey); }); } - - public static bool VerifyEd25519Signature(string rawBody, string? timestampHeader, - string? signatureHex, string publicKeyPem) - { + + public static bool VerifyEd25519Signature(string rawBody, string? timestampHeader, + string? signatureHex, string publicKeyPem) { if (string.IsNullOrWhiteSpace(timestampHeader) || !long.TryParse(timestampHeader, out _)) return false; @@ -31,15 +27,19 @@ public static bool VerifyEd25519Signature(string rawBody, string? timestampHeade return false; byte[] signature; - try { signature = Convert.FromHexString(signatureHex); } - catch { return false; } + try { + signature = Convert.FromHexString(signatureHex); + } + catch { + return false; + } if (signature.Length != 64) return false; - var key = ImportFromPem(publicKeyPem); + PublicKey key = ImportFromPem(publicKeyPem); - var message = Encoding.UTF8.GetBytes(timestampHeader + "." + rawBody); + byte[] message = Encoding.UTF8.GetBytes(timestampHeader + "." + rawBody); return SignatureAlgorithm.Ed25519.Verify(key, message, signature); } diff --git a/SubathonManager.Core/Security/StorageKeys.cs b/SubathonManager.Core/Security/StorageKeys.cs index 8c5bf543..147cdbc0 100644 --- a/SubathonManager.Core/Security/StorageKeys.cs +++ b/SubathonManager.Core/Security/StorageKeys.cs @@ -1,21 +1,22 @@ namespace SubathonManager.Core.Security; -public static class StorageKeys -{ +public static class StorageKeys { public const string TwitchAccessToken = "SM.Twitch.AccessToken"; - + public const string FourthWallAccessToken = "SM.FourthWall.AccessToken"; public const string FourthWallRefreshToken = "SM.FourthWall.RefreshToken"; - public const string StreamLabsSocketToken = "SM.StreamLabs.SocketToken"; + public const string StreamLabsSocketToken = "SM.StreamLabs.SocketToken"; public const string StreamElementsJwt = "SM.StreamElements.JWTToken"; public const string KoFiVerificationToken = "SM.KoFi.VerificationToken"; - + public const string GoAffProEmail = "SM.GoAffPro.Email"; public const string GoAffProPassword = "SM.GoAffPro.Password"; - + public const string OBSWebSocketPassword = "SM.OBS.WebSocket.Password"; + public const string VTubeStudioAuthToken = "SM.VTubeStudio.AuthToken"; + public const string TipeeeStreamAccessToken = "SM.TipeeeStream.AccessToken"; public const string TipeeeStreamRefreshToken = "SM.TipeeeStream.RefreshToken"; public const string TipeeeStreamApiKey = "SM.TipeeeStream.ApiKey"; diff --git a/SubathonManager.Core/Utils.cs b/SubathonManager.Core/Utils.cs index 1759e031..023483b0 100644 --- a/SubathonManager.Core/Utils.cs +++ b/SubathonManager.Core/Utils.cs @@ -1,9 +1,10 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; -using System.Text.RegularExpressions; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using SubathonManager.Core.Enums; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; @@ -11,24 +12,24 @@ namespace SubathonManager.Core; -public static class Utils -{ - +public static class Utils { public static readonly Dictionary DonationSettings = new(); - private static readonly ConcurrentDictionary<(SubathonEventSource Source, string Service), IntegrationConnection> ConnectionDetails = new(); - + + private static readonly ConcurrentDictionary<(SubathonEventSource Source, string Service), IntegrationConnection> + ConnectionDetails = new(); + public static string? PendingOverlayImportPath { get; set; } public static string? PendingWidgetPackImportPath { get; set; } public static OAuthCallback? PendingOAuthCallback { get; set; } - - public static IEnumerable GetAllConnections() - => ConnectionDetails.Values; - public static IntegrationConnection GetConnection(SubathonEventSource source, string service) - { - ConnectionDetails.TryGetValue((source, service), out var conn); + + public static IEnumerable GetAllConnections() { + return ConnectionDetails.Values; + } + + public static IntegrationConnection GetConnection(SubathonEventSource source, string service) { + ConnectionDetails.TryGetValue((source, service), out IntegrationConnection? conn); if (conn != null) return conn; - conn = new IntegrationConnection() - { + conn = new IntegrationConnection { Source = source, Service = service, Name = "", @@ -39,10 +40,9 @@ public static IntegrationConnection GetConnection(SubathonEventSource source, st return conn; } - - public static void UpdateConnection(IntegrationConnection connection) - { - var key = (connection.Source, connection.Service); + + public static void UpdateConnection(IntegrationConnection connection) { + (SubathonEventSource Source, string Service) key = (connection.Source, connection.Service); ConnectionDetails.AddOrUpdate( key, @@ -51,34 +51,32 @@ public static void UpdateConnection(IntegrationConnection connection) ); } - public static TimeSpan ParseDurationString(string input) - { + public static bool IsTruthy(string? value) { + return !string.IsNullOrWhiteSpace(value) && + value.Trim().ToLowerInvariant() is "1" or "true" or "yes" or "y"; + } + + public static TimeSpan ParseDurationString(string input) { if (string.IsNullOrWhiteSpace(input)) - { - Console.WriteLine($"Error - Invalid TimeString to Parse: {input}"); + // Console.WriteLine($"Error - Invalid TimeString to Parse: {input}"); return TimeSpan.Zero; - } input = input.Trim(); if (input.Contains(':')) return ParseColonDurationString(input); return ParseLetterDurationString(input); - } - private static TimeSpan ParseColonDurationString(string input) - { - var parts = input.Replace(".", ":").Split(":"); - int[] values = Array.ConvertAll(parts, p => - { - if (int.TryParse(p, out var v)) return v; + private static TimeSpan ParseColonDurationString(string input) { + string[] parts = input.Replace(".", ":").Split(":"); + int[] values = Array.ConvertAll(parts, p => { + if (int.TryParse(p, out int v)) return v; return 0; }); int days = 0, hours = 0, minutes = 0, seconds = 0; - - switch (values.Length) - { + + switch (values.Length) { case 4: days = values[0]; hours = values[1]; @@ -98,54 +96,40 @@ private static TimeSpan ParseColonDurationString(string input) seconds = values[0]; break; } + return new TimeSpan(days, hours, minutes, seconds); } - private static TimeSpan ParseLetterDurationString(string input) - { - if (input.All(char.IsDigit)) - { - return new TimeSpan(0, 0, 0, int.Parse(input)); - } + private static TimeSpan ParseLetterDurationString(string input) { + if (input.All(char.IsDigit)) return new TimeSpan(0, 0, 0, int.Parse(input)); var regex = new Regex(@"(\d+d|\d+h|\d+m|\d+s)", RegexOptions.IgnoreCase); - + int days = 0, hours = 0, minutes = 0, seconds = 0; - foreach(Match match in regex.Matches(input.ToLower())) - { + foreach (Match match in regex.Matches(input.ToLower())) { if (!match.Success) continue; if (match.ToString().ToLower().Contains("d")) - { days += int.Parse(match.ToString().Replace("d", "")); - } else if (match.ToString().ToLower().Contains("h")) - { hours += int.Parse(match.ToString().Replace("h", "")); - } else if (match.ToString().ToLower().Contains("m")) - { minutes += int.Parse(match.ToString().Replace("m", "")); - } - else if (match.ToString().ToLower().Contains("s")) - { - seconds += int.Parse(match.ToString().Replace("s", "")); - } + else if (match.ToString().ToLower().Contains("s")) seconds += int.Parse(match.ToString().Replace("s", "")); } - return new TimeSpan(days, hours, minutes, seconds); + + return new TimeSpan(days, hours, minutes, seconds); } - - public static Guid TryParseGuid(string? value) - { - if (value != null && Guid.TryParse(value, out var g)) return g; + + public static Guid TryParseGuid(string? value) { + if (value != null && Guid.TryParse(value, out Guid g)) return g; return CreateGuidFromUniqueString(value ?? Guid.NewGuid().ToString()); } - public static Guid CreateGuidFromUniqueString(string? key) - { + public static Guid CreateGuidFromUniqueString(string? key) { if (string.IsNullOrWhiteSpace(key)) return Guid.Empty; using var sha1 = SHA1.Create(); byte[] hash = sha1.ComputeHash(Encoding.UTF8.GetBytes(key)); - byte[] guidBytes = new byte[16]; + var guidBytes = new byte[16]; Array.Copy(hash, guidBytes, 16); guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | (5 << 4)); @@ -153,17 +137,15 @@ public static Guid CreateGuidFromUniqueString(string? key) return new Guid(guidBytes); } - - public static string TryParseCurrency(string amountString) - { - string currency = ""; - var match = Regex.Match(amountString, @"^(?[A-Z]{3})(?![A-Z])"); - if (match.Success) + + public static string TryParseCurrency(string amountString) { + var currency = ""; + Match match = Regex.Match(amountString, @"^(?[A-Z]{3})(?![A-Z])"); + if (match.Success) { currency = match.Groups["code"].Value; - else - { - if (amountString.Contains('$')) - { + } + else { + if (amountString.Contains('$')) { if (amountString.StartsWith("A$")) currency = "AUD"; else if (amountString.StartsWith("CA$")) currency = "CAD"; else if (amountString.StartsWith("R$")) currency = "BRL"; @@ -172,15 +154,13 @@ public static string TryParseCurrency(string amountString) else if (amountString.StartsWith("NT$")) currency = "TWD"; else if (amountString.StartsWith("NZ$")) currency = "NZD"; } - else if (amountString.Contains("₨")) - { + else if (amountString.Contains("₨")) { if (amountString.StartsWith("PK₨")) currency = "PKR"; else if (amountString.StartsWith("LK₨")) currency = "LKR"; else if (amountString.StartsWith("MU₨")) currency = "MUR"; else if (amountString.StartsWith("NP₨")) currency = "NPR"; } - else - { + else { if (amountString.StartsWith("₩")) currency = "KRW"; else if (amountString.StartsWith("₱")) currency = "PHP"; else if (amountString.StartsWith("₫")) currency = "VND"; @@ -198,80 +178,83 @@ public static string TryParseCurrency(string amountString) else if (amountString.StartsWith("₣")) currency = "CHF"; } } + return currency; } - - public static string EscapeCsv(string? value) - { + + public static string EscapeCsv(string? value) { if (string.IsNullOrWhiteSpace(value)) return ""; - if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) - { + if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) { value = value.Replace("\"", "\"\""); return $"\"{value}\""; } + return value; } - - public static (bool, double) GetAltCurrencyUseAsDonation(IConfig config, SubathonEventType? eventType) - { + + public static (bool, double) GetAltCurrencyUseAsDonation(IConfig config, SubathonEventType? eventType) { double modifier = 1; if (!eventType.IsToken()) return (false, 1); if (eventType != SubathonEventType.TwitchCheer && eventType != SubathonEventType.PicartoTip) - { double.TryParse(config.Get("Extensions", $"{eventType}.Modifier", "1"), out modifier); - } - bool useAsDonation = config.GetBool("Currency", "BitsLikeAsDonation", false); + bool useAsDonation = config.GetBool("Currency", "BitsLikeAsDonation"); return (useAsDonation, modifier); } - public static DateTime? GetAccessTokenExpiry(string accessToken) - { - try - { - var parts = accessToken.Split('.'); + public static DateTime? GetAccessTokenExpiry(string accessToken) { + try { + string[] parts = accessToken.Split('.'); if (parts.Length != 3) - return null; - var payload = parts[1]; + return null; + string payload = parts[1]; payload = payload.Replace('-', '+').Replace('_', '/'); - switch (payload.Length % 4) - { + switch (payload.Length % 4) { case 2: payload += "=="; break; case 3: payload += "="; break; } - var json = Encoding.UTF8.GetString(Convert.FromBase64String(payload)); - using var doc = JsonDocument.Parse(json); + string json = Encoding.UTF8.GetString(Convert.FromBase64String(payload)); + using JsonDocument doc = JsonDocument.Parse(json); - if (!doc.RootElement.TryGetProperty("exp", out var expProp)) - return null; + if (!doc.RootElement.TryGetProperty("exp", out JsonElement expProp)) + return null; - var expUnix = expProp.GetInt64(); + long expUnix = expProp.GetInt64(); return DateTimeOffset.FromUnixTimeSeconds(expUnix).UtcDateTime; } - catch - { + catch { return null; } } - - public sealed class ServiceReconnectState : IDisposable - { + + public static bool IsCommissionAsDonation(IConfig config, SubathonEvent ev) { + if (!ev.EventType.IsOrder()) return false; + + if (ev.EventType == SubathonEventType.GoAffProOrder) { + if (string.IsNullOrEmpty(ev.EventTypeMeta)) return false; + if (!GoAffProOrderHelper.TryGetStore(ev.EventTypeMeta, out GoAffProStore? store)) return false; + return config.GetBool("GoAffPro", $"{store.InternalName}.CommissionAsDonation"); + } + + return config.GetBool( + ev.EventType.GetSource().ToString(), + $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", + ev.EventType.GetSource() != SubathonEventSource.GoAffPro); + } + + public sealed class ServiceReconnectState : IDisposable { + public readonly SemaphoreSlim Lock = new(1, 1); public TimeSpan Backoff = TimeSpan.FromSeconds(2); - public int MaxRetries = 100; - public TimeSpan MaxBackoff = TimeSpan.FromMinutes(5); public CancellationTokenSource? Cts; - public readonly SemaphoreSlim Lock = new(1, 1); - public int Retries = 0; - public bool InfiniteRetries = false; - - private TimeSpan InitialBackOff { get; init; } - private TimeSpan InitialMaxBackOff { get; init; } - private int InitialMaxRetries { get; init; } - - public ServiceReconnectState(TimeSpan backoff, int maxRetries, TimeSpan maxBackoff, bool infiniteRetries = false) - { + public bool InfiniteRetries; + public TimeSpan MaxBackoff = TimeSpan.FromMinutes(5); + public int MaxRetries = 100; + public int Retries; + + public ServiceReconnectState(TimeSpan backoff, int maxRetries, TimeSpan maxBackoff, + bool infiniteRetries = false) { Backoff = backoff; MaxRetries = maxRetries; MaxBackoff = maxBackoff; @@ -280,35 +263,37 @@ public ServiceReconnectState(TimeSpan backoff, int maxRetries, TimeSpan maxBacko InitialMaxRetries = MaxRetries; InfiniteRetries = infiniteRetries; } - + public ServiceReconnectState() { InitialBackOff = Backoff; InitialMaxBackOff = MaxBackoff; InitialMaxRetries = MaxRetries; } - public async Task IsReconnecting() - { - return !(await Lock.WaitAsync(0)); + private TimeSpan InitialBackOff { get; } + private TimeSpan InitialMaxBackOff { get; } + private int InitialMaxRetries { get; } + + public void Dispose() { + Cts?.Cancel(); + Cts?.Dispose(); } - public void Reset() - { + public async Task IsReconnecting() { + return !await Lock.WaitAsync(0); + } + + public void Reset() { Backoff = InitialBackOff; MaxRetries = InitialMaxRetries; MaxBackoff = InitialMaxBackOff; Retries = 0; } - - public void Dispose() - { - Cts?.Cancel(); - Cts?.Dispose(); - } } - - public static class SingleInstanceHelper - { + + + [ExcludeFromCodeCoverage] + public static class SingleInstanceHelper { public const int WM_SHOWAPP = 0x0400 + 1; public const int WM_COPYDATA = 0x004A; @@ -318,24 +303,14 @@ public static extern bool PostMessage( int msg, IntPtr wparam, IntPtr lparam); - - [StructLayout(LayoutKind.Sequential)] - public struct COPYDATASTRUCT - { - public IntPtr dwData; - public int cbData; - public IntPtr lpData; - } - + [DllImport("user32")] public static extern IntPtr SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, ref COPYDATASTRUCT lParam); - public static void SendStringMessage(IntPtr hWnd, ProtocolMessageType type, string message) - { - var bytes = Encoding.Unicode.GetBytes(message); + public static void SendStringMessage(IntPtr hWnd, ProtocolMessageType type, string message) { + byte[] bytes = Encoding.Unicode.GetBytes(message); - var cds = new COPYDATASTRUCT - { + var cds = new COPYDATASTRUCT { cbData = bytes.Length, lpData = Marshal.AllocHGlobal(bytes.Length) }; @@ -346,22 +321,12 @@ public static void SendStringMessage(IntPtr hWnd, ProtocolMessageType type, stri Marshal.FreeHGlobal(cds.lpData); } - } - - public static bool IsCommissionAsDonation(IConfig config, SubathonEvent ev) - { - if (!ev.EventType.IsOrder()) return false; - if (ev.EventType == SubathonEventType.GoAffProOrder) - { - if (string.IsNullOrEmpty(ev.EventTypeMeta)) return false; - if (!GoAffProOrderHelper.TryGetStore(ev.EventTypeMeta, out var store)) return false; - return config.GetBool("GoAffPro", $"{store.InternalName}.CommissionAsDonation", false); + [StructLayout(LayoutKind.Sequential)] + public struct COPYDATASTRUCT { + public IntPtr dwData; + public int cbData; + public IntPtr lpData; } - - return config.GetBool( - ev.EventType.GetSource().ToString(), - $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", - ev.EventType.GetSource() != SubathonEventSource.GoAffPro); } } \ No newline at end of file diff --git a/SubathonManager.Data/AppDbContextFactory.cs b/SubathonManager.Data/AppDbContextFactory.cs index 2de6b323..fe3d1c1f 100644 --- a/SubathonManager.Data/AppDbContextFactory.cs +++ b/SubathonManager.Data/AppDbContextFactory.cs @@ -2,20 +2,17 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; -namespace SubathonManager.Data -{ - // to allow migrations add cmdline to work - [ExcludeFromCodeCoverage] - public class AppDbContextFactory - : IDesignTimeDbContextFactory - { - public AppDbContext CreateDbContext(string[] args) - { - var options = new DbContextOptionsBuilder(); +namespace SubathonManager.Data; - options.UseSqlite("Data Source=data/design.db"); +// to allow migrations add cmdline to work +[ExcludeFromCodeCoverage] +public class AppDbContextFactory + : IDesignTimeDbContextFactory { + public AppDbContext CreateDbContext(string[] args) { + var options = new DbContextOptionsBuilder(); - return new AppDbContext(options.Options); - } + options.UseSqlite("Data Source=data/design.db"); + + return new AppDbContext(options.Options); } -} +} \ No newline at end of file diff --git a/SubathonManager.Data/DbContext.cs b/SubathonManager.Data/DbContext.cs index d5cc5af1..0d2a56cc 100644 --- a/SubathonManager.Data/DbContext.cs +++ b/SubathonManager.Data/DbContext.cs @@ -1,666 +1,625 @@ -using Microsoft.EntityFrameworkCore; using System.Diagnostics; +using System.Numerics; using System.Text; -using SubathonManager.Core.Models; -using SubathonManager.Core.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; + // ReSharper disable NullableWarningSuppressionIsUsed -namespace SubathonManager.Data -{ - public class AppDbContext : DbContext - { - public DbSet Routes => Set(); - public DbSet Widgets => Set(); - - public DbSet CssVariables => Set(); - public DbSet JsVariables => Set(); - - public DbSet SubathonEvents { get; set; } - public DbSet SubathonValues { get; set; } - - public DbSet SubathonDatas { get; set; } - public DbSet MultiplierDatas { get; set; } - - public DbSet SubathonGoals { get; set; } - public DbSet SubathonGoalSets { get; set; } - - public DbSet SubathonPromptSets { get; set; } - public DbSet SubathonPrompts { get; set; } - public DbSet SubathonPromptRuns { get; set; } - - public DbSet WheelSets { get; set; } - public DbSet WheelItems { get; set; } - public DbSet WheelSpinActions { get; set; } - public DbSet WheelSpinHistories { get; set; } - public DbSet WheelSpinTriggers { get; set; } - public DbSet WheelSpinTriggerHistories { get; set; } - - public DbSet GoAffProStores { get; set; } - public DbSet MakeShipTrackings { get; set; } - public DbSet JuniperStores { get; set; } - public DbSet JuniperProducts { get; set; } - - public DbSet StateValues { get; set; } - - public DbSet WidgetCatalogEntries => Set(); - - public AppDbContext(DbContextOptions options) - : base(options) - { - } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - if (!optionsBuilder.IsConfigured) // allows migrations to be added still in dev - { - var dbPath = Config.DatabasePath; - Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); - optionsBuilder.UseSqlite($"Data Source={dbPath}"); - } - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity() - .HasMany(r => r.Widgets) - .WithOne(w => w.Route) - .HasForeignKey(w => w.RouteId) - .OnDelete(DeleteBehavior.Cascade); // deleting route deletes its widgets - - modelBuilder.Entity() - .HasMany(w => w.CssVariables) - .WithOne(cv => cv.Widget) - .HasForeignKey(cv => cv.WidgetId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasMany(w => w.JsVariables) - .WithOne(cv => cv.Widget) - .HasForeignKey(cv => cv.WidgetId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasKey(e => new { e.Id, e.Source }); - - modelBuilder.Entity() - .HasOne(e => e.LinkedSubathon) - .WithMany() - .HasForeignKey(e => e.SubathonId) - .IsRequired(false); - - modelBuilder.Entity() - .HasKey(sv => new { sv.EventType, sv.Meta }); - - modelBuilder.Entity() - .HasMany(s => s.Goals) - .WithOne(g => g.LinkedGoalSet) - .HasForeignKey(g => g.GoalSetId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity().HasOne(s => s.Multiplier) - .WithOne(m => m.LinkedSubathon).HasForeignKey(m => m.SubathonId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasMany(s => s.Prompts) - .WithOne(p => p.LinkedSet) - .HasForeignKey(p => p.SetId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(r => r.LinkedPrompt) - .WithMany() - .HasForeignKey(r => r.PromptId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(r => r.LinkedSet) - .WithMany() - .HasForeignKey(r => r.SetId) - .OnDelete(DeleteBehavior.NoAction); - - modelBuilder.Entity() - .Property(s => s.Interval) - .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); - - modelBuilder.Entity() - .Property(s => s.RandomOffset) - .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); - - modelBuilder.Entity() - .Property(s => s.Cooldown) - .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); - - modelBuilder.Entity() - .Property(p => p.CompletionDuration) - .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); - - modelBuilder.Entity() - .HasMany(w => w.WheelItems) - .WithOne(i => i.LinkedWheel) - .HasForeignKey(i => i.WheelId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(i => i.Action) - .WithOne(a => a.LinkedItem) - .HasForeignKey(a => a.WheelItemId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(h => h.LinkedItem) - .WithMany() - .HasForeignKey(h => h.WheelItemId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(h => h.LinkedWheel) - .WithMany() - .HasForeignKey(h => h.WheelId) - .OnDelete(DeleteBehavior.NoAction); - - modelBuilder.Entity() - .HasMany(t => t.History) - .WithOne(h => h.Trigger) - .HasForeignKey(h => h.TriggerId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasKey(e => new { e.RowId }); - modelBuilder.Entity() - .HasIndex(s => new { s.SiteId }) - .IsUnique(); - - modelBuilder.Entity() - .HasIndex(e => e.PackPath) - .IsUnique(); - - modelBuilder.Entity() - .Property(p => p.ProductId) - .HasConversion(v => v.ToString(), v => System.Numerics.BigInteger.Parse(v)) - .ValueGeneratedNever(); - modelBuilder.Entity() - .HasOne(p => p.Store) - .WithMany(s => s.Products) - .HasForeignKey(p => p.StoreId) - .OnDelete(DeleteBehavior.Cascade); - } +namespace SubathonManager.Data; - public override int SaveChanges() - { - UpdateTimestamps(); - return base.SaveChanges(); - } +public class AppDbContext : DbContext { + public AppDbContext(DbContextOptions options) + : base(options) { + } + + public DbSet Routes => Set(); + public DbSet Widgets => Set(); + + public DbSet CssVariables => Set(); + public DbSet JsVariables => Set(); + + public DbSet SubathonEvents { get; set; } + public DbSet SubathonValues { get; set; } + + public DbSet SubathonDatas { get; set; } + public DbSet MultiplierDatas { get; set; } + + public DbSet SubathonGoals { get; set; } + public DbSet SubathonGoalSets { get; set; } - public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + public DbSet SubathonPromptSets { get; set; } + public DbSet SubathonPrompts { get; set; } + public DbSet SubathonPromptRuns { get; set; } + + public DbSet WheelSets { get; set; } + public DbSet WheelItems { get; set; } + public DbSet WheelSpinActions { get; set; } + public DbSet WheelSpinHistories { get; set; } + public DbSet WheelSpinTriggers { get; set; } + public DbSet WheelSpinTriggerHistories { get; set; } + + public DbSet GoAffProStores { get; set; } + public DbSet MakeShipTrackings { get; set; } + public DbSet JuniperStores { get; set; } + public DbSet JuniperProducts { get; set; } + + public DbSet StateValues { get; set; } + + public DbSet WidgetCatalogEntries => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { + if (!optionsBuilder.IsConfigured) // allows migrations to be added still in dev { - UpdateTimestamps(); - return await base.SaveChangesAsync(cancellationToken); + string dbPath = Config.DatabasePath; + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + optionsBuilder.UseAppSqlite(dbPath); } + } - private void UpdateTimestamps() - { - var now = DateTime.UtcNow; - - foreach (var entry in ChangeTracker.Entries()) - { - if (entry.State == EntityState.Added) - { - entry.Entity.CreatedTimestamp = now; - entry.Entity.UpdatedTimestamp = now; - } - else if (entry.State == EntityState.Modified) - { - entry.Entity.UpdatedTimestamp = now; - } + protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity() + .HasMany(r => r.Widgets) + .WithOne(w => w.Route) + .HasForeignKey(w => w.RouteId) + .OnDelete(DeleteBehavior.Cascade); // deleting route deletes its widgets + + modelBuilder.Entity() + .HasMany(w => w.CssVariables) + .WithOne(cv => cv.Widget) + .HasForeignKey(cv => cv.WidgetId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(w => w.JsVariables) + .WithOne(cv => cv.Widget) + .HasForeignKey(cv => cv.WidgetId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasKey(e => new { e.Id, e.Source }); + + modelBuilder.Entity() + .HasOne(e => e.LinkedSubathon) + .WithMany() + .HasForeignKey(e => e.SubathonId) + .IsRequired(false); + + modelBuilder.Entity() + .HasKey(sv => new { sv.EventType, sv.Meta }); + + modelBuilder.Entity() + .HasMany(s => s.Goals) + .WithOne(g => g.LinkedGoalSet) + .HasForeignKey(g => g.GoalSetId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity().HasOne(s => s.Multiplier) + .WithOne(m => m.LinkedSubathon).HasForeignKey(m => m.SubathonId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(s => s.Prompts) + .WithOne(p => p.LinkedSet) + .HasForeignKey(p => p.SetId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(r => r.LinkedPrompt) + .WithMany() + .HasForeignKey(r => r.PromptId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(r => r.LinkedSet) + .WithMany() + .HasForeignKey(r => r.SetId) + .OnDelete(DeleteBehavior.NoAction); + + modelBuilder.Entity() + .Property(s => s.Interval) + .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); + + modelBuilder.Entity() + .Property(s => s.RandomOffset) + .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); + + modelBuilder.Entity() + .Property(s => s.Cooldown) + .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); + + modelBuilder.Entity() + .Property(p => p.CompletionDuration) + .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); + + modelBuilder.Entity() + .HasMany(w => w.WheelItems) + .WithOne(i => i.LinkedWheel) + .HasForeignKey(i => i.WheelId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(i => i.Action) + .WithOne(a => a.LinkedItem) + .HasForeignKey(a => a.WheelItemId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(h => h.LinkedItem) + .WithMany() + .HasForeignKey(h => h.WheelItemId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(h => h.LinkedWheel) + .WithMany() + .HasForeignKey(h => h.WheelId) + .OnDelete(DeleteBehavior.NoAction); + + modelBuilder.Entity() + .HasMany(t => t.History) + .WithOne(h => h.Trigger) + .HasForeignKey(h => h.TriggerId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasKey(e => new { e.RowId }); + modelBuilder.Entity() + .HasIndex(s => new { s.SiteId }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(e => e.PackPath) + .IsUnique(); + + modelBuilder.Entity() + .Property(p => p.ProductId) + .HasConversion(v => v.ToString(), v => BigInteger.Parse(v)) + .ValueGeneratedNever(); + modelBuilder.Entity() + .HasOne(p => p.Store) + .WithMany(s => s.Products) + .HasForeignKey(p => p.StoreId) + .OnDelete(DeleteBehavior.Cascade); + } + + public override int SaveChanges() { + UpdateTimestamps(); + return base.SaveChanges(); + } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) { + UpdateTimestamps(); + return await base.SaveChangesAsync(cancellationToken); + } + + private void UpdateTimestamps() { + DateTime now = DateTime.UtcNow; + + foreach (EntityEntry entry in ChangeTracker.Entries()) + if (entry.State == EntityState.Added) { + entry.Entity.CreatedTimestamp = now; + entry.Entity.UpdatedTimestamp = now; + } + else if (entry.State == EntityState.Modified) { + entry.Entity.UpdatedTimestamp = now; } - // If a widget or cssvariable/jsvariable changes, update its parent route’s timestamp - foreach (var entry in ChangeTracker.Entries()) - { - if (entry.State == EntityState.Added || entry.State == EntityState.Modified || - entry.State == EntityState.Deleted) - { - var route = Routes.FirstOrDefault(r => r.Id == entry.Entity.RouteId); + // If a widget or cssvariable/jsvariable changes, update its parent route’s timestamp + foreach (EntityEntry entry in ChangeTracker.Entries()) + if (entry.State == EntityState.Added || entry.State == EntityState.Modified || + entry.State == EntityState.Deleted) { + Route? route = Routes.FirstOrDefault(r => r.Id == entry.Entity.RouteId); + if (route != null) + route.UpdatedTimestamp = now; + } + + foreach (EntityEntry entry in ChangeTracker.Entries()) + if (entry.State == EntityState.Added || entry.State == EntityState.Modified || + entry.State == EntityState.Deleted) { + Widget? widget = Widgets.FirstOrDefault(w => w.Id == entry.Entity.WidgetId); + if (widget != null) { + Route? route = Routes.FirstOrDefault(r => r.Id == widget.RouteId); if (route != null) route.UpdatedTimestamp = now; } } - foreach (var entry in ChangeTracker.Entries()) - { - if (entry.State == EntityState.Added || entry.State == EntityState.Modified || - entry.State == EntityState.Deleted) - { - var widget = Widgets.FirstOrDefault(w => w.Id == entry.Entity.WidgetId); - if (widget != null) - { - var route = Routes.FirstOrDefault(r => r.Id == widget.RouteId); - if (route != null) - route.UpdatedTimestamp = now; - } - } - } - - foreach (var entry in ChangeTracker.Entries()) - { - if (entry.State == EntityState.Added || entry.State == EntityState.Modified || - entry.State == EntityState.Deleted) - { - var widget = Widgets.FirstOrDefault(w => w.Id == entry.Entity.WidgetId); - if (widget != null) - { - var route = Routes.FirstOrDefault(r => r.Id == widget.RouteId); - if (route != null) - route.UpdatedTimestamp = now; - } + foreach (EntityEntry entry in ChangeTracker.Entries()) + if (entry.State == EntityState.Added || entry.State == EntityState.Modified || + entry.State == EntityState.Deleted) { + Widget? widget = Widgets.FirstOrDefault(w => w.Id == entry.Entity.WidgetId); + if (widget != null) { + Route? route = Routes.FirstOrDefault(r => r.Id == widget.RouteId); + if (route != null) + route.UpdatedTimestamp = now; } } - } - - public static async Task> GetSubathonCurrencyEvents(AppDbContext db) - { - SubathonData? subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); - if (subathon == null) return new List(); - - List events = await db.SubathonEvents.AsNoTracking().Where(e => - e.SubathonId == subathon.Id && e.ProcessedToSubathon && - !string.IsNullOrWhiteSpace(e.Currency) - && e.EventType != SubathonEventType.Command && !string.IsNullOrWhiteSpace(e.Value)) - .ToListAsync(); - - bool includeBits = Utils.DonationSettings.TryGetValue("BitsLikeAsDonation", out bool bitslike) && bitslike ; - List orderTypesToInclude = new List(); - foreach (var orderEvent in Enum.GetValues().Where(et => - ((SubathonEventType?)et).IsOrder() && !et.IsDisabled() - && et.GetSource() != SubathonEventSource.GoAffPro)) - { - bool asDonation = Utils.DonationSettings.TryGetValue($"{orderEvent.ToString()?.Split("Order")[0]}", out bool donation) && donation; - if (asDonation) - orderTypesToInclude.Add(orderEvent); - } + } - // GoAffPro is per store: commission-as-donation keyed by store InternalName, matched on event meta (SiteId) - HashSet goAffProMetasToInclude = GoAffProStoreRegistry.All() - .Where(s => Utils.DonationSettings.TryGetValue(s.InternalName, out bool donation) && donation) - .Select(s => s.SiteId.ToString()) - .ToHashSet(); - - events = events.Where(e => e.EventType != null && - (e.EventType.IsCurrencyDonation() || - (e.EventType == SubathonEventType.GoAffProOrder && - !string.IsNullOrEmpty(e.EventTypeMeta) && goAffProMetasToInclude.Contains(e.EventTypeMeta)) || - (e.EventType.IsOrder() && e.EventType != SubathonEventType.GoAffProOrder && - orderTypesToInclude.Contains((SubathonEventType)e.EventType)) || - (includeBits && e.EventType.IsToken()))).ToList(); - return events; + public static async Task> GetSubathonCurrencyEvents(AppDbContext db) { + SubathonData? subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); + if (subathon == null) return new List(); + + List events = await db.SubathonEvents.AsNoTracking().Where(e => + e.SubathonId == subathon.Id && e.ProcessedToSubathon && + !string.IsNullOrWhiteSpace(e.Currency) + && e.EventType != SubathonEventType.Command && !string.IsNullOrWhiteSpace(e.Value)) + .ToListAsync(); + + bool includeBits = Utils.DonationSettings.TryGetValue("BitsLikeAsDonation", out bool bitslike) && bitslike; + var orderTypesToInclude = new List(); + foreach (SubathonEventType orderEvent in Enum.GetValues().Where(et => + ((SubathonEventType?)et).IsOrder() && !et.IsDisabled() + && et.GetSource() != SubathonEventSource.GoAffPro)) { + bool asDonation = + Utils.DonationSettings.TryGetValue($"{orderEvent.ToString()?.Split("Order")[0]}", out bool donation) && + donation; + if (asDonation) + orderTypesToInclude.Add(orderEvent); } - public static async Task ActiveEventsToCsv(AppDbContext db) - { - SubathonData? subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); - if (subathon == null) return; - List events = await db.SubathonEvents.Where(ev => ev.SubathonId == subathon.Id) - .ToListAsync(); - - string exportDir = Path.Combine(Config.DataFolder, "exports"); - Directory.CreateDirectory(exportDir); - string filepath = $"{exportDir}/subathon-{subathon.Id}.csv"; - - var sb = new StringBuilder(); - sb.AppendLine("Id,Source,Type,Command,User,Seconds Value,Points Value,Value,Currency,Amount,Multiplier Seconds,Multiplier Points,Processed,Final Seconds Added,Final Points Added,Timestamp,Secondary Value,Event Meta Type, Event Meta Common Type"); - foreach (var e in events) - { - var val = e.Value; - var commonMeta = string.IsNullOrWhiteSpace(e.EventTypeMeta) ? "" : e.EventTypeMeta; - if (e.EventType is SubathonEventType.TwitchGiftSub or SubathonEventType.TwitchSub) - { - val = e.Value switch - { - "1000" => "T1", - "2000" => "T2", - "3000" => "T3", - _ => val - }; - commonMeta = val; - } - - if (e.EventType == SubathonEventType.GoAffProOrder - && GoAffProOrderHelper.TryGetStore(e.EventTypeMeta, out var store)) - { - commonMeta = store.InternalName; - } + // GoAffPro is per store: commission-as-donation keyed by store InternalName, matched on event meta (SiteId) + HashSet goAffProMetasToInclude = GoAffProStoreRegistry.All() + .Where(s => Utils.DonationSettings.TryGetValue(s.InternalName, out bool donation) && donation) + .Select(s => s.SiteId.ToString()) + .ToHashSet(); + + events = events.Where(e => e.EventType != null && + (e.EventType.IsCurrencyDonation() || + (e.EventType == SubathonEventType.GoAffProOrder && + !string.IsNullOrEmpty(e.EventTypeMeta) && + goAffProMetasToInclude.Contains(e.EventTypeMeta)) || + (e.EventType.IsOrder() && e.EventType != SubathonEventType.GoAffProOrder && + orderTypesToInclude.Contains((SubathonEventType)e.EventType)) || + (includeBits && e.EventType.IsToken()))).ToList(); + return events; + } - sb.AppendLine(string.Join(",", - e.Id, - Utils.EscapeCsv(e.Source.ToString()), - Utils.EscapeCsv(e.EventType.ToString()), - Utils.EscapeCsv(e.Command.ToString()), - Utils.EscapeCsv(e.User), - e.SecondsValue, - e.PointsValue, - Utils.EscapeCsv(val), - Utils.EscapeCsv(e.Currency), - e.Amount, - e.MultiplierSeconds, - e.MultiplierPoints, - e.ProcessedToSubathon, - e.GetFinalSecondsValueRaw(), - e.GetFinalPointsValue(), - e.EventTimestamp.ToString("yyyy-MM-dd HH:mm:ss"), - Utils.EscapeCsv(e.SecondaryValue), - Utils.EscapeCsv(e.EventTypeMeta), - Utils.EscapeCsv(commonMeta) - )); - } - await File.WriteAllTextAsync(filepath, sb.ToString(), Encoding.UTF8); - - try - { - bool isTest = - AppDomain.CurrentDomain.GetAssemblies() - .Any(a => a.FullName!.StartsWith("xunit", StringComparison.OrdinalIgnoreCase)); - if (!isTest) - { - Process.Start(new ProcessStartInfo - { - FileName = exportDir, - UseShellExecute = true, - Verb = "open" - }); - } + public static async Task ActiveEventsToCsv(AppDbContext db) { + SubathonData? subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); + if (subathon == null) return; + List events = await db.SubathonEvents.Where(ev => ev.SubathonId == subathon.Id) + .ToListAsync(); + + string exportDir = Path.Combine(Config.DataFolder, "exports"); + Directory.CreateDirectory(exportDir); + var filepath = $"{exportDir}/subathon-{subathon.Id}.csv"; + + var sb = new StringBuilder(); + sb.AppendLine( + "Id,Source,Type,Command,User,Seconds Value,Points Value,Value,Currency,Amount,Multiplier Seconds,Multiplier Points,Processed,Final Seconds Added,Final Points Added,Timestamp,Secondary Value,Event Meta Type, Event Meta Common Type"); + foreach (SubathonEvent e in events) { + string val = e.Value; + string? commonMeta = string.IsNullOrWhiteSpace(e.EventTypeMeta) ? "" : e.EventTypeMeta; + if (e.EventType is SubathonEventType.TwitchGiftSub or SubathonEventType.TwitchSub) { + val = e.Value switch { + "1000" => "T1", + "2000" => "T2", + "3000" => "T3", + _ => val + }; + commonMeta = val; } - catch {/**/} - } - public static async Task PauseAllTimers(AppDbContext db) - { - await db.Database.ExecuteSqlRawAsync("UPDATE SubathonDatas SET IsPaused = 1"); + if (e.EventType == SubathonEventType.GoAffProOrder + && GoAffProOrderHelper.TryGetStore(e.EventTypeMeta, out GoAffProStore? store)) + commonMeta = store.InternalName; + + sb.AppendLine(string.Join(",", + e.Id, + Utils.EscapeCsv(e.Source.ToString()), + Utils.EscapeCsv(e.EventType.ToString()), + Utils.EscapeCsv(e.Command.ToString()), + Utils.EscapeCsv(e.User), + e.SecondsValue, + e.PointsValue, + Utils.EscapeCsv(val), + Utils.EscapeCsv(e.Currency), + e.Amount, + e.MultiplierSeconds, + e.MultiplierPoints, + e.ProcessedToSubathon, + e.GetFinalSecondsValueRaw(), + e.GetFinalPointsValue(), + e.EventTimestamp.ToString("yyyy-MM-dd HH:mm:ss"), + Utils.EscapeCsv(e.SecondaryValue), + Utils.EscapeCsv(e.EventTypeMeta), + Utils.EscapeCsv(commonMeta) + )); } - public static async Task ResetPowerHour(AppDbContext db) - { - await db.Database.ExecuteSqlRawAsync("UPDATE MultiplierDatas SET Multiplier = 1, Duration = null, ApplyToSeconds=false, ApplyToPoints=false, FromHypeTrain=false "); + await File.WriteAllTextAsync(filepath, sb.ToString(), Encoding.UTF8); + + try { + bool isTest = + AppDomain.CurrentDomain.GetAssemblies() + .Any(a => a.FullName!.StartsWith("xunit", StringComparison.OrdinalIgnoreCase)); + if (!isTest) + Process.Start(new ProcessStartInfo { + FileName = exportDir, + UseShellExecute = true, + Verb = "open" + }); } - - public static async Task DisableAllTimers(AppDbContext db) - { - await db.Database.ExecuteSqlRawAsync("UPDATE SubathonDatas SET IsActive = 0"); - await PauseAllTimers(db); + catch { + /**/ } + } - public static async Task UpdateSubathonCurrency(AppDbContext db, string currency) - { - await db.Database.ExecuteSqlRawAsync("UPDATE SubathonDatas SET Currency = {0} WHERE IsActive = 1", currency); + public static async Task PauseAllTimers(AppDbContext db) { + await db.Database.ExecuteSqlRawAsync("UPDATE SubathonDatas SET IsPaused = 1"); + } + + public static async Task ResetPowerHour(AppDbContext db) { + await db.Database.ExecuteSqlRawAsync( + "UPDATE MultiplierDatas SET Multiplier = 1, Duration = null, ApplyToSeconds=false, ApplyToPoints=false, FromHypeTrain=false "); + } + + public static async Task DisableAllTimers(AppDbContext db) { + await db.Database.ExecuteSqlRawAsync("UPDATE SubathonDatas SET IsActive = 0"); + await PauseAllTimers(db); + } + + public static async Task UpdateSubathonCurrency(AppDbContext db, string currency) { + await db.Database.ExecuteSqlRawAsync("UPDATE SubathonDatas SET Currency = {0} WHERE IsActive = 1", currency); + } + + public async Task UpdateSubathonMoney(double money, Guid subathonId) { + await Database.ExecuteSqlRawAsync( + "UPDATE SubathonDatas SET MoneySum = {0} WHERE IsActive = 1 and Id = {1}", money, subathonId); + SubathonData? subathon = await SubathonDatas.Include(s => s.Multiplier) + .AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); + if (subathon == null) return; + Entry(subathon).State = EntityState.Detached; + Entry(subathon.Multiplier).State = EntityState.Detached; + Core.Events.SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); + + SubathonGoalSet? goalSet = await SubathonGoalSets.AsNoTracking() + .Include(x => x.Goals).Where(x => x.IsActive) + .FirstOrDefaultAsync(); + if (goalSet != null && goalSet.Goals.Any() && goalSet.Type == GoalsType.Money) + Core.Events.SubathonEvents.RaiseSubathonGoalListUpdated(goalSet.Goals, + subathon.GetRoundedMoneySum(), GoalsType.Money); + } + + public static void SeedDefaultValues(AppDbContext db) { + db.Database.ExecuteSqlRaw( + "UPDATE SubathonValues SET Meta = 'DEFAULT' WHERE Meta = '' AND EventType IN ({0}, {1})", + (int)SubathonEventType.MakeShipPledge, (int)SubathonEventType.MakeShipSale); + + var defaults = new List { + new() { EventType = SubathonEventType.TwitchSub, Meta = "1000", Seconds = 60, Points = 1 }, + new() { EventType = SubathonEventType.TwitchSub, Meta = "2000", Seconds = 120, Points = 2 }, + new() { EventType = SubathonEventType.TwitchSub, Meta = "3000", Seconds = 300, Points = 5 }, + new() { EventType = SubathonEventType.TwitchGiftSub, Meta = "1000", Seconds = 60, Points = 1 }, + new() { EventType = SubathonEventType.TwitchGiftSub, Meta = "2000", Seconds = 120, Points = 2 }, + new() { EventType = SubathonEventType.TwitchGiftSub, Meta = "3000", Seconds = 300, Points = 5 }, + new() { EventType = SubathonEventType.TwitchCheer, Seconds = 0.12 }, + new() { EventType = SubathonEventType.TwitchFollow, Seconds = 0 }, + new() { EventType = SubathonEventType.TwitchRaid, Seconds = 0 }, + new() { + EventType = SubathonEventType.StreamElementsDonation, Seconds = 12 + }, // per 1 unit/dollar of default currency + new() { + EventType = SubathonEventType.StreamLabsDonation, Seconds = 12 + }, // per 1 unit/dollar of default currency + new() { + EventType = SubathonEventType.YouTubeSuperChat, Seconds = 12 + }, // per 1 unit/dollar of default currency + new() { EventType = SubathonEventType.YouTubeMembership, Meta = "DEFAULT", Seconds = 60, Points = 1 }, + new() { EventType = SubathonEventType.YouTubeGiftMembership, Meta = "DEFAULT", Seconds = 60, Points = 1 }, + new() { + EventType = SubathonEventType.TwitchCharityDonation, Seconds = 12 + }, // per 1 unit/dollar of default currency + new() { + EventType = SubathonEventType.ExternalDonation, Seconds = 12 + }, // per 1 unit/dollar of default currency + new() { EventType = SubathonEventType.KoFiDonation, Seconds = 12 }, // per 1 unit/dollar of default currency + new() { EventType = SubathonEventType.KoFiSub, Meta = "DEFAULT", Seconds = 60, Points = 1 }, + new() { + EventType = SubathonEventType.KoFiShopOrder, Seconds = 12 + }, // per 1 unit/dollar of default currency + new() { + EventType = SubathonEventType.KoFiCommissionOrder, Seconds = 12 + }, // per 1 unit/dollar of default currency + new() { EventType = SubathonEventType.BlerpBeets, Seconds = 0.12 }, + new() { EventType = SubathonEventType.BlerpBits, Seconds = 0.12 }, + new() { EventType = SubathonEventType.PicartoSub, Meta = "T1", Seconds = 60, Points = 1 }, + new() { EventType = SubathonEventType.PicartoSub, Meta = "T2", Seconds = 120, Points = 2 }, + new() { EventType = SubathonEventType.PicartoSub, Meta = "T3", Seconds = 180, Points = 3 }, + new() { + EventType = SubathonEventType.PicartoGiftSub, Meta = "T1", Seconds = 60, Points = 1 + }, // you can only gift T1 + // PicartoMonth subs, treat as amount? = months * quantity = amount + new() { EventType = SubathonEventType.PicartoTip, Seconds = 0.12 }, + new() { EventType = SubathonEventType.PicartoFollow, Seconds = 0 }, + // assuming defaults for order types are by Dollar, we set at 12s + new() { EventType = SubathonEventType.ExternalSub, Meta = "DEFAULT", Seconds = 60, Points = 1 }, + new() { EventType = SubathonEventType.YouTubeRedirect, Seconds = 0 }, + new() { EventType = SubathonEventType.FourthWallDonation, Seconds = 12 }, + new() { EventType = SubathonEventType.FourthWallMembership, Meta = "DEFAULT", Seconds = 60, Points = 1 }, + new() { EventType = SubathonEventType.FourthWallGiftOrder, Seconds = 12 }, + new() { EventType = SubathonEventType.FourthWallOrder, Seconds = 12 }, + new() { EventType = SubathonEventType.ThroneGiftContribution, Seconds = 12 }, + new() { EventType = SubathonEventType.ThroneGiftPurchase, Seconds = 12 }, + // GoAffPro store order values are seeded per store (Meta = SiteId) in SeedKnownGoAffProStores + new() { EventType = SubathonEventType.TipeeeStreamDonation, Seconds = 12 }, + new() { EventType = SubathonEventType.TangiaTokens, Seconds = 0.12 }, + new() { EventType = SubathonEventType.PallyGGDonation, Seconds = 12 }, // per 1 USD, Pally is USD only + new() { EventType = SubathonEventType.TreatStreamOrder, Seconds = 600 }, // per treat, always 1 item + new() { + EventType = SubathonEventType.MakeShipPledge, Meta = "DEFAULT", Seconds = 60 + }, // always items mode, per pledge; overridable per tracked item (Meta = tracking name) + new() { + EventType = SubathonEventType.MakeShipSale, Meta = "DEFAULT", Seconds = 300 + }, // always items mode, per order; overridable per tracked item (Meta = tracking name) + new() { + EventType = SubathonEventType.JuniperMerchSale, Meta = "DEFAULT", Seconds = 300 + } // always items mode. meta = product id number, which links back to a store + }; + + foreach (SubathonValue def in defaults) + // only add if not exists + if (!db.SubathonValues.Any(sv => sv.EventType == def.EventType && sv.Meta == def.Meta)) + db.SubathonValues.Add(def); + + if (!db.SubathonDatas.Any(s => s.IsActive)) { + var subathon = new SubathonData(); + TimeSpan initialMs = TimeSpan.FromHours(8); + subathon.MillisecondsCumulative += (int)initialMs.TotalMilliseconds; + subathon.IsPaused = true; + db.SubathonDatas.Add(subathon); } - - public async Task UpdateSubathonMoney(double money, Guid subathonId) - { - await Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET MoneySum = {0} WHERE IsActive = 1 and Id = {1}", money, subathonId); - var subathon = await SubathonDatas.Include(s=> s.Multiplier) - .AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); - if (subathon == null) return; - Entry(subathon).State = EntityState.Detached; - Entry(subathon.Multiplier).State = EntityState.Detached; - Core.Events.SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); - - var goalSet = await SubathonGoalSets.AsNoTracking() - .Include(x => x.Goals).Where(x => x.IsActive) - .FirstOrDefaultAsync(); - if (goalSet != null && goalSet.Goals.Any() && goalSet.Type == GoalsType.Money) - { - Core.Events.SubathonEvents.RaiseSubathonGoalListUpdated(goalSet.Goals, - subathon.GetRoundedMoneySum(), GoalsType.Money); - } + + if (!db.SubathonGoalSets.Any(s => s.IsActive)) { + var goalSet = new SubathonGoalSet(); + db.SubathonGoalSets.Add(goalSet); } - - public static void SeedDefaultValues(AppDbContext db) - { - db.Database.ExecuteSqlRaw( - "UPDATE SubathonValues SET Meta = 'DEFAULT' WHERE Meta = '' AND EventType IN ({0}, {1})", - (int)SubathonEventType.MakeShipPledge, (int)SubathonEventType.MakeShipSale); - - var defaults = new List - { - new () { EventType = SubathonEventType.TwitchSub, Meta = "1000", Seconds = 60, Points = 1 }, - new () { EventType = SubathonEventType.TwitchSub, Meta = "2000", Seconds = 120, Points = 2 }, - new () { EventType = SubathonEventType.TwitchSub, Meta = "3000", Seconds = 300, Points = 5 }, - new () { EventType = SubathonEventType.TwitchGiftSub, Meta = "1000", Seconds = 60, Points = 1 }, - new () { EventType = SubathonEventType.TwitchGiftSub, Meta = "2000", Seconds = 120, Points = 2 }, - new () { EventType = SubathonEventType.TwitchGiftSub, Meta = "3000", Seconds = 300, Points = 5 }, - new () { EventType = SubathonEventType.TwitchCheer, Seconds = 0.12 }, - new () { EventType = SubathonEventType.TwitchFollow, Seconds = 0 }, - new () { EventType = SubathonEventType.TwitchRaid, Seconds = 0 }, - new () { EventType = SubathonEventType.StreamElementsDonation, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.StreamLabsDonation, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.YouTubeSuperChat, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.YouTubeMembership, Meta = "DEFAULT", Seconds = 60, Points = 1}, - new () { EventType = SubathonEventType.YouTubeGiftMembership, Meta = "DEFAULT", Seconds = 60, Points = 1}, - new () { EventType = SubathonEventType.TwitchCharityDonation, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.ExternalDonation, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.KoFiDonation, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.KoFiSub, Meta = "DEFAULT", Seconds = 60, Points = 1}, - new () { EventType = SubathonEventType.KoFiShopOrder, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.KoFiCommissionOrder, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.BlerpBeets, Seconds = 0.12 }, - new () { EventType = SubathonEventType.BlerpBits, Seconds = 0.12 }, - new () { EventType = SubathonEventType.PicartoSub, Meta = "T1", Seconds = 60, Points = 1 }, - new () { EventType = SubathonEventType.PicartoSub, Meta = "T2", Seconds = 120, Points = 2 }, - new () { EventType = SubathonEventType.PicartoSub, Meta = "T3", Seconds = 180, Points = 3 }, - new () { EventType = SubathonEventType.PicartoGiftSub, Meta = "T1", Seconds = 60, Points = 1 }, // you can only gift T1 - // PicartoMonth subs, treat as amount? = months * quantity = amount - new () { EventType = SubathonEventType.PicartoTip, Seconds = 0.12 }, - new () { EventType = SubathonEventType.PicartoFollow, Seconds = 0 }, - // assuming defaults for order types are by Dollar, we set at 12s - new () { EventType = SubathonEventType.ExternalSub, Meta = "DEFAULT", Seconds = 60, Points = 1}, - new () { EventType = SubathonEventType.YouTubeRedirect, Seconds = 0 }, - new () { EventType = SubathonEventType.FourthWallDonation, Seconds = 12}, - new () { EventType = SubathonEventType.FourthWallMembership, Meta = "DEFAULT", Seconds = 60, Points = 1}, - new () { EventType = SubathonEventType.FourthWallGiftOrder, Seconds = 12}, - new () { EventType = SubathonEventType.FourthWallOrder, Seconds = 12}, - new () { EventType = SubathonEventType.ThroneGiftContribution, Seconds = 12}, - new () { EventType = SubathonEventType.ThroneGiftPurchase, Seconds = 12}, - // GoAffPro store order values are seeded per store (Meta = SiteId) in SeedKnownGoAffProStores - new () { EventType = SubathonEventType.TipeeeStreamDonation, Seconds = 12 }, - new () { EventType = SubathonEventType.TangiaTokens, Seconds = 0.12 }, - new () { EventType = SubathonEventType.PallyGGDonation, Seconds = 12 }, // per 1 USD, Pally is USD only - new () { EventType = SubathonEventType.TreatStreamOrder, Seconds = 600 }, // per treat, always 1 item - new () { EventType = SubathonEventType.MakeShipPledge, Meta = "DEFAULT", Seconds = 60 }, // always items mode, per pledge; overridable per tracked item (Meta = tracking name) - new () { EventType = SubathonEventType.MakeShipSale, Meta = "DEFAULT", Seconds = 300 }, // always items mode, per order; overridable per tracked item (Meta = tracking name) - new () { EventType = SubathonEventType.JuniperMerchSale, Meta = "DEFAULT", Seconds = 300 }, // always items mode. meta = product id number, which links back to a store - }; - - foreach (var def in defaults) - { - // only add if not exists - if (!db.SubathonValues.Any(sv => sv.EventType == def.EventType && sv.Meta == def.Meta)) - { - db.SubathonValues.Add(def); - } - } - if (!db.SubathonDatas.Any(s => s.IsActive)) - { - SubathonData subathon = new SubathonData(); - TimeSpan initialMs = TimeSpan.FromHours(8); - subathon.MillisecondsCumulative += (int)initialMs.TotalMilliseconds; - subathon.IsPaused = true; - db.SubathonDatas.Add(subathon); - } - - if (!db.SubathonGoalSets.Any(s => s.IsActive)) - { - SubathonGoalSet goalSet = new SubathonGoalSet(); - db.SubathonGoalSets.Add(goalSet); - } - - if (!db.SubathonPromptSets.Any(s => s.IsActive)) - { - db.SubathonPromptSets.Add(new SubathonPromptSet { IsActive = true }); - } + if (!db.SubathonPromptSets.Any(s => s.IsActive)) + db.SubathonPromptSets.Add(new SubathonPromptSet { IsActive = true }); - if (!db.WheelSets.Any(w => w.IsActive)) - { - db.WheelSets.Add(new WheelSet { IsActive = true }); - } + if (!db.WheelSets.Any(w => w.IsActive)) db.WheelSets.Add(new WheelSet { IsActive = true }); - if (!db.StateValues.Any(sv => sv.Name == StateKeys.WheelSpinsOwed)) - { - db.StateValues.Add(new StateValue - { - Name = StateKeys.WheelSpinsOwed, - Value = "0", - TypeName = "Int32" - }); - } + if (!db.StateValues.Any(sv => sv.Name == StateKeys.WheelSpinsOwed)) + db.StateValues.Add(new StateValue { + Name = StateKeys.WheelSpinsOwed, + Value = "0", + TypeName = "Int32" + }); - MigrateLegacyData(db); - SeedKnownGoAffProStores(db); - db.SaveChanges(); - } + MigrateLegacyData(db); + SeedKnownGoAffProStores(db); + db.SaveChanges(); + } - private static void SeedKnownGoAffProStores(AppDbContext db) - { - var defaults = new List - { - new () { SiteId = 165328, StoreName = "GamerSupps", EventName = "GamerSupps Order"}, - new () { SiteId = 132230, StoreName = "UwUMarket", EventName = "UwUMarket Order"}, - new () { SiteId = 7142837, StoreName = "Orchid Eight", EventName = "Orchid Eight Order"}, - new () { SiteId = 7160049, StoreName = "KatDragonz", EventName = "KatDragonz Order"}, - new () { SiteId = 7138531, StoreName = "Cheeky Soap", EventName = "Cheeky Soap Order"}, - new () { SiteId = 105752, StoreName = "Advanced GG", EventName = "Advanced GG Order"}, - new () { SiteId = 7014645, StoreName = "Rogue Energy", EventName = "Rogue Energy Order"}, - new () { SiteId = 7118656, StoreName = "Saucy Biz", EventName = "Saucy Biz Order"}, - new () { SiteId = 48808, StoreName = "GFuel", EventName = "GFuel Order"}, - new () { SiteId = 7132796, StoreName = "Natura Pine", EventName = "Natura Pine Order"}, - new () { SiteId = 18870, StoreName = "Madrinas", EventName = "Madrinas Order"}, - new () { SiteId = 7000863, StoreName = "Otaku", EventName = "Otaku Order"}, - new () { SiteId = 7111695, StoreName = "V1 Tech", EventName = "V1 Tech Order"}, - new () { SiteId = 7120088, StoreName = "Plush Foundry", EventName = "PlushFoundry Order"}, - new () { SiteId = 7112002, StoreName = "Horizons Merch", EventName = "Horizons Merch Order"} - }; - - foreach (var def in defaults) - { - var existing = db.GoAffProStores.FirstOrDefault(store => store.SiteId == def.SiteId); - if (existing == null) - { - db.GoAffProStores.Add(def); - } - else if (existing.StoreName != def.StoreName || existing.EventName != def.EventName) - { - existing.StoreName = def.StoreName; - existing.EventName = def.EventName; - } + private static void SeedKnownGoAffProStores(AppDbContext db) { + var defaults = new List { + new() { SiteId = 165328, StoreName = "GamerSupps", EventName = "GamerSupps Order" }, + new() { SiteId = 132230, StoreName = "UwUMarket", EventName = "UwUMarket Order" }, + new() { SiteId = 7142837, StoreName = "Orchid Eight", EventName = "Orchid Eight Order" }, + new() { SiteId = 7160049, StoreName = "KatDragonz", EventName = "KatDragonz Order" }, + new() { SiteId = 7138531, StoreName = "Cheeky Soap", EventName = "Cheeky Soap Order" }, + new() { SiteId = 105752, StoreName = "Advanced GG", EventName = "Advanced GG Order" }, + new() { SiteId = 7014645, StoreName = "Rogue Energy", EventName = "Rogue Energy Order" }, + new() { SiteId = 7118656, StoreName = "Saucy Biz", EventName = "Saucy Biz Order" }, + new() { SiteId = 48808, StoreName = "GFuel", EventName = "GFuel Order" }, + new() { SiteId = 7132796, StoreName = "Natura Pine", EventName = "Natura Pine Order" }, + new() { SiteId = 18870, StoreName = "Madrinas", EventName = "Madrinas Order" }, + new() { SiteId = 7000863, StoreName = "Otaku", EventName = "Otaku Order" }, + new() { SiteId = 7111695, StoreName = "V1 Tech", EventName = "V1 Tech Order" }, + new() { SiteId = 7120088, StoreName = "Plush Foundry", EventName = "PlushFoundry Order" }, + new() { SiteId = 7112002, StoreName = "Horizons Merch", EventName = "Horizons Merch Order" }, + new() { SiteId = 7181690, StoreName = "Redtail Retail", EventName = "Redtail Order"} + }; + + foreach (GoAffProStore def in defaults) { + GoAffProStore? existing = db.GoAffProStores.FirstOrDefault(store => store.SiteId == def.SiteId); + if (existing == null) { + db.GoAffProStores.Add(def); } - - var siteIds = defaults.Select(d => d.SiteId) - .Concat(db.GoAffProStores.AsNoTracking().Select(s => s.SiteId).ToList()) - .Distinct(); - foreach (var siteId in siteIds) - { - var meta = siteId.ToString(); - if (!db.SubathonValues.Any(sv => sv.EventType == SubathonEventType.GoAffProOrder - && sv.Meta == meta)) - { - db.SubathonValues.Add( new () - { - EventType = SubathonEventType.GoAffProOrder, Seconds = 12, Meta = meta - }); - } + else if (existing.StoreName != def.StoreName || existing.EventName != def.EventName) { + existing.StoreName = def.StoreName; + existing.EventName = def.EventName; } } - private static void MigrateLegacyGoAffProData(AppDbContext db) - { - // Convert rows stored under retired per-store GoAffPro enum values to the single - // GoAffProOrder type, SiteId in the meta column - int newGoAffProOrderInt = (int)SubathonEventType.GoAffProOrder; - - foreach (var legacyType in Enum.GetValues() - .Where(t => t.GetLegacyGoAffProSiteId() > 0)) - { - int oldTypeInt = (int)legacyType; - string meta = legacyType.GetLegacyGoAffProSiteId().ToString(); - - db.Database.ExecuteSqlRaw( - "UPDATE OR IGNORE SubathonValues SET EventType = {0}, Meta = {1} WHERE EventType = {2}", - newGoAffProOrderInt, meta, oldTypeInt); - db.Database.ExecuteSqlRaw( - "DELETE FROM SubathonValues WHERE EventType = {0}", - oldTypeInt); - - db.Database.ExecuteSqlRaw( - "UPDATE SubathonEvents SET EventType = {0}, EventTypeMeta = {1} WHERE EventType = {2}", - newGoAffProOrderInt, meta, oldTypeInt); - - db.Database.ExecuteSqlRaw( - "UPDATE WheelSpinTriggers SET EventType = {0}, TierValue = {1} WHERE EventType = {2}", - newGoAffProOrderInt, meta, oldTypeInt); - db.Database.ExecuteSqlRaw( - "UPDATE WheelSpinTriggerHistories SET SubathonEventType = {0} WHERE SubathonEventType = {1}", - newGoAffProOrderInt, oldTypeInt); - - db.Database.ExecuteSqlRaw( - "UPDATE SubathonPrompts SET FilterEventType = {0}, FilterMeta = {1} WHERE FilterEventType = {2}", - newGoAffProOrderInt, meta, oldTypeInt); - } + IEnumerable siteIds = defaults.Select(d => d.SiteId) + .Concat(db.GoAffProStores.AsNoTracking().Select(s => s.SiteId).ToList()) + .Distinct(); + foreach (int siteId in siteIds) { + var meta = siteId.ToString(); + if (!db.SubathonValues.Any(sv => sv.EventType == SubathonEventType.GoAffProOrder + && sv.Meta == meta)) + db.SubathonValues.Add(new SubathonValue { + EventType = SubathonEventType.GoAffProOrder, Seconds = 12, Meta = meta + }); } + } - private static void MigrateLegacyData(AppDbContext db) - { - - if (db.SubathonGoalSets.Any(s => s.Type == null)) - { - db.SubathonGoalSets.Where(s => s.Type == null) - .ExecuteUpdate(s => - s.SetProperty(x => x.Type, GoalsType.Points)); - } - - if (db.SubathonDatas.Any(s => s.ReversedTime == null)) - { - db.SubathonDatas.Where(s => s.ReversedTime == null) - .ExecuteUpdate(s => - s.SetProperty(x => x.ReversedTime, false)); - } - - MigrateLegacyGoAffProData(db); - - // var fontTypes = WidgetVariableTypeHelper.FontVariables.ToList(); - // var widgetsMissingFontTypes = db.Widgets - // .Where(w => !fontTypes.All(ft => w.JsVariables.Any(v => v.Type == ft))) - // .Include(w => w.JsVariables) - // .ToList(); - // if (widgetsMissingFontTypes.Count > 0) - // { - // var newVars = new List(); - // foreach (var widget in widgetsMissingFontTypes) - // { - // var existingTypes = widget.JsVariables - // .Select(v => v.Type) - // .ToHashSet(); - // - // foreach (var fontVar in fontTypes.Where(ft => !existingTypes.Contains(ft))) - // { - // newVars.Add(new JsVariable - // { - // WidgetId = widget.Id, - // Widget = widget, - // Type = fontVar, - // Name = $"{fontVar}s", - // Description = $"Custom font names to include from {fontVar}s, comma separated", - // Value = string.Empty - // }); - // } - // } - // if (newVars.Count > 0) - // { - // db.JsVariables.AddRange(newVars); - // db.SaveChanges(); - // } - // } + private static void MigrateLegacyGoAffProData(AppDbContext db) { + // Convert rows stored under retired per-store GoAffPro enum values to the single + // GoAffProOrder type, SiteId in the meta column + var newGoAffProOrderInt = (int)SubathonEventType.GoAffProOrder; + + foreach (SubathonEventType legacyType in Enum.GetValues() + .Where(t => t.GetLegacyGoAffProSiteId() > 0)) { + var oldTypeInt = (int)legacyType; + var meta = legacyType.GetLegacyGoAffProSiteId().ToString(); + + db.Database.ExecuteSqlRaw( + "UPDATE OR IGNORE SubathonValues SET EventType = {0}, Meta = {1} WHERE EventType = {2}", + newGoAffProOrderInt, meta, oldTypeInt); + db.Database.ExecuteSqlRaw( + "DELETE FROM SubathonValues WHERE EventType = {0}", + oldTypeInt); + + db.Database.ExecuteSqlRaw( + "UPDATE SubathonEvents SET EventType = {0}, EventTypeMeta = {1} WHERE EventType = {2}", + newGoAffProOrderInt, meta, oldTypeInt); + + db.Database.ExecuteSqlRaw( + "UPDATE WheelSpinTriggers SET EventType = {0}, TierValue = {1} WHERE EventType = {2}", + newGoAffProOrderInt, meta, oldTypeInt); + db.Database.ExecuteSqlRaw( + "UPDATE WheelSpinTriggerHistories SET SubathonEventType = {0} WHERE SubathonEventType = {1}", + newGoAffProOrderInt, oldTypeInt); + + db.Database.ExecuteSqlRaw( + "UPDATE SubathonPrompts SET FilterEventType = {0}, FilterMeta = {1} WHERE FilterEventType = {2}", + newGoAffProOrderInt, meta, oldTypeInt); } } + + private static void MigrateLegacyData(AppDbContext db) { + if (db.SubathonGoalSets.Any(s => s.Type == null)) + db.SubathonGoalSets.Where(s => s.Type == null) + .ExecuteUpdate(s => + s.SetProperty(x => x.Type, GoalsType.Points)); + + if (db.SubathonDatas.Any(s => s.ReversedTime == null)) + db.SubathonDatas.Where(s => s.ReversedTime == null) + .ExecuteUpdate(s => + s.SetProperty(x => x.ReversedTime, false)); + + MigrateLegacyGoAffProData(db); + + // var fontTypes = WidgetVariableTypeHelper.FontVariables.ToList(); + // var widgetsMissingFontTypes = db.Widgets + // .Where(w => !fontTypes.All(ft => w.JsVariables.Any(v => v.Type == ft))) + // .Include(w => w.JsVariables) + // .ToList(); + // if (widgetsMissingFontTypes.Count > 0) + // { + // var newVars = new List(); + // foreach (var widget in widgetsMissingFontTypes) + // { + // var existingTypes = widget.JsVariables + // .Select(v => v.Type) + // .ToHashSet(); + // + // foreach (var fontVar in fontTypes.Where(ft => !existingTypes.Contains(ft))) + // { + // newVars.Add(new JsVariable + // { + // WidgetId = widget.Id, + // Widget = widget, + // Type = fontVar, + // Name = $"{fontVar}s", + // Description = $"Custom font names to include from {fontVar}s, comma separated", + // Value = string.Empty + // }); + // } + // } + // if (newVars.Count > 0) + // { + // db.JsVariables.AddRange(newVars); + // db.SaveChanges(); + // } + // } + } } \ No newline at end of file diff --git a/SubathonManager.Data/Extensions/SubathonQueryExtensions.cs b/SubathonManager.Data/Extensions/SubathonQueryExtensions.cs index cd315478..7a40a460 100644 --- a/SubathonManager.Data/Extensions/SubathonQueryExtensions.cs +++ b/SubathonManager.Data/Extensions/SubathonQueryExtensions.cs @@ -1,35 +1,33 @@ -using SubathonManager.Core.Models; -using SubathonManager.Core.Enums; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using Microsoft.EntityFrameworkCore; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; + namespace SubathonManager.Data.Extensions; [ExcludeFromCodeCoverage] -public static class SubathonQueryExtensions -{ +public static class SubathonQueryExtensions { // unused but may be useful in future. Also sets up possible future extensions - + public static async Task TwitchCheerTotalAsync( this SubathonData subathon, - AppDbContext db) - { - var events = await db.SubathonEvents.AsNoTracking() + AppDbContext db) { + List events = await db.SubathonEvents.AsNoTracking() .Where(e => e.SubathonId == subathon.Id && e.EventType == SubathonEventType.TwitchCheer) .ToListAsync(); return events.Sum(e => - long.TryParse(e.Value, out var v) ? v : 0); + long.TryParse(e.Value, out long v) ? v : 0); } - + public static long TwitchCheerTotal( this SubathonData subathon, - AppDbContext db) - { + AppDbContext db) { return db.Set() .Where(e => e.SubathonId == subathon.Id && e.EventType == SubathonEventType.TwitchCheer).AsNoTracking().AsEnumerable() - .Sum(e => (long?)Convert.ToInt64(e.Value))?? 0; + .Sum(e => (long?)Convert.ToInt64(e.Value)) ?? 0; } } \ No newline at end of file diff --git a/SubathonManager.Data/OverlayPorter.cs b/SubathonManager.Data/OverlayPorter.cs index 54629a6e..7ceb6690 100644 --- a/SubathonManager.Data/OverlayPorter.cs +++ b/SubathonManager.Data/OverlayPorter.cs @@ -4,6 +4,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using SubathonManager.Core; using SubathonManager.Core.Enums; @@ -16,139 +17,126 @@ namespace SubathonManager.Data; -public static class OverlayPorter -{ +public static class OverlayPorter { private const int SegmentHashLength = 4; private const string ExternalFolder = "_external"; private const string PacksFolder = "packs"; private const string ManifestFileName = OverlayPackInstaller.ManifestFileName; public const string FormatVersion = "2"; - private static readonly JsonSerializerOptions SerializeOptions = new JsonSerializerOptions - { + private static readonly JsonSerializerOptions SerializeOptions = new() { WriteIndented = true, - Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } + Converters = { new JsonStringEnumConverter() } }; #region EXPORT public static async Task ExportRouteAsync(Route route, string outputPath, string exportName, HashSet? excludedZipEntries = null, string version = "1", string appVersion = "", - string author = "", List? tags = null) - { - var widgets = route.Widgets.ToList(); - var plan = BuildExportPlan(widgets, excludedZipEntries); + string author = "", List? tags = null) { + List widgets = route.Widgets.ToList(); + ExportPlan plan = BuildExportPlan(widgets, excludedZipEntries); await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write); - using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: false); + using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, false); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var (srcFile, zipEntry) in plan.FileCopies) - { + foreach ((string srcFile, string zipEntry) in plan.FileCopies) { if (!WidgetFiles.Current.Exists(srcFile)) continue; if (!seen.Add(zipEntry)) continue; if (excludedZipEntries != null && excludedZipEntries.Contains(zipEntry)) continue; - if (File.Exists(srcFile)) - { + if (File.Exists(srcFile)) { await archive.CreateEntryFromFileAsync(srcFile, zipEntry, CompressionLevel.Optimal); continue; } - var bytes = WidgetFiles.Current.ReadAllBytes(srcFile); + byte[]? bytes = WidgetFiles.Current.ReadAllBytes(srcFile); if (bytes == null) continue; - var packedEntry = archive.CreateEntry(zipEntry, CompressionLevel.Optimal); - await using var packedStream = await packedEntry.OpenAsync(); + ZipArchiveEntry packedEntry = archive.CreateEntry(zipEntry, CompressionLevel.Optimal); + await using Stream packedStream = await packedEntry.OpenAsync(); await packedStream.WriteAsync(bytes); } - var manifest = BuildManifest(route, widgets, plan, exportName, version, appVersion, author, tags); - var manifestJson = JsonSerializer.Serialize(manifest, SerializeOptions); - var manifestEntry = archive.CreateEntry(ManifestFileName, CompressionLevel.Optimal); - await using var manifestStream = await manifestEntry.OpenAsync(); + JsonElement manifest = BuildManifest(route, widgets, plan, exportName, version, appVersion, author, tags); + string manifestJson = JsonSerializer.Serialize(manifest, SerializeOptions); + ZipArchiveEntry manifestEntry = archive.CreateEntry(ManifestFileName, CompressionLevel.Optimal); + await using Stream manifestStream = await manifestEntry.OpenAsync(); await manifestStream.WriteAsync(Encoding.UTF8.GetBytes(manifestJson)); OpenExportFolder(outputPath); } [ExcludeFromCodeCoverage] - private static void OpenExportFolder(string outputPath) - { + private static void OpenExportFolder(string outputPath) { bool isTest = AppDomain.CurrentDomain.GetAssemblies() .Any(a => a.FullName!.StartsWith("xunit", StringComparison.OrdinalIgnoreCase)); if (isTest) return; - try - { - Process.Start(new ProcessStartInfo - { + try { + Process.Start(new ProcessStartInfo { FileName = Path.GetDirectoryName(Path.GetFullPath(outputPath)), UseShellExecute = true, Verb = "open" }); } - catch {/**/} + catch { + /**/ + } } - + public const string ResourcesFolder = "_resources"; - private static ExportPlan BuildExportPlan(List widgets, HashSet? excludedZipEntries = null) - { + private static ExportPlan BuildExportPlan(List widgets, HashSet? excludedZipEntries = null) { var plan = new ExportPlan(); - bool IsKept(string zipEntry) => excludedZipEntries?.Contains(zipEntry) != true; - foreach (var rel in ResourcePaths.EnumerateRelative()) - { - string zipEntry = $"{ResourcesFolder}/{rel}"; + bool IsKept(string zipEntry) { + return excludedZipEntries?.Contains(zipEntry) != true; + } + + foreach (string rel in ResourcePaths.EnumerateRelative()) { + var zipEntry = $"{ResourcesFolder}/{rel}"; if (!IsKept(zipEntry)) continue; if (ResourcePaths.ToLocalPath(ResourcePaths.UrlPrefix + rel) is { } source) plan.FileCopies.Add((source, zipEntry)); } - var widgetRoots = widgets.Select(w => w.GetPath()).ToList(); - var zipRoots = GetZipWidgetRoots(widgetRoots); + List widgetRoots = widgets.Select(w => w.GetPath()).ToList(); + List zipRoots = GetZipWidgetRoots(widgetRoots); - for (int i = 0; i < widgets.Count; i++) - { - var widget = widgets[i]; + for (var i = 0; i < widgets.Count; i++) { + Widget widget = widgets[i]; string widgetRoot = widgetRoots[i]; string zipWidgetRoot = zipRoots[i]; - var baseFolder = GetWidgetBaseFolder(zipWidgetRoot); + string baseFolder = GetWidgetBaseFolder(zipWidgetRoot); plan.WidgetFolderMap[widget.Id] = zipWidgetRoot; - bool isPacked = WidgetPackPaths.TryResolve(widget.HtmlPath, out var packFile, out var packEntry, - out var packId, out var packVersion); + bool isPacked = WidgetPackPaths.TryResolve(widget.HtmlPath, out string packFile, out string packEntry, + out string packId, out string packVersion); - if (isPacked) - { - string packZipPath = $"{PacksFolder}/{packId}/{packVersion}{WidgetPackPaths.PackExtension}"; + if (isPacked) { + var packZipPath = $"{PacksFolder}/{packId}/{packVersion}{WidgetPackPaths.PackExtension}"; plan.FileCopies.Add((packFile, packZipPath)); plan.WidgetPacks[widget.Id] = new PackReference(packId, packVersion, packEntry, packZipPath); } - else if (widget.Type.IsAsset()) - { - if (WidgetFiles.Current.Exists(widget.HtmlPath)) - { + else if (widget.Type.IsAsset()) { + if (WidgetFiles.Current.Exists(widget.HtmlPath)) { string fileName = Path.GetFileName(widget.HtmlPath); plan.FileCopies.Add((widget.HtmlPath, $"{zipWidgetRoot}/{fileName}")); } } - else - { - foreach (var file in WidgetFiles.Current.EnumerateFiles(widgetRoot)) - { + else { + foreach (string file in WidgetFiles.Current.EnumerateFiles(widgetRoot)) { string relative = Path.GetRelativePath(widgetRoot, file).Replace('\\', '/'); plan.FileCopies.Add((file, $"{zipWidgetRoot}/{relative}")); } } - foreach (var jsVar in widget.JsVariables) - { + foreach (JsVariable jsVar in widget.JsVariables) { if (!((WidgetVariableType?)jsVar.Type).IsFileVariable()) continue; if (string.IsNullOrWhiteSpace(jsVar.Value)) continue; - if (ResourcePaths.RelativeFromUrl(jsVar.Value) is { } resourceRel) - { + if (ResourcePaths.RelativeFromUrl(jsVar.Value) is { } resourceRel) { if (!IsKept($"{ResourcesFolder}/{resourceRel}")) continue; string upToRoot = string.Concat( Enumerable.Repeat("../", zipWidgetRoot.Split('/').Length)); @@ -158,22 +146,20 @@ private static ExportPlan BuildExportPlan(List widgets, HashSet? } bool isAbsolute = !jsVar.Value.StartsWith("./") && !jsVar.Value.StartsWith("../") - && Path.IsPathRooted(jsVar.Value); + && Path.IsPathRooted(jsVar.Value); if (!isAbsolute) continue; bool isFolderType = jsVar.Type == WidgetVariableType.FolderPath; - if (isFolderType && Directory.Exists(jsVar.Value)) - { + if (isFolderType && Directory.Exists(jsVar.Value)) { string varFolderName = SanitizeName(jsVar.Name); - foreach (var file in Directory.EnumerateFiles(jsVar.Value, "*", SearchOption.AllDirectories)) - { + foreach (string file in Directory.EnumerateFiles(jsVar.Value, "*", SearchOption.AllDirectories)) { string relative = Path.GetRelativePath(jsVar.Value, file).Replace('\\', '/'); plan.FileCopies.Add((file, $"{zipWidgetRoot}/{ExternalFolder}/{varFolderName}/{relative}")); } + SetRewrite(plan.VariableRewrites, widget.Id, jsVar.Name, $"./{ExternalFolder}/{varFolderName}"); } - else if (!isFolderType && File.Exists(jsVar.Value)) - { + else if (!isFolderType && File.Exists(jsVar.Value)) { string fileName = Path.GetFileName(jsVar.Value); plan.FileCopies.Add((jsVar.Value, $"{zipWidgetRoot}/{ExternalFolder}/{fileName}")); SetRewrite(plan.VariableRewrites, widget.Id, jsVar.Name, $"./{ExternalFolder}/{fileName}"); @@ -185,28 +171,27 @@ private static ExportPlan BuildExportPlan(List widgets, HashSet? } private static JsonElement BuildManifest(Route route, List widgets, ExportPlan plan, string exportName, - string version = "1", string appVersion = "", string author = "", List? tags = null) - { + string version = "1", string appVersion = "", string author = "", List? tags = null) { if (string.IsNullOrWhiteSpace(appVersion)) appVersion = AppServices.AppVersion; - var widgetList = widgets.Select(w => - { - if (!plan.WidgetFolderMap.TryGetValue(w.Id, out var zipWidgetRoot)) return null!; + var widgetList = widgets.Select(w => { + if (!plan.WidgetFolderMap.TryGetValue(w.Id, out string? zipWidgetRoot)) return null!; string htmlFileName = Path.GetFileName(w.HtmlPath); - string htmlZipRelPath = $"{zipWidgetRoot}/{htmlFileName}"; - - var rewrites = plan.VariableRewrites.TryGetValue(w.Id, out var r) ? r : new(); - var jsVars = w.JsVariables.Select(v => - { - string value = rewrites.TryGetValue(v.Name, out var rewritten) ? rewritten : v.Value; + var htmlZipRelPath = $"{zipWidgetRoot}/{htmlFileName}"; + + Dictionary rewrites = + plan.VariableRewrites.TryGetValue(w.Id, out Dictionary? r) + ? r + : new Dictionary(); + var jsVars = w.JsVariables.Select(v => { + string value = rewrites.TryGetValue(v.Name, out string? rewritten) ? rewritten : v.Value; return new { name = v.Name, value, type = v.Type }; }); - var pack = plan.WidgetPacks.TryGetValue(w.Id, out var packRef) + var pack = plan.WidgetPacks.TryGetValue(w.Id, out PackReference? packRef) ? new { id = packRef.PackId, version = packRef.Version, entry = packRef.Entry, file = packRef.ZipPath } : null; - return new - { + return new { id = w.Id, name = w.Name, htmlPath = htmlZipRelPath, @@ -222,18 +207,16 @@ private static JsonElement BuildManifest(Route route, List widgets, Expo }; }); - var obj = new - { + var obj = new { // formatversion helps determine some behaviour from old stuff - version = version, + version, format_version = FormatVersion, app_version = appVersion, exported_at = DateTime.UtcNow, - route = new - { + route = new { id = route.Id, name = exportName, - author = author, + author, overlay_version = version, tags = tags ?? new List(), resolution = new { width = route.Width, height = route.Height }, @@ -250,6 +233,7 @@ private static JsonElement BuildManifest(Route route, List widgets, Expo return JsonSerializer.SerializeToElement(obj); } + #endregion #region IMPORT @@ -257,45 +241,42 @@ private static JsonElement BuildManifest(Route route, List widgets, Expo public static async Task ImportRouteAsync( string smoPath, string extractRoot, - IDbContextFactory factory) - { + IDbContextFactory factory) { string archiveName = Path.GetFileNameWithoutExtension(smoPath); string extractDir = Path.Combine(extractRoot, SanitizeName(archiveName)); Directory.CreateDirectory(extractDir); - await ZipFile.ExtractToDirectoryAsync(smoPath, extractDir, overwriteFiles: true); + await ZipFile.ExtractToDirectoryAsync(smoPath, extractDir, true); return await ImportExtractedRouteAsync(extractDir, factory, archiveName); } - + public static async Task ImportExtractedRouteAsync( string extractDir, IDbContextFactory factory, string fallbackName, - string? routeNameOverride = null) - { + string? routeNameOverride = null) { string manifestPath = Path.Combine(extractDir, ManifestFileName); if (!File.Exists(manifestPath)) return ImportResult.Fail("overlay.json not found in archive"); - var manifestJson = await File.ReadAllTextAsync(manifestPath); - using var doc = JsonDocument.Parse(manifestJson); - var root = doc.RootElement; + string manifestJson = await File.ReadAllTextAsync(manifestPath); + using JsonDocument doc = JsonDocument.Parse(manifestJson); + JsonElement root = doc.RootElement; - await using var db = await factory.CreateDbContextAsync(); + await using AppDbContext db = await factory.CreateDbContextAsync(); - var routeEl = root.GetProperty("route"); + JsonElement routeEl = root.GetProperty("route"); string routeName = routeNameOverride ?? routeEl.GetProperty("name").GetString() ?? fallbackName; - var manifestWidgets = root.GetProperty("widgets").EnumerateArray() + List manifestWidgets = root.GetProperty("widgets").EnumerateArray() .Select(wEl => ResolveManifestWidget(wEl, extractDir)) .ToList(); var existing = db.Widgets .Select(w => new { w.Id, w.HtmlPath, w.RouteId }) .ToList() - .Select(w => new - { + .Select(w => new { w.Id, w.HtmlPath, w.RouteId, @@ -304,8 +285,7 @@ public static async Task ImportExtractedRouteAsync( .ToList(); Guid? matchedRouteId = null; - foreach (var mw in manifestWidgets) - { + foreach (ManifestWidget mw in manifestWidgets) { var hit = existing.FirstOrDefault(w => mw.PackFolder != null ? string.Equals(w.PackFolder, mw.PackFolder, StringComparison.OrdinalIgnoreCase) @@ -316,39 +296,32 @@ public static async Task ImportExtractedRouteAsync( break; } - if (matchedRouteId == null) - { + if (matchedRouteId == null) { string baseName = OverlayPackPaths.BaseRouteName(routeName); if (!string.IsNullOrWhiteSpace(baseName)) - { matchedRouteId = db.Routes .Select(r => new { r.Id, r.Name }) .ToList() .FirstOrDefault(r => string.Equals( - OverlayPackPaths.BaseRouteName(r.Name), + OverlayPackPaths.BaseRouteName(r.Name), baseName, StringComparison.OrdinalIgnoreCase)) ?.Id; - } } Route? route = null; bool routeIsNew; if (matchedRouteId != null) - { route = await db.Routes .Include(r => r.Widgets).ThenInclude(w => w.JsVariables) .Include(r => r.Widgets).ThenInclude(w => w.CssVariables) .FirstOrDefaultAsync(r => r.Id == matchedRouteId); - } - if (route == null) - { + if (route == null) { route = BuildNewRoute(routeEl, routeName); routeIsNew = true; } - else - { + else { routeIsNew = false; } @@ -357,102 +330,92 @@ public static async Task ImportExtractedRouteAsync( var newJsVariables = new List(); var repointedWidgets = new List(); - foreach (var mw in manifestWidgets) - { - var wEl = mw.Element; + foreach (ManifestWidget mw in manifestWidgets) { + JsonElement wEl = mw.Element; string htmlAbsPath = mw.HtmlAbsPath; string widgetExtractFolder = mw.PackFolder != null ? mw.OverlayFolder : Path.GetDirectoryName(htmlAbsPath)!; - Widget? existingWidget = routeIsNew ? null + Widget? existingWidget = routeIsNew + ? null : route.Widgets.FirstOrDefault(w => MatchesManifestWidget(w, mw)); if (existingWidget != null && - !string.Equals(existingWidget.HtmlPath, htmlAbsPath, StringComparison.OrdinalIgnoreCase)) - { + !string.Equals(existingWidget.HtmlPath, htmlAbsPath, StringComparison.OrdinalIgnoreCase)) { existingWidget.HtmlPath = htmlAbsPath; repointedWidgets.Add(existingWidget); } - if (existingWidget == null) - { - WidgetType widgetType = WidgetType.Html; - if (wEl.TryGetProperty("type", out var typeEl)) - Enum.TryParse(typeEl.GetString(), ignoreCase: true, out widgetType); + if (existingWidget == null) { + var widgetType = WidgetType.Html; + if (wEl.TryGetProperty("type", out JsonElement typeEl)) + Enum.TryParse(typeEl.GetString(), true, out widgetType); else widgetType = WidgetTypeHelper.DetectFromPath(htmlAbsPath); - var widget = new Widget(wEl.GetProperty("name").GetString() ?? "Imported Widget", htmlAbsPath) - { + var widget = new Widget(wEl.GetProperty("name").GetString() ?? "Imported Widget", htmlAbsPath) { Id = Guid.NewGuid(), RouteId = route.Id, Type = widgetType, Visibility = wEl.GetProperty("visibility").GetBoolean(), - DocsUrl = wEl.TryGetProperty("docsUrl", out var du) ? du.GetString() : null + DocsUrl = wEl.TryGetProperty("docsUrl", out JsonElement du) ? du.GetString() : null }; - var pos = wEl.GetProperty("position"); + JsonElement pos = wEl.GetProperty("position"); widget.X = pos.GetProperty("x").GetSingle(); widget.Y = pos.GetProperty("y").GetSingle(); widget.Z = pos.GetProperty("z").GetInt32(); - var size = wEl.GetProperty("size"); + JsonElement size = wEl.GetProperty("size"); widget.Width = size.GetProperty("width").GetInt32(); widget.Height = size.GetProperty("height").GetInt32(); - var scale = wEl.GetProperty("scale"); + JsonElement scale = wEl.GetProperty("scale"); widget.ScaleX = scale.GetProperty("x").GetSingle(); widget.ScaleY = scale.GetProperty("y").GetSingle(); - if (widgetType == WidgetType.Html) - { - foreach (var v in wEl.GetProperty("cssVariables").EnumerateArray().Select(cssEl => + if (widgetType == WidgetType.Html) { + foreach (CssVariable v in wEl.GetProperty("cssVariables").EnumerateArray().Select(cssEl => CssVariable.FromJson(cssEl, widget.Id)).OfType()) - { widget.CssVariables.Add(v); - } - foreach (var v in wEl.GetProperty("jsVariables").EnumerateArray().Select(jsEl => - BuildJsVariable(jsEl, widget.Id, widgetExtractFolder, mw.PackFolder != null)) + foreach (JsVariable v in wEl.GetProperty("jsVariables").EnumerateArray().Select(jsEl => + BuildJsVariable(jsEl, widget.Id, widgetExtractFolder, mw.PackFolder != null)) .OfType()) - { widget.JsVariables.Add(v); - } } newWidgets.Add(widget); } - else - { - var existingCssNames = new HashSet(existingWidget.CssVariables.Select(v => v.Name), StringComparer.OrdinalIgnoreCase); - foreach (var cssEl in wEl.GetProperty("cssVariables").EnumerateArray()) - { - string? name = cssEl.TryGetProperty("name", out var n) ? n.GetString() : null; + else { + var existingCssNames = new HashSet(existingWidget.CssVariables.Select(v => v.Name), + StringComparer.OrdinalIgnoreCase); + foreach (JsonElement cssEl in wEl.GetProperty("cssVariables").EnumerateArray()) { + string? name = cssEl.TryGetProperty("name", out JsonElement n) ? n.GetString() : null; if (name == null || existingCssNames.Contains(name)) continue; - var v = CssVariable.FromJson(cssEl, existingWidget.Id); + CssVariable? v = CssVariable.FromJson(cssEl, existingWidget.Id); if (v != null) newCssVariables.Add(v); } - - var existingJsNames = new HashSet(existingWidget.JsVariables.Select(v => v.Name), StringComparer.OrdinalIgnoreCase); - foreach (var jsEl in wEl.GetProperty("jsVariables").EnumerateArray()) - { - string? name = jsEl.TryGetProperty("name", out var n) ? n.GetString() : null; + + var existingJsNames = new HashSet(existingWidget.JsVariables.Select(v => v.Name), + StringComparer.OrdinalIgnoreCase); + foreach (JsonElement jsEl in wEl.GetProperty("jsVariables").EnumerateArray()) { + string? name = jsEl.TryGetProperty("name", out JsonElement n) ? n.GetString() : null; if (name == null || existingJsNames.Contains(name)) continue; - var v = BuildJsVariable(jsEl, existingWidget.Id, widgetExtractFolder, mw.PackFolder != null); + JsVariable? v = BuildJsVariable(jsEl, existingWidget.Id, widgetExtractFolder, + mw.PackFolder != null); if (v != null) newJsVariables.Add(v); } } } var repointedIds = new HashSet(repointedWidgets.Select(w => w.Id)); - foreach (var mw in manifestWidgets.Where(m => m.PackFolder != null)) - { + foreach (ManifestWidget mw in manifestWidgets.Where(m => m.PackFolder != null)) { var strays = existing.Where(w => string.Equals(w.PackFolder, mw.PackFolder, StringComparison.OrdinalIgnoreCase) && !string.Equals(w.HtmlPath, mw.HtmlAbsPath, StringComparison.OrdinalIgnoreCase) && !repointedIds.Contains(w.Id)); - foreach (var stray in strays) - { - var tracked = await db.Widgets.FirstOrDefaultAsync(w => w.Id == stray.Id); + foreach (var stray in strays) { + Widget? tracked = await db.Widgets.FirstOrDefaultAsync(w => w.Id == stray.Id); if (tracked == null || !repointedIds.Add(tracked.Id)) continue; tracked.HtmlPath = mw.HtmlAbsPath; repointedWidgets.Add(tracked); @@ -461,8 +424,7 @@ public static async Task ImportExtractedRouteAsync( bool renameMerged = !routeIsNew && !string.Equals(route!.Name, routeName, StringComparison.Ordinal); - return new ImportResult - { + return new ImportResult { Route = routeIsNew ? route : null, NewWidgets = newWidgets, NewCssVariables = newCssVariables, @@ -476,28 +438,27 @@ public static async Task ImportExtractedRouteAsync( private record ManifestWidget(JsonElement Element, string HtmlAbsPath, string OverlayFolder, string? PackFolder); - private static ManifestWidget ResolveManifestWidget(JsonElement wEl, string extractDir) - { - string htmlZipRelPath = wEl.TryGetProperty("htmlPath", out var hp) ? hp.GetString() ?? "" : ""; - + private static ManifestWidget ResolveManifestWidget(JsonElement wEl, string extractDir) { + string htmlZipRelPath = wEl.TryGetProperty("htmlPath", out JsonElement hp) ? hp.GetString() ?? "" : ""; + string overlayPath = Path.GetFullPath( Path.Combine(extractDir, htmlZipRelPath.Replace('/', Path.DirectorySeparatorChar))); string overlayFolder = Path.GetDirectoryName(overlayPath) ?? extractDir; - if (!wEl.TryGetProperty("pack", out var packEl) || packEl.ValueKind != JsonValueKind.Object) + if (!wEl.TryGetProperty("pack", out JsonElement packEl) || packEl.ValueKind != JsonValueKind.Object) return new ManifestWidget(wEl, overlayPath, overlayFolder, null); - string file = packEl.TryGetProperty("file", out var f) ? f.GetString() ?? "" : ""; + string file = packEl.TryGetProperty("file", out JsonElement f) ? f.GetString() ?? "" : ""; if (string.IsNullOrWhiteSpace(file)) return new ManifestWidget(wEl, overlayPath, overlayFolder, null); string smwPath = Path.GetFullPath( Path.Combine(extractDir, file.Replace('/', Path.DirectorySeparatorChar))); - var packManifest = WidgetPackInstaller.ReadManifest(smwPath); + WidgetPackManifest? packManifest = WidgetPackInstaller.ReadManifest(smwPath); string entry = packManifest?.Entry ?? string.Empty; if (string.IsNullOrWhiteSpace(entry)) - entry = packEl.TryGetProperty("entry", out var en) ? en.GetString() ?? "" : ""; + entry = packEl.TryGetProperty("entry", out JsonElement en) ? en.GetString() ?? "" : ""; if (string.IsNullOrWhiteSpace(entry) || !File.Exists(smwPath)) return new ManifestWidget(wEl, overlayPath, overlayFolder, null); @@ -511,12 +472,10 @@ private static ManifestWidget ResolveManifestWidget(JsonElement wEl, string extr return new ManifestWidget(wEl, htmlPath, overlayFolder, packFolder); } - - private static bool MatchesManifestWidget(Widget widget, ManifestWidget mw) - { - if (mw.PackFolder != null) - { - var location = WidgetPackPaths.Resolve(widget.HtmlPath); + + private static bool MatchesManifestWidget(Widget widget, ManifestWidget mw) { + if (mw.PackFolder != null) { + WidgetPackPaths.PackLocation? location = WidgetPackPaths.Resolve(widget.HtmlPath); return location != null && string.Equals(location.PackFolderStr, mw.PackFolder, StringComparison.OrdinalIgnoreCase); } @@ -524,17 +483,17 @@ private static bool MatchesManifestWidget(Widget widget, ManifestWidget mw) return string.Equals(widget.HtmlPath, mw.HtmlAbsPath, StringComparison.OrdinalIgnoreCase); } - private static Route BuildNewRoute(JsonElement routeEl, string routeName) => new Route - { - Id = Guid.NewGuid(), - Name = routeName, - Width = routeEl.GetProperty("resolution").GetProperty("width").GetInt32(), - Height = routeEl.GetProperty("resolution").GetProperty("height").GetInt32(), - }; + private static Route BuildNewRoute(JsonElement routeEl, string routeName) { + return new Route { + Id = Guid.NewGuid(), + Name = routeName, + Width = routeEl.GetProperty("resolution").GetProperty("width").GetInt32(), + Height = routeEl.GetProperty("resolution").GetProperty("height").GetInt32() + }; + } - private static JsVariable? BuildJsVariable(JsonElement jsEl, Guid widgetId, string widgetFolder, bool isPacked) - { - var v = JsVariable.FromJson(jsEl, widgetId); + private static JsVariable? BuildJsVariable(JsonElement jsEl, Guid widgetId, string widgetFolder, bool isPacked) { + JsVariable? v = JsVariable.FromJson(jsEl, widgetId); if (v == null) return null; if (!((WidgetVariableType?)v.Type).IsFileVariable() || string.IsNullOrWhiteSpace(v.Value)) @@ -547,15 +506,14 @@ private static bool MatchesManifestWidget(Widget widget, ManifestWidget mw) resolvedValue = resolvedValue.Replace('\\', '/'); string normWidgetFolder = widgetFolder.Replace('\\', '/').TrimEnd('/') + "/"; - if (!resolvedValue.StartsWith(normWidgetFolder, StringComparison.OrdinalIgnoreCase)) - { + if (!resolvedValue.StartsWith(normWidgetFolder, StringComparison.OrdinalIgnoreCase)) { v.Value = resolvedValue; return v; } string relative = resolvedValue[normWidgetFolder.Length..]; bool bundledByOverlay = isPacked && - relative.StartsWith(ExternalFolder + "/", + relative.StartsWith(ExternalFolder + "/", StringComparison.OrdinalIgnoreCase); v.Value = bundledByOverlay ? resolvedValue : "./" + relative; @@ -567,51 +525,44 @@ private static bool MatchesManifestWidget(Widget widget, ManifestWidget mw) #region HELPERS - private static void SetRewrite(Dictionary> rewrites, Guid widgetId, string varName, string value) - { - if (!rewrites.TryGetValue(widgetId, out var inner)) - { + private static void SetRewrite(Dictionary> rewrites, Guid widgetId, string varName, + string value) { + if (!rewrites.TryGetValue(widgetId, out Dictionary? inner)) { inner = new Dictionary(); rewrites[widgetId] = inner; } + inner[varName] = value; } - private static string GetWidgetBaseFolder(string zipWidgetRoot) - { + private static string GetWidgetBaseFolder(string zipWidgetRoot) { int lastSlash = zipWidgetRoot.LastIndexOf('/'); return lastSlash > 0 ? zipWidgetRoot[..lastSlash] : zipWidgetRoot; } - public static List GetZipWidgetRoots(List absoluteFolderPaths) - { - if (absoluteFolderPaths.Count == 0) return new(); + public static List GetZipWidgetRoots(List absoluteFolderPaths) { + if (absoluteFolderPaths.Count == 0) return new List(); - var segmentSets = absoluteFolderPaths + List segmentSets = absoluteFolderPaths .Select(p => p.Replace('\\', '/').TrimEnd('/').Split('/', StringSplitOptions.RemoveEmptyEntries)) .ToList(); - static bool IsPrefix(string[] parent, string[] child) - { + static bool IsPrefix(string[] parent, string[] child) { if (parent.Length > child.Length) return false; return !parent.Where((t, i) => !string.Equals(t, child[i], StringComparison.OrdinalIgnoreCase)).Any(); } var roots = new List(segmentSets.Count); - foreach (var current in segmentSets) - { + foreach (string[] current in segmentSets) { string[]? bestRoot = null; - foreach (var candidate in segmentSets) - { + foreach (string[] candidate in segmentSets) { if (ReferenceEquals(candidate, current)) continue; if (IsPrefix(candidate, current)) - { if (bestRoot == null || candidate.Length > bestRoot.Length) bestRoot = candidate; - } } roots.Add(bestRoot ?? current); @@ -619,16 +570,15 @@ static bool IsPrefix(string[] parent, string[] child) var results = new List(segmentSets.Count); - for (int i = 0; i < segmentSets.Count; i++) - { - var segment = segmentSets[i]; - var root = roots[i]; + for (var i = 0; i < segmentSets.Count; i++) { + string[] segment = segmentSets[i]; + string[] root = roots[i]; string leaf = SanitizeName(segment[^1]); var sb = new StringBuilder("widgets"); - var parentParts = root.SkipLast(1).ToArray(); + string[] parentParts = root.SkipLast(1).ToArray(); string bucketSource = parentParts.Length > 0 ? string.Join("/", parentParts).ToLowerInvariant() @@ -639,8 +589,7 @@ static bool IsPrefix(string[] parent, string[] child) int start = root.Length - 1; - for (int j = start; j < segment.Length; j++) - { + for (int j = start; j < segment.Length; j++) { sb.Append('/'); sb.Append(SanitizeName(segment[j])); } @@ -651,19 +600,21 @@ static bool IsPrefix(string[] parent, string[] child) return results; } - private static string HashSegment(string segment) - { + private static string HashSegment(string segment) { using var sha = SHA256.Create(); - var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(segment)); + byte[] bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(segment)); return Convert.ToHexString(bytes)[..SegmentHashLength].ToLowerInvariant(); } - private static string SanitizeName(string name) => SafeFileName.Sanitize(name); + private static string SanitizeName(string name) { + return SafeFileName.Sanitize(name); + } + #endregion #region TYPES - public class ImportResult - { + + public class ImportResult { public Route? Route { get; init; } public List NewWidgets { get; init; } = []; public List NewCssVariables { get; init; } = []; @@ -680,18 +631,21 @@ public class ImportResult RouteIsNew || NewWidgets.Count > 0 || NewCssVariables.Count > 0 || NewJsVariables.Count > 0 || RepointedWidgets.Count > 0 || (MergedRouteName != null && MergedRouteId != null); - public static ImportResult Fail(string reason) => - new ImportResult { Failed = true, FailReason = reason }; + + public static ImportResult Fail(string reason) { + return new ImportResult { Failed = true, FailReason = reason }; + } } + [ExcludeFromCodeCoverage] private record PackReference(string PackId, string Version, string Entry, string ZipPath); - private class ExportPlan - { + private class ExportPlan { public Dictionary WidgetFolderMap { get; } = new(); // debug helper public Dictionary WidgetPacks { get; } = new(); public List<(string Src, string ZipEntry)> FileCopies { get; } = []; public Dictionary> VariableRewrites { get; } = new(); } + #endregion } \ No newline at end of file diff --git a/SubathonManager.Data/Overlays/OverlayPackInstaller.cs b/SubathonManager.Data/Overlays/OverlayPackInstaller.cs index 00b5d5ea..69de2b82 100644 --- a/SubathonManager.Data/Overlays/OverlayPackInstaller.cs +++ b/SubathonManager.Data/Overlays/OverlayPackInstaller.cs @@ -3,8 +3,7 @@ namespace SubathonManager.Data.Overlays; -public class OverlayManifest -{ +public class OverlayManifest { public string FormatVersion { get; init; } = "1"; public string AppVersion { get; init; } = string.Empty; public string Name { get; init; } = string.Empty; @@ -13,68 +12,58 @@ public class OverlayManifest public List Tags { get; init; } = []; } -public static class OverlayPackInstaller -{ +public static class OverlayPackInstaller { public const string ManifestFileName = "overlay.json"; - public record InstalledOverlay(OverlayManifest Manifest, string ArchiveFile, string UnpackDir); - public static OverlayManifest? ReadManifest(string smoPath) - { - try - { + public static OverlayManifest? ReadManifest(string smoPath) { + try { using var stream = new FileStream(smoPath, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - var entry = archive.Entries.FirstOrDefault(e => + ZipArchiveEntry? entry = archive.Entries.FirstOrDefault(e => string.Equals(e.FullName.Replace('\\', '/'), ManifestFileName, StringComparison.OrdinalIgnoreCase)); if (entry == null) return null; using var reader = new StreamReader(entry.Open()); - using var doc = JsonDocument.Parse(reader.ReadToEnd()); + using JsonDocument doc = JsonDocument.Parse(reader.ReadToEnd()); return Parse(doc.RootElement, smoPath); } - catch - { + catch { return null; } } - public static InstalledOverlay? Install(string smoPath) - { - var manifest = ReadManifest(smoPath); + public static InstalledOverlay? Install(string smoPath) { + OverlayManifest? manifest = ReadManifest(smoPath); if (manifest == null) return null; string archiveTarget = OverlayPackPaths.ArchiveFile(manifest.Author, manifest.Name, manifest.Version); string unpackDir = OverlayPackPaths.UnpackDir(manifest.Author, manifest.Name); - try - { + try { Directory.CreateDirectory(Path.GetDirectoryName(archiveTarget)!); if (!string.Equals(Path.GetFullPath(smoPath), Path.GetFullPath(archiveTarget), StringComparison.OrdinalIgnoreCase)) - File.Copy(smoPath, archiveTarget, overwrite: true); + File.Copy(smoPath, archiveTarget, true); Directory.CreateDirectory(unpackDir); ExtractSafely(archiveTarget, unpackDir); } - catch - { + catch { return null; } return new InstalledOverlay(manifest, archiveTarget, unpackDir); } - private static void ExtractSafely(string archivePath, string targetDir) - { + private static void ExtractSafely(string archivePath, string targetDir) { string root = Path.GetFullPath(targetDir) + Path.DirectorySeparatorChar; using var stream = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - foreach (var entry in archive.Entries) - { + foreach (ZipArchiveEntry entry in archive.Entries) { if (string.IsNullOrEmpty(entry.Name)) continue; // directory marker string relative = entry.FullName.Replace('\\', '/').TrimStart('/'); @@ -84,13 +73,12 @@ private static void ExtractSafely(string archivePath, string targetDir) if (!destination.StartsWith(root, StringComparison.OrdinalIgnoreCase)) continue; Directory.CreateDirectory(Path.GetDirectoryName(destination)!); - entry.ExtractToFile(destination, overwrite: true); + entry.ExtractToFile(destination, true); } } - private static OverlayManifest Parse(JsonElement root, string smoPath) - { - var route = root.TryGetProperty("route", out var r) ? r : default; + private static OverlayManifest Parse(JsonElement root, string smoPath) { + JsonElement route = root.TryGetProperty("route", out JsonElement r) ? r : default; string name = Str(route, "name"); if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileNameWithoutExtension(smoPath); @@ -99,8 +87,7 @@ private static OverlayManifest Parse(JsonElement root, string smoPath) if (string.IsNullOrWhiteSpace(version)) version = Str(root, "version"); if (string.IsNullOrWhiteSpace(version)) version = "1.0.0"; - return new OverlayManifest - { + return new OverlayManifest { FormatVersion = Str(root, "format_version"), AppVersion = Str(root, "app_version"), Name = name, @@ -110,16 +97,16 @@ private static OverlayManifest Parse(JsonElement root, string smoPath) }; } - private static string Str(JsonElement el, string name) - => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out var v) && - v.ValueKind == JsonValueKind.String + private static string Str(JsonElement el, string name) { + return el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out JsonElement v) && + v.ValueKind == JsonValueKind.String ? v.GetString() ?? string.Empty : string.Empty; + } - private static List StrList(JsonElement el, string name) - { + private static List StrList(JsonElement el, string name) { if (el.ValueKind != JsonValueKind.Object || - !el.TryGetProperty(name, out var v) || + !el.TryGetProperty(name, out JsonElement v) || v.ValueKind != JsonValueKind.Array) return []; @@ -129,4 +116,6 @@ private static List StrList(JsonElement el, string name) .Where(s => !string.IsNullOrWhiteSpace(s)) .ToList(); } -} + + public record InstalledOverlay(OverlayManifest Manifest, string ArchiveFile, string UnpackDir); +} \ No newline at end of file diff --git a/SubathonManager.Data/Overlays/OverlayPackPaths.cs b/SubathonManager.Data/Overlays/OverlayPackPaths.cs index 6c7a7f2f..dd3b4648 100644 --- a/SubathonManager.Data/Overlays/OverlayPackPaths.cs +++ b/SubathonManager.Data/Overlays/OverlayPackPaths.cs @@ -1,18 +1,17 @@ using System.Text.RegularExpressions; using SubathonManager.Core; using SubathonManager.Data.Widgets; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Data.Overlays; -public static partial class OverlayPackPaths -{ +public static partial class OverlayPackPaths { public const string OverlayExtension = ".smo"; public const string UnpackFolderName = "unpack"; public static string ImportsRoot => Path.GetFullPath(Path.Combine("./imports", "overlays")); - public static string OverlayRoot(string author, string name) - { + public static string OverlayRoot(string author, string name) { string authorFolder = WidgetPackPaths.Slug(author); if (string.IsNullOrEmpty(authorFolder)) authorFolder = "unknown"; @@ -22,37 +21,33 @@ public static string OverlayRoot(string author, string name) return Path.Combine(ImportsRoot, authorFolder, nameFolder); } - public static string ArchiveFile(string author, string name, string version) - { + public static string ArchiveFile(string author, string name, string version) { string versionFile = SanitizeSegment(version); if (string.IsNullOrEmpty(versionFile)) versionFile = "1.0.0"; return Path.Combine(OverlayRoot(author, name), versionFile + OverlayExtension); } - public static string UnpackDir(string author, string name) - => Path.Combine(OverlayRoot(author, name), UnpackFolderName); + public static string UnpackDir(string author, string name) { + return Path.Combine(OverlayRoot(author, name), UnpackFolderName); + } - public static List ImportedVersions(string author, string name) - { + public static List ImportedVersions(string author, string name) { string folder = OverlayRoot(author, name); if (!Directory.Exists(folder)) return []; - try - { + try { return Directory.EnumerateFiles(folder, "*" + OverlayExtension) .Select(Path.GetFileNameWithoutExtension) .Where(v => !string.IsNullOrWhiteSpace(v)) .Select(v => v!) .ToList(); } - catch - { + catch { return []; } } - public static string RouteName(string name, string version) - { + public static string RouteName(string name, string version) { string trimmed = (name ?? string.Empty).Trim(); if (string.IsNullOrEmpty(trimmed)) trimmed = "Imported Overlay"; @@ -60,18 +55,17 @@ public static string RouteName(string name, string version) return string.IsNullOrWhiteSpace(display) ? trimmed : $"{trimmed} v{display}"; } - public static string BaseRouteName(string? routeName) - { - return string.IsNullOrWhiteSpace(routeName) ? string.Empty : - VersionSuffixRegex().Replace(routeName.Trim(), string.Empty).Trim(); + public static string BaseRouteName(string? routeName) { + return string.IsNullOrWhiteSpace(routeName) + ? string.Empty + : VersionSuffixRegex().Replace(routeName.Trim(), string.Empty).Trim(); } [GeneratedRegex(@"\s+v\d[\w.\-]*$", RegexOptions.IgnoreCase)] private static partial Regex VersionSuffixRegex(); - public static string BuildFileName(string author, string name, string version) - { - var parts = new[] { author, name, version } + public static string BuildFileName(string author, string name, string version) { + IEnumerable parts = new[] { author, name, version } .Select(p => SanitizeSegment(p).Replace(' ', '-')) .Where(p => !string.IsNullOrWhiteSpace(p)); @@ -79,5 +73,7 @@ public static string BuildFileName(string author, string name, string version) return (string.IsNullOrWhiteSpace(joined) ? "overlay" : joined) + OverlayExtension; } - private static string SanitizeSegment(string? value) => SafeFileName.Sanitize(value); -} + private static string SanitizeSegment(string? value) { + return SafeFileName.Sanitize(value); + } +} \ No newline at end of file diff --git a/SubathonManager.Data/SqliteSetup.cs b/SubathonManager.Data/SqliteSetup.cs new file mode 100644 index 00000000..124fee13 --- /dev/null +++ b/SubathonManager.Data/SqliteSetup.cs @@ -0,0 +1,53 @@ +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace SubathonManager.Data; + +[ExcludeFromCodeCoverage] +public static class SqliteSetup { + private const int BusyTimeoutMs = 30_000; + + public static string ConnectionString(string dbPath) { + return new SqliteConnectionStringBuilder { + DataSource = dbPath, + DefaultTimeout = BusyTimeoutMs / 1000 + }.ToString(); + } + + public static DbContextOptionsBuilder UseAppSqlite(this DbContextOptionsBuilder options, string dbPath) { + return options + .UseSqlite(ConnectionString(dbPath)) + .AddInterceptors(PragmaInterceptor.Instance); + } + + [ExcludeFromCodeCoverage] + private sealed class PragmaInterceptor : DbConnectionInterceptor { + public static readonly PragmaInterceptor Instance = new(); + + public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData) { + Apply(connection); + } + + public override Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, + CancellationToken cancellationToken = default) { + Apply(connection); + return Task.CompletedTask; + } + + private static void Apply(DbConnection connection) { + try { + using DbCommand cmd = connection.CreateCommand(); + cmd.CommandText = $"PRAGMA busy_timeout={BusyTimeoutMs};" + + "PRAGMA journal_mode=WAL;" + + "PRAGMA synchronous=NORMAL;"; + cmd.ExecuteNonQuery(); + } + catch (Exception) { + /**/ + } + } + } +} diff --git a/SubathonManager.Data/StateValueHelper.cs b/SubathonManager.Data/StateValueHelper.cs index 0fdc706b..0cde6433 100644 --- a/SubathonManager.Data/StateValueHelper.cs +++ b/SubathonManager.Data/StateValueHelper.cs @@ -3,65 +3,64 @@ namespace SubathonManager.Data; -public static class StateValueHelper -{ - private static T TypeDefault() - { +public static class StateValueHelper { + private static T TypeDefault() { if (typeof(T) == typeof(string)) return (T)(object)""; - return default(T)!; + return default!; } - private static T Parse(string value, T fallback) - { - try { return (T)Convert.ChangeType(value, typeof(T)); } - catch { return fallback; } + private static T Parse(string value, T fallback) { + try { + return (T)Convert.ChangeType(value, typeof(T)); + } + catch { + return fallback; + } } - public static T Get(AppDbContext db, string name, T? defaultValue = default) - { + public static T Get(AppDbContext db, string name, T? defaultValue = default) { T def = defaultValue is not null ? defaultValue : TypeDefault(); - var row = db.StateValues.AsNoTracking().FirstOrDefault(sv => sv.Name == name); - return row == null ? def : Parse(row.Value, def); + StateValue? row = db.StateValues.AsNoTracking().FirstOrDefault(sv => sv.Name == name); + return row == null ? def : Parse(row.Value, def); } - public static async Task GetAsync(IDbContextFactory factory, string name, T? defaultValue = default) - { - await using var db = await factory.CreateDbContextAsync(); - return Get(db, name, defaultValue); + public static async Task GetAsync(IDbContextFactory factory, string name, + T? defaultValue = default) { + await using AppDbContext db = await factory.CreateDbContextAsync(); + return Get(db, name, defaultValue); } - public static async Task SetAsync(AppDbContext db, string name, T value) where T : notnull - { + public static async Task SetAsync(AppDbContext db, string name, T value) where T : notnull { string strVal = value.ToString() ?? ""; string typeName = typeof(T).Name; - var existing = await db.StateValues.FindAsync(name); - if (existing == null) + StateValue? existing = await db.StateValues.FindAsync(name); + if (existing == null) { db.StateValues.Add(new StateValue { Name = name, Value = strVal, TypeName = typeName }); - else - { + } + else { existing.Value = strVal; existing.TypeName = typeName; } + await db.SaveChangesAsync(); } - public static async Task SetAsync(IDbContextFactory factory, string name, T value) where T : notnull - { - await using var db = await factory.CreateDbContextAsync(); + public static async Task SetAsync(IDbContextFactory factory, string name, T value) + where T : notnull { + await using AppDbContext db = await factory.CreateDbContextAsync(); await SetAsync(db, name, value); } - public static void Set(AppDbContext db, string name, T value) where T : notnull - { + public static void Set(AppDbContext db, string name, T value) where T : notnull { string strVal = value.ToString() ?? ""; string typeName = typeof(T).Name; - var existing = db.StateValues.Find(name); - if (existing == null) + StateValue? existing = db.StateValues.Find(name); + if (existing == null) { db.StateValues.Add(new StateValue { Name = name, Value = strVal, TypeName = typeName }); - else - { + } + else { existing.Value = strVal; existing.TypeName = typeName; } } -} +} \ No newline at end of file diff --git a/SubathonManager.Data/SubathonValueConfigHelper.cs b/SubathonManager.Data/SubathonValueConfigHelper.cs index b112929f..6df34a7e 100644 --- a/SubathonManager.Data/SubathonValueConfigHelper.cs +++ b/SubathonManager.Data/SubathonValueConfigHelper.cs @@ -1,23 +1,18 @@ using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.EntityFrameworkCore; -using System.Text.Json.Serialization; -using SubathonManager.Core.Events; +using SubathonManager.Core; using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; using SubathonManager.Core.Models; -using SubathonManager.Core; + namespace SubathonManager.Data; -public class SubathonValueConfigHelper -{ - private readonly ILogger? _logger; - private readonly IDbContextFactory _factory; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - Converters = - { +public class SubathonValueConfigHelper { + private static readonly JsonSerializerOptions JsonOptions = new() { + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, PropertyNameCaseInsensitive = true, @@ -25,56 +20,53 @@ public class SubathonValueConfigHelper WriteIndented = true }; - public SubathonValueConfigHelper(IDbContextFactory? factory, ILogger? logger) - { + private readonly IDbContextFactory _factory; + private readonly ILogger? _logger; + + public SubathonValueConfigHelper(IDbContextFactory? factory, ILogger? logger) { _factory = factory ?? AppServices.Provider.GetRequiredService>(); _logger = logger ?? AppServices.Provider?.GetRequiredService>(); } - - public string GetAllAsJson() - { - using var db = _factory.CreateDbContext(); - var values = db.SubathonValues + public string GetAllAsJson() { + using AppDbContext db = _factory.CreateDbContext(); + + List values = db.SubathonValues .AsNoTracking() .ToList(); - var dtoList = values.Select(v => v.ToObject()); + IEnumerable dtoList = values.Select(v => v.ToObject()); return JsonSerializer.Serialize(dtoList, JsonOptions); } - - public async Task GetAllAsJsonAsync(List? filterSources = null) - { - if (filterSources == null || filterSources.Count == 0) filterSources = Enum.GetValues().ToList(); + + public async Task GetAllAsJsonAsync(List? filterSources = null) { + if (filterSources == null || filterSources.Count == 0) + filterSources = Enum.GetValues().ToList(); // filter by source list, future scope - await using var db = await _factory.CreateDbContextAsync(); + await using AppDbContext db = await _factory.CreateDbContextAsync(); - var values = await db.SubathonValues + List values = await db.SubathonValues .AsNoTracking() .ToListAsync(); - var dtoList = values.Where(v => filterSources.Contains(( + IEnumerable dtoList = values.Where(v => filterSources.Contains(( (SubathonEventType?)v.EventType).GetSource())).Select(v => v.ToObject()); return JsonSerializer.Serialize(dtoList, JsonOptions); } - public async Task PatchFromJsonDataAsync(JsonElement data) - { + public async Task PatchFromJsonDataAsync(JsonElement data) { string json = JsonSerializer.Serialize(data, JsonOptions); return await PatchFromJsonAsync(json); } - public async Task PatchFromJsonAsync(string json) - { + public async Task PatchFromJsonAsync(string json) { List? incoming = null; - List? success = new (); - try - { + List? success = new(); + try { incoming = JsonSerializer.Deserialize>(json, JsonOptions); } - catch (Exception ex) - { + catch (Exception ex) { string msg = "Could not parse Value Config Patch: " + json; ErrorMessageEvents.RaiseErrorEvent("ERROR", "SYSTEM", msg, DateTime.Now); _logger?.LogError(ex.Message, msg); @@ -84,25 +76,23 @@ public async Task PatchFromJsonAsync(string json) if (incoming == null || incoming.Count == 0) return -1; - await using var db = await _factory.CreateDbContextAsync(); + await using AppDbContext db = await _factory.CreateDbContextAsync(); - var dbValues = await db.SubathonValues.ToListAsync(); + List dbValues = await db.SubathonValues.ToListAsync(); - int patched = 0; + var patched = 0; - foreach (var dto in incoming) - { - var meta = dto.Meta; + foreach (SubathonValueDto dto in incoming) { + string meta = dto.Meta; if (dto is { Source: SubathonEventSource.GoAffPro, EventType: SubathonEventType.GoAffProOrder } - && !int.TryParse(dto.Meta, out _)) - { - if (!GoAffProStoreRegistry.TryGetByInternalName(dto.Meta, out var store) + && !int.TryParse(dto.Meta, out _)) { + if (!GoAffProStoreRegistry.TryGetByInternalName(dto.Meta, out GoAffProStore? store) && !GoAffProOrderHelper.TryGetStoreByOrderKey(dto.Meta, out store)) continue; meta = store.SiteId.ToString(); } - var match = dbValues.FirstOrDefault(v => + SubathonValue? match = dbValues.FirstOrDefault(v => v.EventType == dto.EventType && v.Meta == meta && ((SubathonEventType?)v.EventType).GetSource() == dto.Source @@ -111,26 +101,22 @@ public async Task PatchFromJsonAsync(string json) if (match == null) continue; - if (match.PatchByObject(dto)) - { + if (match.PatchByObject(dto)) { patched++; success.Add(dto); } } - try - { - if (patched > 0) - { + try { + if (patched > 0) { await db.SaveChangesAsync(); - var newData = await GetAllAsJsonAsync(); + string newData = await GetAllAsJsonAsync(); SubathonEvents.RaiseSubathonValueConfigRequested(newData); SubathonEvents.RaiseSubathonValueConfigUpdatedRemote(); SubathonEvents.RaiseSubathonValuesPatched(success); } } - catch (Exception ex) - { + catch (Exception ex) { string msg = "Could not save Value Config Patch: " + json; ErrorMessageEvents.RaiseErrorEvent("ERROR", "SYSTEM", msg, DateTime.Now); _logger?.LogError(ex.Message, msg); @@ -141,5 +127,4 @@ public async Task PatchFromJsonAsync(string json) return patched; } - } \ No newline at end of file diff --git a/SubathonManager.Data/WidgetEntityHelper.cs b/SubathonManager.Data/WidgetEntityHelper.cs index 72f0fd82..d1e48bdd 100644 --- a/SubathonManager.Data/WidgetEntityHelper.cs +++ b/SubathonManager.Data/WidgetEntityHelper.cs @@ -1,113 +1,96 @@ using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.EntityFrameworkCore; -using System.Text.RegularExpressions; -using SubathonManager.Core.Events; +using SubathonManager.Core; using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; -using SubathonManager.Core; using SubathonManager.Core.Objects; namespace SubathonManager.Data; -public class WidgetEntityHelper -{ +public class WidgetEntityHelper { + private static readonly JsonSerializerOptions JsonOptions = new() { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; - private readonly ILogger? _logger; private readonly IDbContextFactory _factory; + private readonly ILogger? _logger; + private readonly List _protectedVarNames = ["height", "width", "url", "author", "version"]; - public WidgetEntityHelper(IDbContextFactory? factory, ILogger? logger) - { + public WidgetEntityHelper(IDbContextFactory? factory, ILogger? logger) { _factory = factory ?? AppServices.Provider.GetRequiredService>(); _logger = logger ?? AppServices.Provider?.GetRequiredService>(); } - public void SyncCssVariables(Widget widget) - { - using var db = _factory.CreateDbContext(); - var extracted = widget.ExtractCssVariablesFromFiles(); - List extractedNames = new List(); + public void SyncCssVariables(Widget widget) { + using AppDbContext db = _factory.CreateDbContext(); + List extracted = widget.ExtractCssVariablesFromFiles(); + var extractedNames = new List(); - foreach (var variable in extracted) - { - var cssVar = db.CssVariables + foreach (CssVariable variable in extracted) { + CssVariable? cssVar = db.CssVariables .FirstOrDefault(v => v.WidgetId == widget.Id && v.Name == variable.Name); - if (cssVar == null) - { + if (cssVar == null) { db.CssVariables.Add(variable); _logger?.LogDebug($"[Widget {widget.Name}] Added new CSS variable: {variable.Name}"); } - else - { - if (cssVar.Type != variable.Type) - { - cssVar.Type = variable.Type; - } + else { + if (cssVar.Type != variable.Type) cssVar.Type = variable.Type; - if (cssVar.Description != variable.Description) - { - cssVar.Description = variable.Description; - } + if (cssVar.Description != variable.Description) cssVar.Description = variable.Description; } extractedNames.Add(variable.Name); } - + db.SaveChanges(); - foreach (var variable in db.CssVariables.AsNoTracking() + foreach (CssVariable variable in db.CssVariables.AsNoTracking() .Where(v => v.WidgetId == widget.Id && !extractedNames.Contains(v.Name)) .ToList()) - { db.CssVariables.Remove(variable); - } //dedupe var seenNames = new HashSet(); - foreach (var variable in db.CssVariables.AsNoTracking() + foreach (CssVariable variable in db.CssVariables.AsNoTracking() .Where(v => v.WidgetId == widget.Id) .ToList()) - { if (!seenNames.Add(variable.Name)) - { db.CssVariables.Remove(variable); - } - } db.SaveChanges(); } - public void SyncJsVariables(Widget widget) - { + public void SyncJsVariables(Widget widget) { WidgetMeta metadata = ExtractWidgetMetadataSync(widget.HtmlPath); - var oldUrl = widget.DocsUrl; + string? oldUrl = widget.DocsUrl; widget.DocsUrl = !string.IsNullOrWhiteSpace(metadata.Url) && Uri.IsWellFormedUriString(metadata.Url, UriKind.Absolute) && !metadata.Url.Trim().Equals(widget.DocsUrl) ? metadata.Url.Trim() : widget.DocsUrl; - var (jsVars, extractedNames, updatedVars) = LoadNewJsVariables(widget, metadata); + (List jsVars, List extractedNames, List updatedVars) = + LoadNewJsVariables(widget, metadata); - using var db = _factory.CreateDbContext(); - if (oldUrl != widget.DocsUrl) - { - db.Widgets.First(w => w.Id == widget.Id).DocsUrl = widget.DocsUrl; - } + using AppDbContext db = _factory.CreateDbContext(); + if (oldUrl != widget.DocsUrl) db.Widgets.First(w => w.Id == widget.Id).DocsUrl = widget.DocsUrl; db.JsVariables.AddRange(jsVars); // db.JsVariables.UpdateRange(updatedVars); - foreach (var updated in updatedVars) - { - var tracked = db.JsVariables + foreach (JsVariable updated in updatedVars) { + JsVariable? tracked = db.JsVariables .FirstOrDefault(v => v.WidgetId == widget.Id && v.Name == updated.Name); - if (tracked != null) - { + if (tracked != null) { tracked.Value = updated.Value; tracked.Description = updated.Description; tracked.Type = updated.Type; @@ -118,96 +101,79 @@ public void SyncJsVariables(Widget widget) _logger?.LogDebug($"[Widget {widget.Name}] Added new JS variables: {jsVars.Count}"); - foreach (var variable in db.JsVariables + foreach (JsVariable variable in db.JsVariables .Where(v => v.WidgetId == widget.Id && !extractedNames.Contains(v.Name)) - .ToList()) - { - if (variable.Type.IsFontVariable() && string.Equals(variable.Name, $"{variable.Type}s")) - { - continue; - } + .ToList()) { + if (variable.Type.IsFontVariable() && string.Equals(variable.Name, $"{variable.Type}s")) continue; db.JsVariables.Remove(variable); } var seenNames = new HashSet(); - foreach (var variable in db.JsVariables.AsNoTracking() + foreach (JsVariable variable in db.JsVariables.AsNoTracking() .Where(v => v.WidgetId == widget.Id) .ToList()) - { // dupe check if (!seenNames.Add(variable.Name)) - { db.JsVariables.Remove(variable); - } - } - - var existingFontTypesInDb = db.JsVariables + + List existingFontTypesInDb = db.JsVariables .Where(v => v.WidgetId == widget.Id && WidgetVariableTypeHelper.FontVariables.ToList().Contains(v.Type)) .Select(v => v.Type) .Distinct() .ToList(); - var missingFontTypes = WidgetVariableTypeHelper.FontVariables + List missingFontTypes = WidgetVariableTypeHelper.FontVariables .Where(x => !existingFontTypesInDb.Contains(x)) .ToList(); - foreach (var fontType in missingFontTypes) - { - db.JsVariables.Add(new JsVariable - { + foreach (WidgetVariableType fontType in missingFontTypes) + db.JsVariables.Add(new JsVariable { WidgetId = widget.Id, Type = fontType, Name = $"{fontType}s", Description = $"Custom font names to include from {fontType}s, comma separated", Value = string.Empty - }); - } + }); db.SaveChanges(); } - public (List, List, List) LoadNewJsVariables(Widget widget, Dictionary metadata) - { + public (List, List, List) LoadNewJsVariables(Widget widget, + Dictionary metadata) { return LoadNewJsVariables(widget, ConvertHtmlMetaToJsonMeta(metadata)); } - public (List, List, List) LoadNewJsVariables(Widget widget, WidgetMeta metadata) - { + public (List, List, List) LoadNewJsVariables(Widget widget, WidgetMeta metadata) { var extractedVars = new List(); var extractedNames = new List(); var updatedVars = new List(); - foreach (var (varName, metaVar) in metadata.Vars) - { + foreach ((string varName, WidgetMetaVar metaVar) in metadata.Vars) { if (string.IsNullOrEmpty(varName) || "/?<>~!@#$%^&*()_+=-{}|\\]['\";:,.".Contains(varName[0])) continue; if (extractedNames.Contains(varName)) continue; extractedNames.Add(varName); - var existingVar = widget.JsVariables.Find(v => v.Name == varName); - var description = metaVar.Description; + JsVariable? existingVar = widget.JsVariables.Find(v => v.Name == varName); + string description = metaVar.Description; - if (metaVar.Type == WidgetVariableType.StringSelect && existingVar != null) - { - var oldVals = existingVar.Value.Split(',').Select(v => v.Trim()).ToList(); - var newVals = metaVar.Options ?? ((string)metaVar.Value).Split(',').Select(v => v.Trim()).ToList(); - foreach (var v in newVals) - if (!oldVals.Contains(v)) oldVals.Add(v); + if (metaVar.Type == WidgetVariableType.StringSelect && existingVar != null) { + List oldVals = existingVar.Value.Split(',').Select(v => v.Trim()).ToList(); + List newVals = + metaVar.Options ?? ((string)metaVar.Value).Split(',').Select(v => v.Trim()).ToList(); + foreach (string v in newVals) + if (!oldVals.Contains(v)) + oldVals.Add(v); oldVals.RemoveAll(v => !newVals.Contains(v)); existingVar.Value = string.Join(",", oldVals); - if (!string.Equals(description, existingVar.Description)) - { - existingVar.Description = description; - } + if (!string.Equals(description, existingVar.Description)) existingVar.Description = description; updatedVars.Add(existingVar); continue; } - if (existingVar != null && existingVar.Type != WidgetVariableType.StringSelect) - { - if (!string.Equals(description, existingVar.Description) || existingVar.Type != metaVar.Type) - { + if (existingVar != null && existingVar.Type != WidgetVariableType.StringSelect) { + if (!string.Equals(description, existingVar.Description) || existingVar.Type != metaVar.Type) { existingVar.Description = description; existingVar.Type = metaVar.Type; updatedVars.Add(existingVar); @@ -216,19 +182,14 @@ public void SyncJsVariables(Widget widget) continue; } - var value = metaVar.ValueToString(); - + string value = metaVar.ValueToString(); + if (metaVar.Type is WidgetVariableType.EventTypeSelect or WidgetVariableType.EventSubTypeSelect) - { if (!string.IsNullOrWhiteSpace(value) && !Enum.TryParse(metaVar.Type.GetClsSingleType(), value, true, out _)) - { value = string.Empty; - } - } - - extractedVars.Add(new JsVariable - { + + extractedVars.Add(new JsVariable { Name = varName, WidgetId = widget.Id, Type = metaVar.Type, @@ -239,123 +200,100 @@ public void SyncJsVariables(Widget widget) return (extractedVars, extractedNames, updatedVars); } - - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNameCaseInsensitive = true, - Converters = { new JsonStringEnumConverter() } - }; - public WidgetMeta ExtractWidgetMetadataSync(string htmlpath) - { - var jsonPath = htmlpath + ".json"; - if (!EnsureMetaSidecar(htmlpath, jsonPath, out var packedFallback)) + public WidgetMeta ExtractWidgetMetadataSync(string htmlpath) { + string jsonPath = htmlpath + ".json"; + if (!EnsureMetaSidecar(htmlpath, jsonPath, out WidgetMeta packedFallback)) return packedFallback; - try - { - var json = WidgetFiles.Current.ReadAllText(jsonPath); + try { + string? json = WidgetFiles.Current.ReadAllText(jsonPath); if (json != null) return JsonSerializer.Deserialize(json, JsonOptions) ?? new WidgetMeta(); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "Failed to read widget meta JSON at {Path}", jsonPath); } return new WidgetMeta(); } - public Task ExtractWidgetMetadata(string htmlpath) - => Task.FromResult(ExtractWidgetMetadataSync(htmlpath)); - - private bool EnsureMetaSidecar(string htmlpath, string jsonPath, out WidgetMeta packedFallback) - { + public Task ExtractWidgetMetadata(string htmlpath) { + return Task.FromResult(ExtractWidgetMetadataSync(htmlpath)); + } + + private bool EnsureMetaSidecar(string htmlpath, string jsonPath, out WidgetMeta packedFallback) { packedFallback = new WidgetMeta(); if (WidgetFiles.Current.Exists(jsonPath)) return true; if (WidgetFiles.Current.IsPacked(htmlpath)) return false; - var html = WidgetFiles.Current.ReadAllText(htmlpath); + string? html = WidgetFiles.Current.ReadAllText(htmlpath); if (html == null) return false; - try - { - var meta = ConvertHtmlMetaToJsonMeta(GetMetaDataHtml(html)); + try { + WidgetMeta meta = ConvertHtmlMetaToJsonMeta(GetMetaDataHtml(html)); File.WriteAllText(jsonPath, JsonSerializer.Serialize(meta, JsonOptions)); _logger?.LogDebug("Wrote widget meta JSON to {Path}", jsonPath); return true; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "Failed to write widget meta JSON to {Path}", jsonPath); return false; } } - internal WidgetMeta ConvertHtmlMetaToJsonMeta(Dictionary data) - { - WidgetMeta meta = new() - { + internal WidgetMeta ConvertHtmlMetaToJsonMeta(Dictionary data) { + WidgetMeta meta = new() { Author = data.GetValueOrDefault("Author", string.Empty), Url = data.GetValueOrDefault("Url", string.Empty), - Width = int.TryParse(data.GetValueOrDefault("Width", "400"), out var width) ? width : 400, - Height = int.TryParse(data.GetValueOrDefault("Height", "200"), out var height) ? height : 200 + Width = int.TryParse(data.GetValueOrDefault("Width", "400"), out int width) ? width : 400, + Height = int.TryParse(data.GetValueOrDefault("Height", "200"), out int height) ? height : 200 }; - foreach (var key in data.Keys)//.Where(x => x.Contains('.'))) + foreach (string key in data.Keys) //.Where(x => x.Contains('.'))) { - var parts = key.Split('.'); - var varName = parts[0]; - if (_protectedVarNames.Contains(varName.ToLower())) continue; - if (parts.Length < 2) - { - parts = [varName, "String"]; // default case if missing - } + string[] parts = key.Split('.'); + string varName = parts[0]; + if (_protectedVarNames.Contains(varName.ToLower())) continue; + if (parts.Length < 2) parts = [varName, "String"]; // default case if missing if (string.IsNullOrEmpty(varName) || parts.Length < 2) continue; if (!Enum.TryParse(parts[1], true, out WidgetVariableType type)) continue; - var rawValue = data.GetValueOrDefault(key, string.Empty); + string rawValue = data.GetValueOrDefault(key, string.Empty); if (string.Equals(rawValue, "NONE", StringComparison.OrdinalIgnoreCase)) rawValue = string.Empty; - WidgetMetaVar wVar = new() - { + WidgetMetaVar wVar = new() { Name = varName, - Type = type, + Type = type }; - if (type is WidgetVariableType.StringSelect) - { - var options = rawValue.Split(',') + if (type is WidgetVariableType.StringSelect) { + List options = rawValue.Split(',') .Select(v => v.Trim()) .Where(v => !string.IsNullOrEmpty(v) && v != "NONE") .ToList(); wVar.Options = options; wVar.Value = options.Count > 0 ? options[0] : string.Empty; } - else if (type is WidgetVariableType.EventSubTypeSelect or WidgetVariableType.EventTypeSelect) - { + else if (type is WidgetVariableType.EventSubTypeSelect or WidgetVariableType.EventTypeSelect) { wVar.Value = rawValue; } - else if (type.IsListType()) - { - var items = rawValue.Split(',') + else if (type.IsListType()) { + List items = rawValue.Split(',') .Select(v => v.Trim()) .Where(v => !string.IsNullOrEmpty(v) && v != "NONE") .ToList(); wVar.Value = items; } - else - { - wVar.Value = type switch - { - WidgetVariableType.Boolean => bool.TryParse(rawValue, out var b) && b, - WidgetVariableType.Int => int.TryParse(rawValue, out var i) ? i : 0, - WidgetVariableType.Percent => int.TryParse(rawValue, out var i) ? i : 0, - WidgetVariableType.Float => float.TryParse(rawValue, out var d) ? d : 0, + else { + wVar.Value = type switch { + WidgetVariableType.Boolean => bool.TryParse(rawValue, out bool b) && b, + WidgetVariableType.Int => int.TryParse(rawValue, out int i) ? i : 0, + WidgetVariableType.Percent => int.TryParse(rawValue, out int i) ? i : 0, + WidgetVariableType.Float => float.TryParse(rawValue, out float d) ? d : 0, _ => rawValue }; } @@ -365,59 +303,58 @@ internal WidgetMeta ConvertHtmlMetaToJsonMeta(Dictionary data) return meta; } - - + + private Dictionary GetMetaDataHtml(string html) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); var pattern = @""; - var match = Regex.Match(html, pattern, RegexOptions.Singleline); + Match match = Regex.Match(html, pattern, RegexOptions.Singleline); if (!match.Success) return result; - var block = match.Groups[1].Value; + string block = match.Groups[1].Value; - var lines = block.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - - foreach (var line in lines) - { - var trimmed = line.Trim(); + string[] lines = block.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (string line in lines) { + string trimmed = line.Trim(); - var index = trimmed.IndexOf(':'); + int index = trimmed.IndexOf(':'); if (index <= 0 || index == trimmed.Length - 1) continue; - var key = trimmed.Substring(0, index).Trim(); - var value = trimmed.Substring(index + 1).Trim(); + string key = trimmed.Substring(0, index).Trim(); + string value = trimmed.Substring(index + 1).Trim(); if (!string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(value)) result[key] = value; } + return result; } - private async Task<(Widget?, DbContext?)> GetWidgetForUpdate(string widgetId, Dictionary data) - { - if (data.Count == 0 || !Guid.TryParse(widgetId, out var widgetGuid)) return (null, null); - var db = await _factory.CreateDbContextAsync(); - var widget = await db.Widgets.FirstOrDefaultAsync(w => w.Id == widgetGuid); + private async Task<(Widget?, DbContext?)> + GetWidgetForUpdate(string widgetId, Dictionary data) { + if (data.Count == 0 || !Guid.TryParse(widgetId, out Guid widgetGuid)) return (null, null); + AppDbContext db = await _factory.CreateDbContextAsync(); + Widget? widget = await db.Widgets.FirstOrDefaultAsync(w => w.Id == widgetGuid); return widget == null ? (null, db) : (widget, db); } - - public async Task UpdateWidgetScale(string widgetId, Dictionary data) - { + + public async Task UpdateWidgetScale(string widgetId, Dictionary data) { (Widget?, DbContext?) result = await GetWidgetForUpdate(widgetId, data); - var widget = result.Item1; - await using var db = result.Item2; + Widget? widget = result.Item1; + await using DbContext? db = result.Item2; if (widget == null || db == null) return false; - + float origX = widget.X; float origY = widget.Y; - if (data.TryGetValue("scaleX", out var sxElem) && sxElem.TryGetSingle(out var sx)) widget.ScaleX = sx; - if (data.TryGetValue("scaleY", out var syElem) && syElem.TryGetSingle(out var sy)) widget.ScaleY = sy; - if (data.TryGetValue("x", out var xElem) && xElem.TryGetSingle(out var x)) widget.X = x; - if (data.TryGetValue("y", out var yElem) && yElem.TryGetSingle(out var y)) widget.Y = y; - + if (data.TryGetValue("scaleX", out JsonElement sxElem) && sxElem.TryGetSingle(out float sx)) widget.ScaleX = sx; + if (data.TryGetValue("scaleY", out JsonElement syElem) && syElem.TryGetSingle(out float sy)) widget.ScaleY = sy; + if (data.TryGetValue("x", out JsonElement xElem) && xElem.TryGetSingle(out float x)) widget.X = x; + if (data.TryGetValue("y", out JsonElement yElem) && yElem.TryGetSingle(out float y)) widget.Y = y; + await db.SaveChangesAsync(); WidgetEvents.RaiseScaleUpdated(widget); if (!origX.Equals(widget.X) || !origY.Equals(widget.Y)) @@ -425,41 +362,37 @@ public async Task UpdateWidgetScale(string widgetId, Dictionary UpdateWidgetPosition(string widgetId, Dictionary data) - { + + public async Task UpdateWidgetPosition(string widgetId, Dictionary data) { (Widget?, DbContext?) result = await GetWidgetForUpdate(widgetId, data); - var widget = result.Item1; - await using var db = result.Item2; + Widget? widget = result.Item1; + await using DbContext? db = result.Item2; if (widget == null || db == null) return false; - - if (data.TryGetValue("x", out var xElem) && xElem.TryGetSingle(out var x)) widget.X = x; - if (data.TryGetValue("y", out var yElem) && yElem.TryGetSingle(out var y)) widget.Y = y; - if (data.TryGetValue("z", out var zElem) && zElem.TryGetInt32(out var z)) widget.Z = z; - + + if (data.TryGetValue("x", out JsonElement xElem) && xElem.TryGetSingle(out float x)) widget.X = x; + if (data.TryGetValue("y", out JsonElement yElem) && yElem.TryGetSingle(out float y)) widget.Y = y; + if (data.TryGetValue("z", out JsonElement zElem) && zElem.TryGetInt32(out int z)) widget.Z = z; + await db.SaveChangesAsync(); WidgetEvents.RaisePositionUpdated(widget); await db.Entry(widget).ReloadAsync(); return true; - } - - public async Task UpdateWidgetDimensions(string widgetId, Dictionary data) - { + + public async Task UpdateWidgetDimensions(string widgetId, Dictionary data) { (Widget?, DbContext?) result = await GetWidgetForUpdate(widgetId, data); - var widget = result.Item1; - await using var db = result.Item2; + Widget? widget = result.Item1; + await using DbContext? db = result.Item2; if (widget == null || db == null) return false; - - if (data.TryGetValue("width", out var wEl) && wEl.TryGetInt32(out int w)) widget.Width = w; - if (data.TryGetValue("height", out var hEl) && hEl.TryGetInt32(out int h)) widget.Height = h; - if (data.TryGetValue("x", out var xEl) && xEl.TryGetSingle(out float x)) widget.X = x; - if (data.TryGetValue("y", out var yEl) && yEl.TryGetSingle(out float y)) widget.Y = y; - + + if (data.TryGetValue("width", out JsonElement wEl) && wEl.TryGetInt32(out int w)) widget.Width = w; + if (data.TryGetValue("height", out JsonElement hEl) && hEl.TryGetInt32(out int h)) widget.Height = h; + if (data.TryGetValue("x", out JsonElement xEl) && xEl.TryGetSingle(out float x)) widget.X = x; + if (data.TryGetValue("y", out JsonElement yEl) && yEl.TryGetSingle(out float y)) widget.Y = y; + await db.SaveChangesAsync(); WidgetEvents.RaiseSizeUpdated(widget); await db.Entry(widget).ReloadAsync(); return true; } - } \ No newline at end of file diff --git a/SubathonManager.Data/WidgetPorter.cs b/SubathonManager.Data/WidgetPorter.cs index ad78360f..35313a0c 100644 --- a/SubathonManager.Data/WidgetPorter.cs +++ b/SubathonManager.Data/WidgetPorter.cs @@ -10,28 +10,26 @@ using SubathonManager.Core.Models; using SubathonManager.Core.Objects; using SubathonManager.Data.Widgets; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Data; -public static partial class WidgetPorter -{ +public static partial class WidgetPorter { public const string ManifestFileName = WidgetPackInstaller.ManifestFileName; public const string ContentFolder = "content"; public const string ExternalFolder = "_external"; public const string SharedFolder = "_shared"; public const string FormatVersion = "1"; - private static readonly JsonSerializerOptions MetaOptions = new() - { + private static readonly JsonSerializerOptions MetaOptions = new() { WriteIndented = true, PropertyNameCaseInsensitive = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, Converters = { new JsonStringEnumConverter() } }; - private static readonly JsonSerializerOptions ManifestOptions = new() - { + private static readonly JsonSerializerOptions ManifestOptions = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, Converters = { new JsonStringEnumConverter() } @@ -39,8 +37,7 @@ public static partial class WidgetPorter #region TYPES - public enum SmwEntryKind - { + public enum SmwEntryKind { Manifest, Entry, Css, @@ -49,8 +46,7 @@ public enum SmwEntryKind External } - public class SmwEntry - { + public class SmwEntry { public required string ZipEntry { get; init; } public string? AbsSource { get; init; } public SmwEntryKind Kind { get; init; } @@ -61,8 +57,7 @@ public class SmwEntry public string? UsageHint { get; init; } } - public class SmwExportOptions - { + public class SmwExportOptions { public string Name { get; init; } = string.Empty; public string Author { get; init; } = string.Empty; public string Group { get; init; } = string.Empty; @@ -77,41 +72,36 @@ public class SmwExportOptions public static readonly string[] PreviewExtensions = [".png", ".jpg", ".jpeg", ".webp", ".gif"]; - public static string PreviewEntryName(string sourcePath) - { + public static string PreviewEntryName(string sourcePath) { string ext = Path.GetExtension(sourcePath).ToLowerInvariant(); if (!PreviewExtensions.Contains(ext)) ext = ".png"; return "preview" + ext; } - - public static string? ExtractExistingPreview(Widget widget) - { - var location = WidgetPackPaths.Resolve(widget.HtmlPath); + + public static string? ExtractExistingPreview(Widget widget) { + WidgetPackPaths.PackLocation? location = WidgetPackPaths.Resolve(widget.HtmlPath); if (location == null) return null; - var manifest = WidgetPackInstaller.ReadManifest(location.PackFileStr); + WidgetPackManifest? manifest = WidgetPackInstaller.ReadManifest(location.PackFileStr); if (manifest == null || string.IsNullOrWhiteSpace(manifest.PreviewImage)) return null; - var bytes = WidgetFiles.Current.ReadAllBytes( + byte[]? bytes = WidgetFiles.Current.ReadAllBytes( WidgetPackPaths.EntryPathIn(location.MountRootStr, manifest.PreviewImage)); if (bytes == null) return null; - try - { + try { string temp = Path.Combine(Path.GetTempPath(), $"smw-preview-{Guid.NewGuid():N}{Path.GetExtension(manifest.PreviewImage)}"); File.WriteAllBytes(temp, bytes); return temp; } - catch - { + catch { return null; } } - public static List ParseTags(string? raw) - { + public static List ParseTags(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return []; var seen = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -120,8 +110,7 @@ public static List ParseTags(string? raw) .ToList(); } - public class ExportPlan - { + public class ExportPlan { public List Entries { get; } = []; public Dictionary VariableRewrites { get; } = new(StringComparer.OrdinalIgnoreCase); public string EntryZipPath { get; set; } = string.Empty; @@ -129,14 +118,15 @@ public class ExportPlan public Dictionary OptionalRewrites { get; } = new(StringComparer.OrdinalIgnoreCase); - public bool IsSelected(string zipEntry) - => Entries.Any(e => e.ZipEntry.Equals(zipEntry, StringComparison.OrdinalIgnoreCase) - && (e.Locked || e.DefaultSelected)); + public bool IsSelected(string zipEntry) { + return Entries.Any(e => e.ZipEntry.Equals(zipEntry, StringComparison.OrdinalIgnoreCase) + && (e.Locked || e.DefaultSelected)); + } - public string ResolveVarValue(JsVariable jsVar) - { - if (VariableRewrites.TryGetValue(jsVar.Name, out var rewritten)) return rewritten; - if (OptionalRewrites.TryGetValue(jsVar.Name, out var optional) && IsSelected(optional.ZipEntry)) + public string ResolveVarValue(JsVariable jsVar) { + if (VariableRewrites.TryGetValue(jsVar.Name, out string? rewritten)) return rewritten; + if (OptionalRewrites.TryGetValue(jsVar.Name, out (string ZipEntry, string Value) optional) && + IsSelected(optional.ZipEntry)) return optional.Value; return jsVar.Value ?? string.Empty; } @@ -146,34 +136,31 @@ public string ResolveVarValue(JsVariable jsVar) #region PLAN - public static ExportPlan BuildPlan(Widget widget) - { + public static ExportPlan BuildPlan(Widget widget) { var plan = new ExportPlan(); if (widget.Type.IsAsset() || !WidgetFiles.Current.Exists(widget.HtmlPath)) return plan; string widgetRoot = widget.GetPath(); - string htmlEntry = $"{ContentFolder}/{Path.GetFileName(widget.HtmlPath)}"; + var htmlEntry = $"{ContentFolder}/{Path.GetFileName(widget.HtmlPath)}"; plan.EntryZipPath = htmlEntry; - var cssPaths = widget.GetLinkedCssPaths(); + List cssPaths = widget.GetLinkedCssPaths(); var cssZipMap = new Dictionary(StringComparer.OrdinalIgnoreCase); var htmlLinkRewrites = new Dictionary(StringComparer.Ordinal); - foreach (var cssPath in cssPaths) - { + foreach (string cssPath in cssPaths) { string? entry = ToContentEntry(widgetRoot, cssPath); - if (entry == null) - { + if (entry == null) { entry = $"{ContentFolder}/{SharedFolder}/{Path.GetFileName(cssPath)}"; string original = GetHtmlLinkFor(widget, cssPath); if (!string.IsNullOrEmpty(original)) htmlLinkRewrites[original] = $"./{SharedFolder}/{Path.GetFileName(cssPath)}"; } + cssZipMap[cssPath] = entry; } - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = ManifestFileName, Kind = SmwEntryKind.Manifest, DefaultSelected = true, @@ -184,7 +171,7 @@ public static ExportPlan BuildPlan(Widget widget) string htmlText = WidgetFiles.Current.ReadAllText(widget.HtmlPath) ?? string.Empty; var hardcodedResources = new HashSet(ResourcePaths.FindReferences(htmlText), StringComparer.OrdinalIgnoreCase); - foreach (var cssPath in cssPaths) + foreach (string cssPath in cssPaths) hardcodedResources.UnionWith(ResourcePaths.FindReferences(WidgetFiles.Current.ReadAllText(cssPath))); Func? htmlGenerator = null; @@ -192,8 +179,7 @@ public static ExportPlan BuildPlan(Widget widget) htmlGenerator = _ => Encoding.UTF8.GetBytes( RewriteResourceUrls(RewriteHtmlLinks(htmlText, htmlLinkRewrites), htmlEntry, plan)); - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = htmlEntry, AbsSource = widget.HtmlPath, Kind = SmwEntryKind.Entry, @@ -202,8 +188,7 @@ public static ExportPlan BuildPlan(Widget widget) Generator = htmlGenerator }); - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = $"{htmlEntry}.json", AbsSource = WidgetFiles.Current.Exists(widget.HtmlPath + ".json") ? widget.HtmlPath + ".json" : null, Kind = SmwEntryKind.Meta, @@ -212,25 +197,22 @@ public static ExportPlan BuildPlan(Widget widget) }); var consumed = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var cssPath in cssPaths) - { + foreach (string cssPath in cssPaths) { string cssEntry = cssZipMap[cssPath]; - var varsInFile = ClaimCssVariables(widget, cssPath, consumed); + List varsInFile = ClaimCssVariables(widget, cssPath, consumed); string cssText = WidgetFiles.Current.ReadAllText(cssPath) ?? string.Empty; bool cssUsesResources = ResourcePaths.FindReferences(cssText).Any(); Func? cssGenerator = null; - if (varsInFile.Count > 0 || cssUsesResources) - { - var capturedVars = varsInFile; + if (varsInFile.Count > 0 || cssUsesResources) { + List capturedVars = varsInFile; string capturedEntry = cssEntry; cssGenerator = _ => Encoding.UTF8.GetBytes( RewriteResourceUrls(OverrideCssValues(cssText, capturedVars), capturedEntry, plan)); } - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = cssEntry, AbsSource = cssPath, Kind = SmwEntryKind.Css, @@ -246,9 +228,8 @@ public static ExportPlan BuildPlan(Widget widget) if (!hasMeta && !worthWriting) continue; string capturedMetaPath = cssMetaPath; - var capturedMetaVars = varsInFile; - plan.Entries.Add(new SmwEntry - { + List capturedMetaVars = varsInFile; + plan.Entries.Add(new SmwEntry { ZipEntry = $"{cssEntry}.json", AbsSource = hasMeta ? cssMetaPath : null, Kind = SmwEntryKind.Meta, @@ -259,13 +240,11 @@ public static ExportPlan BuildPlan(Widget widget) var claimed = new HashSet(plan.Entries.Select(en => en.ZipEntry), StringComparer.OrdinalIgnoreCase); - foreach (var file in WidgetFiles.Current.EnumerateFiles(widgetRoot)) - { + foreach (string file in WidgetFiles.Current.EnumerateFiles(widgetRoot)) { string? entry = ToContentEntry(widgetRoot, file); if (entry == null || !claimed.Add(entry)) continue; - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = entry, AbsSource = file, Kind = SmwEntryKind.Asset, @@ -279,25 +258,21 @@ public static ExportPlan BuildPlan(Widget widget) } private static void AddSharedResourceEntries(Widget widget, ExportPlan plan, HashSet claimed, - HashSet hardcodedResources) - { + HashSet hardcodedResources) { var varUsed = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var jsVar in widget.JsVariables) - { + foreach (JsVariable jsVar in widget.JsVariables) { if (!((WidgetVariableType?)jsVar.Type).IsFileVariable()) continue; if (ResourcePaths.RelativeFromUrl(jsVar.Value) is { } rel) varUsed[rel] = jsVar.Name; } - foreach (var rel in ResourcePaths.EnumerateRelative()) - { - string entry = $"{ContentFolder}/{ExternalFolder}/{ResourcePaths.BundleFolder}/{rel}"; + foreach (string rel in ResourcePaths.EnumerateRelative()) { + var entry = $"{ContentFolder}/{ExternalFolder}/{ResourcePaths.BundleFolder}/{rel}"; if (!claimed.Add(entry)) continue; - bool byVar = varUsed.TryGetValue(rel, out var varName); + bool byVar = varUsed.TryGetValue(rel, out string? varName); bool byMarkup = hardcodedResources.Contains(rel); - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = entry, AbsSource = ResourcePaths.ToLocalPath(ResourcePaths.UrlPrefix + rel), Kind = SmwEntryKind.External, @@ -305,7 +280,9 @@ private static void AddSharedResourceEntries(Widget widget, ExportPlan plan, Has InUse = byVar || byMarkup, UsageHint = byVar ? $"Used by variable \"{varName}\"" - : byMarkup ? "Referenced directly by this widget's html/css" : null + : byMarkup + ? "Referenced directly by this widget's html/css" + : null }); if (byVar) @@ -314,10 +291,9 @@ private static void AddSharedResourceEntries(Widget widget, ExportPlan plan, Has } } - private static void AddFileVariableEntries(Widget widget, string widgetRoot, ExportPlan plan, HashSet claimed) - { - foreach (var jsVar in widget.JsVariables) - { + private static void AddFileVariableEntries(Widget widget, string widgetRoot, ExportPlan plan, + HashSet claimed) { + foreach (JsVariable jsVar in widget.JsVariables) { if (!((WidgetVariableType?)jsVar.Type).IsFileVariable()) continue; if (string.IsNullOrWhiteSpace(jsVar.Value)) continue; if (ResourcePaths.IsResourceUrl(jsVar.Value)) continue; @@ -325,58 +301,48 @@ private static void AddFileVariableEntries(Widget widget, string widgetRoot, Exp bool isFolderType = jsVar.Type == WidgetVariableType.FolderPath; bool isRelative = jsVar.Value.StartsWith("./") || jsVar.Value.StartsWith("../"); - if (isRelative) - { + if (isRelative) { string rel = jsVar.Value.TrimStart('.').TrimStart('/'); - string prefix = $"{ContentFolder}/{rel}"; - foreach (var entry in plan.Entries.Where(en => + var prefix = $"{ContentFolder}/{rel}"; + foreach (SmwEntry entry in plan.Entries.Where(en => en.Kind == SmwEntryKind.Asset && (en.ZipEntry.Equals(prefix, StringComparison.OrdinalIgnoreCase) || en.ZipEntry.StartsWith(prefix.TrimEnd('/') + "/", StringComparison.OrdinalIgnoreCase)))) - { entry.DefaultSelected = true; - } continue; } if (!Path.IsPathRooted(jsVar.Value)) continue; - if (isFolderType && Directory.Exists(jsVar.Value)) - { + if (isFolderType && Directory.Exists(jsVar.Value)) { string varFolder = SanitizeName(jsVar.Name); - foreach (var file in Directory.EnumerateFiles(jsVar.Value, "*", - SearchOption.AllDirectories)) - { + foreach (string file in Directory.EnumerateFiles(jsVar.Value, "*", + SearchOption.AllDirectories)) { string rel = Path.GetRelativePath(jsVar.Value, file).Replace('\\', '/'); - string entry = $"{ContentFolder}/{ExternalFolder}/{varFolder}/{rel}"; - + var entry = $"{ContentFolder}/{ExternalFolder}/{varFolder}/{rel}"; + if (!claimed.Add(entry)) continue; - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = entry, AbsSource = file, Kind = SmwEntryKind.External, DefaultSelected = true }); } - + plan.VariableRewrites[jsVar.Name] = $"./{ExternalFolder}/{varFolder}"; } - else if (!isFolderType && File.Exists(jsVar.Value)) - { + else if (!isFolderType && File.Exists(jsVar.Value)) { string fileName = Path.GetFileName(jsVar.Value); - string entry = $"{ContentFolder}/{ExternalFolder}/{fileName}"; + var entry = $"{ContentFolder}/{ExternalFolder}/{fileName}"; if (claimed.Add(entry)) - { - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = entry, AbsSource = jsVar.Value, Kind = SmwEntryKind.External, DefaultSelected = true }); - } plan.VariableRewrites[jsVar.Name] = $"./{ExternalFolder}/{fileName}"; } } @@ -386,13 +352,10 @@ private static void AddFileVariableEntries(Widget widget, string widgetRoot, Exp #region EXPORT - public static async Task ExportWidgetAsync(ExportPlan plan, SmwExportOptions options, string outputPath) - { - var opts = options; + public static async Task ExportWidgetAsync(ExportPlan plan, SmwExportOptions options, string outputPath) { + SmwExportOptions opts = options; if (string.IsNullOrWhiteSpace(opts.AppVersion)) - { - opts = new SmwExportOptions - { + opts = new SmwExportOptions { Name = options.Name, Author = options.Author, Group = options.Group, @@ -401,90 +364,78 @@ public static async Task ExportWidgetAsync(ExportPlan plan, SmwExportOptions opt PreviewImagePath = options.PreviewImagePath, AppVersion = AppServices.AppVersion }; - } Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPath))!); await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write); - await using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: false); + await using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, false); var written = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var entry in plan.Entries) - { + foreach (SmwEntry entry in plan.Entries) { if (entry is { Locked: false, DefaultSelected: false }) continue; if (!written.Add(entry.ZipEntry)) continue; byte[]? bytes = null; - if (entry.Generator != null) - { + if (entry.Generator != null) { bytes = entry.Generator(opts); } - else if (entry.AbsSource != null) - { - if (File.Exists(entry.AbsSource)) - { + else if (entry.AbsSource != null) { + if (File.Exists(entry.AbsSource)) { await archive.CreateEntryFromFileAsync(entry.AbsSource, entry.ZipEntry, CompressionLevel.Optimal); continue; } + bytes = WidgetFiles.Current.ReadAllBytes(entry.AbsSource); } if (bytes == null) continue; - var zipEntry = archive.CreateEntry(entry.ZipEntry, CompressionLevel.Optimal); - await using var stream = await zipEntry.OpenAsync(); + ZipArchiveEntry zipEntry = archive.CreateEntry(entry.ZipEntry, CompressionLevel.Optimal); + await using Stream stream = await zipEntry.OpenAsync(); await stream.WriteAsync(bytes); } - if (!string.IsNullOrWhiteSpace(opts.PreviewImagePath) && File.Exists(opts.PreviewImagePath)) - { + if (!string.IsNullOrWhiteSpace(opts.PreviewImagePath) && File.Exists(opts.PreviewImagePath)) { string previewEntry = PreviewEntryName(opts.PreviewImagePath); if (written.Add(previewEntry)) await archive.CreateEntryFromFileAsync(opts.PreviewImagePath, previewEntry, CompressionLevel.Optimal); } } - + public static string ExportsDirectory => Path.GetFullPath(Path.Combine("./exports", "widgets")); - public static WidgetMeta ReadExistingMeta(Widget widget) - { - var json = WidgetFiles.Current.ReadAllText(widget.HtmlPath + ".json"); + public static WidgetMeta ReadExistingMeta(Widget widget) { + string? json = WidgetFiles.Current.ReadAllText(widget.HtmlPath + ".json"); if (json == null) return new WidgetMeta(); - try - { + try { return JsonSerializer.Deserialize(json, MetaOptions) ?? new WidgetMeta(); } - catch - { + catch { return new WidgetMeta(); } } - public static string BuildFileName(string author, string group, string name, string version) - { - var parts = new[] { author, WidgetPackPaths.NormalizeGroup(group), name, version } + public static string BuildFileName(string author, string group, string name, string version) { + IEnumerable parts = new[] { author, WidgetPackPaths.NormalizeGroup(group), name, version } .Select(p => SanitizeName(p?.Trim() ?? string.Empty).Replace(' ', '-')) .Where(p => !string.IsNullOrWhiteSpace(p)); return $"{string.Join('_', parts)}.smw"; } - private static string BuildManifest(Widget widget, ExportPlan plan, SmwExportOptions opts) - { + private static string BuildManifest(Widget widget, ExportPlan plan, SmwExportOptions opts) { string name = string.IsNullOrWhiteSpace(opts.Name) ? widget.Name : opts.Name; string group = WidgetPackPaths.NormalizeGroup(opts.Group); - string packId = WidgetPackPaths.TryResolve(widget.HtmlPath, - out _, out _, out var existingPackId, out _) + string packId = WidgetPackPaths.TryResolve(widget.HtmlPath, + out _, out _, out string existingPackId, out _) ? existingPackId : WidgetPackPaths.MakePackId(opts.Author, group, name); - var obj = new - { + var obj = new { version = FormatVersion, app_version = opts.AppVersion, exported_at = DateTime.UtcNow, - widget = new - { + widget = new { pack_id = packId, name, author = opts.Author, @@ -508,17 +459,15 @@ private static string BuildManifest(Widget widget, ExportPlan plan, SmwExportOpt #region OVERRIDES - private static List ClaimCssVariables(Widget widget, string cssPath, HashSet consumed) - { + private static List ClaimCssVariables(Widget widget, string cssPath, HashSet consumed) { var result = new List(); string? content = WidgetFiles.Current.ReadAllText(cssPath); if (content == null) return result; - foreach (Match match in CssVarRegex().Matches(content)) - { + foreach (Match match in CssVarRegex().Matches(content)) { string name = match.Groups[1].Value.Trim(); if (!consumed.Add(name)) continue; - var dbVar = widget.CssVariables.FirstOrDefault(v => + CssVariable? dbVar = widget.CssVariables.FirstOrDefault(v => string.Equals(v.Name, name, StringComparison.OrdinalIgnoreCase)); if (dbVar != null) result.Add(dbVar); } @@ -526,8 +475,7 @@ private static List ClaimCssVariables(Widget widget, string cssPath return result; } - private static string RewriteResourceUrls(string text, string ownZipEntry, ExportPlan plan) - { + private static string RewriteResourceUrls(string text, string ownZipEntry, ExportPlan plan) { if (string.IsNullOrEmpty(text)) return text; int depth = ownZipEntry.Split('/').Length - 2; @@ -539,37 +487,33 @@ private static string RewriteResourceUrls(string text, string ownZipEntry, Expor : null); } - private static string OverrideCssValues(string css, List variables) - { - foreach (var variable in variables) - { + private static string OverrideCssValues(string css, List variables) { + foreach (CssVariable variable in variables) { if (string.IsNullOrWhiteSpace(variable.Value)) continue; var pattern = new Regex($@"(--{Regex.Escape(variable.Name)}\s*:\s*)([^;]*)(;)"); css = pattern.Replace(css, m => $"{m.Groups[1].Value}{variable.Value}{m.Groups[3].Value}", 1); } + return css; } - private static string BuildCssMetaJson(string existingMetaPath, List variables) - { + private static string BuildCssMetaJson(string existingMetaPath, List variables) { Dictionary> meta = new(StringComparer.Ordinal); - var existingJson = WidgetFiles.Current.ReadAllText(existingMetaPath); + string? existingJson = WidgetFiles.Current.ReadAllText(existingMetaPath); if (existingJson != null) - { - try - { + try { meta = JsonSerializer.Deserialize>>(existingJson) ?? meta; } - catch { /**/ } - } + catch { + /**/ + } - foreach (var variable in variables) - { - if (!meta.TryGetValue(variable.Name, out var entry)) - { + foreach (CssVariable variable in variables) { + if (!meta.TryGetValue(variable.Name, out Dictionary? entry)) { entry = new Dictionary(StringComparer.Ordinal); meta[variable.Name] = entry; } + entry["type"] = variable.Type.ToString(); entry["description"] = variable.Description ?? string.Empty; } @@ -577,8 +521,7 @@ private static string BuildCssMetaJson(string existingMetaPath, List items = value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .ToList(); - switch (type) - { + switch (type) { case WidgetVariableType.StringSelect: metaVar.Options = items; return items.Count > 0 ? items[0] : string.Empty; case WidgetVariableType.Boolean: - return bool.TryParse(value, out var b) && b; + return bool.TryParse(value, out bool b) && b; case WidgetVariableType.Int: case WidgetVariableType.Percent: - return int.TryParse(value, out var i) ? i : 0; + return int.TryParse(value, out int i) ? i : 0; case WidgetVariableType.Float: - return float.TryParse(value, out var f) ? f : 0f; + return float.TryParse(value, out float f) ? f : 0f; default: if (type.IsListType()) return items; @@ -634,40 +574,39 @@ private static object ToMetaValue(WidgetVariableType type, string value, WidgetM #region HELPERS - private static string? ToContentEntry(string widgetRoot, string absPath) - { + private static string? ToContentEntry(string widgetRoot, string absPath) { string rel = Path.GetRelativePath(widgetRoot, absPath).Replace('\\', '/'); if (rel.StartsWith("../") || rel == ".." || Path.IsPathRooted(rel)) return null; return $"{ContentFolder}/{rel}"; } - private static string GetHtmlLinkFor(Widget widget, string cssPath) - { + private static string GetHtmlLinkFor(Widget widget, string cssPath) { string baseDir = Path.GetDirectoryName(widget.HtmlPath)!; - foreach (Match match in Widget.CssLinkMatches(WidgetFiles.Current.ReadAllText(widget.HtmlPath) ?? string.Empty)) - { + foreach (Match match in + Widget.CssLinkMatches(WidgetFiles.Current.ReadAllText(widget.HtmlPath) ?? string.Empty)) { string href = match.Groups[1].Value; string resolved = Path.IsPathRooted(href) ? href : Path.Combine(baseDir, href); - if (string.Equals(Path.GetFullPath(resolved), Path.GetFullPath(cssPath), StringComparison.OrdinalIgnoreCase)) + if (string.Equals(Path.GetFullPath(resolved), Path.GetFullPath(cssPath), + StringComparison.OrdinalIgnoreCase)) return href; } + return string.Empty; } - private static string RewriteHtmlLinks(string html, Dictionary rewrites) - { - foreach (var (original, replacement) in rewrites) - { + private static string RewriteHtmlLinks(string html, Dictionary rewrites) { + foreach ((string original, string replacement) in rewrites) html = html.Replace($"\"{original}\"", $"\"{replacement}\"") - .Replace($"'{original}'", $"'{replacement}'"); - } + .Replace($"'{original}'", $"'{replacement}'"); return html; } - private static string SanitizeName(string name) => SafeFileName.Sanitize(name); + private static string SanitizeName(string name) { + return SafeFileName.Sanitize(name); + } [GeneratedRegex(@"--([a-zA-Z0-9-_]+)\s*:\s*([^;]+);")] private static partial Regex CssVarRegex(); #endregion -} +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetCatalog.cs b/SubathonManager.Data/Widgets/WidgetCatalog.cs index 82f8471d..cf255314 100644 --- a/SubathonManager.Data/Widgets/WidgetCatalog.cs +++ b/SubathonManager.Data/Widgets/WidgetCatalog.cs @@ -4,98 +4,92 @@ using Microsoft.EntityFrameworkCore; using SubathonManager.Core.Enums; using SubathonManager.Core.Models; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Data.Widgets; -public static class WidgetCatalog -{ +public static class WidgetCatalog { public static string PresetsRoot => WidgetPackPaths.PresetsRoot; public static string ImportsRoot => WidgetPackPaths.ImportsRoot; public static string PreviewCacheRoot => Path.GetFullPath(Path.Combine("./cache", "widget-previews")); - public record ScanRoot(string Path, WidgetCatalogSource Source); - - public static IEnumerable Roots() - { + public static IEnumerable Roots() { yield return new ScanRoot(PresetsRoot, WidgetCatalogSource.Preset); yield return new ScanRoot(ImportsRoot, WidgetCatalogSource.Imported); } public static async Task> RefreshAsync( - IDbContextFactory factory, CancellationToken ct = default) - { - await using var db = await factory.CreateDbContextAsync(ct); + IDbContextFactory factory, CancellationToken ct = default) { + await using AppDbContext db = await factory.CreateDbContextAsync(ct); - var rows = await db.WidgetCatalogEntries.ToListAsync(ct); + List rows = await db.WidgetCatalogEntries.ToListAsync(ct); var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var row in rows) + foreach (WidgetCatalogEntry row in rows) byPath.TryAdd(row.PackPath, row); var live = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var now = DateTime.UtcNow; - - foreach (var root in Roots()) - { - foreach (var file in EnumeratePackages(root.Path)) - { - ct.ThrowIfCancellationRequested(); + DateTime now = DateTime.UtcNow; - string stored = ToStoredPath(file); - if (!seen.Add(stored)) continue; + foreach (ScanRoot root in Roots()) + foreach (string file in EnumeratePackages(root.Path)) { + ct.ThrowIfCancellationRequested(); - FileInfo info; - try { info = new FileInfo(file); } - catch { continue; } + string stored = ToStoredPath(file); + if (!seen.Add(stored)) continue; - byPath.TryGetValue(stored, out var entry); + FileInfo info; + try { + info = new FileInfo(file); + } + catch { + continue; + } - bool unchanged = entry != null && - entry.FileSize == info.Length && - entry.FileModifiedTicks == info.LastWriteTimeUtc.Ticks; + byPath.TryGetValue(stored, out WidgetCatalogEntry? entry); - if (unchanged) - { - if (!string.IsNullOrWhiteSpace(entry!.PreviewImage) && - (string.IsNullOrWhiteSpace(entry.PreviewCachePath) || !File.Exists(entry.PreviewCachePath))) - entry.PreviewCachePath = ExtractPreview(file, entry.PreviewImage) ?? string.Empty; + bool unchanged = entry != null && + entry.FileSize == info.Length && + entry.FileModifiedTicks == info.LastWriteTimeUtc.Ticks; - entry.Source = root.Source; - entry.LastSeenUtc = now; - live.Add(entry); - continue; - } + if (unchanged) { + if (!string.IsNullOrWhiteSpace(entry!.PreviewImage) && + (string.IsNullOrWhiteSpace(entry.PreviewCachePath) || !File.Exists(entry.PreviewCachePath))) + entry.PreviewCachePath = ExtractPreview(file, entry.PreviewImage) ?? string.Empty; - var manifest = WidgetPackInstaller.ReadManifest(file); - if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) - { - if (entry != null) db.WidgetCatalogEntries.Remove(entry); - continue; - } + entry.Source = root.Source; + entry.LastSeenUtc = now; + live.Add(entry); + continue; + } - if (entry == null) - { - entry = new WidgetCatalogEntry { PackPath = stored }; - db.WidgetCatalogEntries.Add(entry); - } + WidgetPackManifest? manifest = WidgetPackInstaller.ReadManifest(file); + if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) { + if (entry != null) db.WidgetCatalogEntries.Remove(entry); + continue; + } - Fill(entry, file, info, manifest, root.Source, now); - live.Add(entry); + if (entry == null) { + entry = new WidgetCatalogEntry { PackPath = stored }; + db.WidgetCatalogEntries.Add(entry); } + + Fill(entry, file, info, manifest, root.Source, now); + live.Add(entry); } - foreach (var row in rows.Where(row => !seen.Contains(row.PackPath))) - { + foreach (WidgetCatalogEntry row in rows.Where(row => !seen.Contains(row.PackPath))) { db.WidgetCatalogEntries.Remove(row); DeletePreview(row.PreviewCachePath); } - try - { + try { await db.SaveChangesAsync(ct); } - catch { /**/ } + catch { + /**/ + } BuildIndex(live); @@ -108,8 +102,7 @@ public static async Task> RefreshAsync( } private static void Fill(WidgetCatalogEntry entry, string file, FileInfo info, - WidgetPackManifest manifest, WidgetCatalogSource source, DateTime now) - { + WidgetPackManifest manifest, WidgetCatalogSource source, DateTime now) { entry.FileSize = info.Length; entry.FileModifiedTicks = info.LastWriteTimeUtc.Ticks; entry.Source = source; @@ -122,93 +115,98 @@ private static void Fill(WidgetCatalogEntry entry, string file, FileInfo info, entry.DocsUrl = manifest.DocsUrl; entry.Tags = string.Join(", ", manifest.Tags); entry.PreviewImage = manifest.PreviewImage; - entry.PreviewCachePath = ExtractPreview(file, manifest.PreviewImage, force: true) ?? string.Empty; + entry.PreviewCachePath = ExtractPreview(file, manifest.PreviewImage, true) ?? string.Empty; entry.ScaleX = manifest.ScaleX; entry.ScaleY = manifest.ScaleY; entry.LastSeenUtc = now; } public static async Task RefreshEntryAsync(IDbContextFactory factory, - string packPath, CancellationToken ct = default) - { + string packPath, CancellationToken ct = default) { string file = ToAbsolutePath(packPath); - await using var db = await factory.CreateDbContextAsync(ct); - var entry = await db.WidgetCatalogEntries.FirstOrDefaultAsync(e => e.PackPath == packPath, ct); + await using AppDbContext db = await factory.CreateDbContextAsync(ct); + WidgetCatalogEntry? entry = await db.WidgetCatalogEntries.FirstOrDefaultAsync(e => e.PackPath == packPath, ct); - if (!File.Exists(file)) - { - if (entry != null) - { + if (!File.Exists(file)) { + if (entry != null) { db.WidgetCatalogEntries.Remove(entry); DeletePreview(entry.PreviewCachePath); - try { await db.SaveChangesAsync(ct); } catch { /**/ } + try { + await db.SaveChangesAsync(ct); + } + catch { + /**/ + } } return null; } - var manifest = WidgetPackInstaller.ReadManifest(file); + WidgetPackManifest? manifest = WidgetPackInstaller.ReadManifest(file); if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) return null; FileInfo info; - try { info = new FileInfo(file); } - catch { return null; } + try { + info = new FileInfo(file); + } + catch { + return null; + } - if (entry == null) - { + if (entry == null) { entry = new WidgetCatalogEntry { PackPath = ToStoredPath(file) }; db.WidgetCatalogEntries.Add(entry); } Fill(entry, file, info, manifest, SourceFor(file), DateTime.UtcNow); - try - { + try { string cacheDir = WidgetPackPaths.CacheDirFor(file); - if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, recursive: true); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, true); + } + catch { + /**/ } - catch { /**/ } WidgetPackMemoryCache.InvalidatePack(file); WidgetPackPaths.InvalidateVersionCache(); WidgetPackPaths.InvalidateResolveCache(); - try { await db.SaveChangesAsync(ct); } catch { /**/ } + try { + await db.SaveChangesAsync(ct); + } + catch { + /**/ + } return entry; } - private static WidgetCatalogSource SourceFor(string file) - { - try - { + private static WidgetCatalogSource SourceFor(string file) { + try { return Path.GetFullPath(file).StartsWith(PresetsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ? WidgetCatalogSource.Preset : WidgetCatalogSource.Imported; } - catch - { + catch { return WidgetCatalogSource.Imported; } } public static async Task DeleteAsync(IDbContextFactory factory, WidgetCatalogEntry entry, - CancellationToken ct = default) - { + CancellationToken ct = default) { if (entry.Source == WidgetCatalogSource.Preset) return false; string file = ToAbsolutePath(entry.PackPath); - try - { + try { if (File.Exists(file)) File.Delete(file); string cacheDir = WidgetPackPaths.CacheDirFor(file); - if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, recursive: true); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, true); } - catch - { + catch { return false; } @@ -217,105 +215,41 @@ public static async Task DeleteAsync(IDbContextFactory facto WidgetPackPaths.InvalidateVersionCache(); WidgetPackPaths.InvalidateResolveCache(); - try - { - await using var db = await factory.CreateDbContextAsync(ct); + try { + await using AppDbContext db = await factory.CreateDbContextAsync(ct); await db.WidgetCatalogEntries.Where(e => e.PackPath == entry.PackPath).ExecuteDeleteAsync(ct); } - catch { /**/ } + catch { + /**/ + } - try - { + try { string? folder = Path.GetDirectoryName(file); if (folder != null && Directory.Exists(folder) && !Directory.EnumerateFileSystemEntries(folder).Any() && WidgetPackPaths.IsInGlobalStore(folder)) Directory.Delete(folder); } - catch { /**/ } - - return true; - } - - #region pack id index - - private static readonly Lock IndexLock = new(); - private static Dictionary> _byPackId = new(StringComparer.OrdinalIgnoreCase); - private static Dictionary _byPath = new(StringComparer.OrdinalIgnoreCase); - - public static void BuildIndex(IEnumerable entries) - { - var byPackId = new Dictionary>(StringComparer.OrdinalIgnoreCase); - var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var entry in entries) - { - byPath[entry.PackPath] = entry; - - if (string.IsNullOrWhiteSpace(entry.PackId)) continue; - - if (!byPackId.TryGetValue(entry.PackId, out var list)) - byPackId[entry.PackId] = list = new List(); - - list.Add(entry); - } - - lock (IndexLock) - { - _byPackId = byPackId; - _byPath = byPath; - } - } - - public static async Task LoadIndexAsync(IDbContextFactory factory, CancellationToken ct = default) - { - try - { - await using var db = await factory.CreateDbContextAsync(ct); - BuildIndex(await db.WidgetCatalogEntries.AsNoTracking().ToListAsync(ct)); - } - catch {/**/} - } - - public static WidgetCatalogEntry? EntryForPackFile(string packFile) - { - string stored = ToStoredPath(packFile); - - lock (IndexLock) - { - if (_byPath.TryGetValue(stored, out var byStored)) - return byStored; - - return _byPath.GetValueOrDefault(Path.GetFullPath(packFile)); + catch { + /**/ } - } - public static List EntriesForPackId(string packId) - { - lock (IndexLock) - { - return _byPackId.TryGetValue(packId, out var list) - ? [..list] : []; - } + return true; } - #endregion - - public static string ToAbsolutePath(string storedPath) - { - try - { + public static string ToAbsolutePath(string storedPath) { + try { return Path.IsPathRooted(storedPath) ? storedPath : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), storedPath)); } - catch { return storedPath; } + catch { + return storedPath; + } } - private static string ToStoredPath(string absolute) - { - try - { + private static string ToStoredPath(string absolute) { + try { string full = Path.GetFullPath(absolute); string baseDir = Directory.GetCurrentDirectory(); string relative = Path.GetRelativePath(baseDir, full); @@ -324,26 +258,27 @@ private static string ToStoredPath(string absolute) ? full : relative; } - catch { return absolute; } + catch { + return absolute; + } } - private static IEnumerable EnumeratePackages(string root) - { + private static IEnumerable EnumeratePackages(string root) { if (!Directory.Exists(root)) yield break; IEnumerable files; - try - { + try { files = Directory.EnumerateFiles(root, "*" + WidgetPackPaths.PackExtension, SearchOption.AllDirectories); } - catch { yield break; } + catch { + yield break; + } - foreach (var file in files) + foreach (string file in files) yield return file; } - private static string? ExtractPreview(string packFile, string previewEntry, bool force = false) - { + private static string? ExtractPreview(string packFile, string previewEntry, bool force = false) { if (string.IsNullOrWhiteSpace(previewEntry)) return null; string ext = Path.GetExtension(previewEntry); @@ -352,46 +287,108 @@ private static IEnumerable EnumeratePackages(string root) string target = Path.Combine(PreviewCacheRoot, PreviewKey(packFile) + ext); if (!force && File.Exists(target)) return target; - try - { + try { Directory.CreateDirectory(PreviewCacheRoot); using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); string wanted = previewEntry.Replace('\\', '/').TrimStart('/'); - var entry = archive.Entries.FirstOrDefault(e => + ZipArchiveEntry? entry = archive.Entries.FirstOrDefault(e => string.Equals(e.FullName.Replace('\\', '/'), wanted, StringComparison.OrdinalIgnoreCase)); if (entry == null) return null; - entry.ExtractToFile(target, overwrite: true); + entry.ExtractToFile(target, true); return target; } - catch - { + catch { return null; } } - private static void DeletePreview(string? path) - { + private static void DeletePreview(string? path) { if (string.IsNullOrWhiteSpace(path)) return; - try - { + try { string full = Path.GetFullPath(path); if (!full.StartsWith(PreviewCacheRoot, StringComparison.OrdinalIgnoreCase)) return; if (File.Exists(full)) File.Delete(full); } - catch { /**/ } + catch { + /**/ + } } - private static string PreviewKey(string packFile) - { + private static string PreviewKey(string packFile) { string key; - try { key = Path.GetFullPath(packFile).ToLowerInvariant(); } - catch { key = packFile.ToLowerInvariant(); } + try { + key = Path.GetFullPath(packFile).ToLowerInvariant(); + } + catch { + key = packFile.ToLowerInvariant(); + } byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(key)); - return $"{WidgetPackPaths.Slug(Path.GetFileNameWithoutExtension(packFile))}-{Convert.ToHexString(hash)[..12].ToLowerInvariant()}"; + return + $"{WidgetPackPaths.Slug(Path.GetFileNameWithoutExtension(packFile))}-{Convert.ToHexString(hash)[..12].ToLowerInvariant()}"; } -} + + public record ScanRoot(string Path, WidgetCatalogSource Source); + + #region pack id index + + private static readonly Lock IndexLock = new(); + private static Dictionary> _byPackId = new(StringComparer.OrdinalIgnoreCase); + private static Dictionary _byPath = new(StringComparer.OrdinalIgnoreCase); + + public static void BuildIndex(IEnumerable entries) { + var byPackId = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (WidgetCatalogEntry entry in entries) { + byPath[entry.PackPath] = entry; + + if (string.IsNullOrWhiteSpace(entry.PackId)) continue; + + if (!byPackId.TryGetValue(entry.PackId, out List? list)) + byPackId[entry.PackId] = list = new List(); + + list.Add(entry); + } + + lock (IndexLock) { + _byPackId = byPackId; + _byPath = byPath; + } + } + + public static async Task LoadIndexAsync(IDbContextFactory factory, CancellationToken ct = default) { + try { + await using AppDbContext db = await factory.CreateDbContextAsync(ct); + BuildIndex(await db.WidgetCatalogEntries.AsNoTracking().ToListAsync(ct)); + } + catch { + /**/ + } + } + + public static WidgetCatalogEntry? EntryForPackFile(string packFile) { + string stored = ToStoredPath(packFile); + + lock (IndexLock) { + if (_byPath.TryGetValue(stored, out WidgetCatalogEntry? byStored)) + return byStored; + + return _byPath.GetValueOrDefault(Path.GetFullPath(packFile)); + } + } + + public static List EntriesForPackId(string packId) { + lock (IndexLock) { + return _byPackId.TryGetValue(packId, out List? list) + ? [..list] + : []; + } + } + + #endregion +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs b/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs index 85678a15..3cffda48 100644 --- a/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs +++ b/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs @@ -3,8 +3,7 @@ namespace SubathonManager.Data.Widgets; -public class WidgetCollectionManifest -{ +public class WidgetCollectionManifest { public string FormatVersion { get; init; } = "1"; public string AppVersion { get; init; } = string.Empty; public string Name { get; init; } = string.Empty; @@ -14,87 +13,72 @@ public class WidgetCollectionManifest public List Tags { get; init; } = new(); } -public static class WidgetCollectionInstaller -{ +public static class WidgetCollectionInstaller { public const string CollectionExtension = ".smwc"; public const string ManifestFileName = "collection.json"; - public record InstalledCollection( - WidgetCollectionManifest? Manifest, - List Packs, - int Failed); - - public static WidgetCollectionManifest? ReadManifest(string smwcPath) - { - try - { + public static WidgetCollectionManifest? ReadManifest(string smwcPath) { + try { using var stream = new FileStream(smwcPath, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - var entry = archive.Entries.FirstOrDefault(e => + ZipArchiveEntry? entry = archive.Entries.FirstOrDefault(e => string.Equals(e.FullName.Replace('\\', '/'), ManifestFileName, StringComparison.OrdinalIgnoreCase)); if (entry == null) return null; using var reader = new StreamReader(entry.Open()); - using var doc = JsonDocument.Parse(reader.ReadToEnd()); + using JsonDocument doc = JsonDocument.Parse(reader.ReadToEnd()); return Parse(doc.RootElement, smwcPath); } - catch - { + catch { return null; } } - public static InstalledCollection? InstallAll(string smwcPath) - { + public static InstalledCollection? InstallAll(string smwcPath) { if (!File.Exists(smwcPath)) return null; - var manifest = ReadManifest(smwcPath); + WidgetCollectionManifest? manifest = ReadManifest(smwcPath); var installed = new List(); - int failed = 0; + var failed = 0; string scratch = Path.Combine(Path.GetTempPath(), "smwc-" + Guid.NewGuid().ToString("N")); - try - { + try { Directory.CreateDirectory(scratch); using var stream = new FileStream(smwcPath, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - foreach (var entry in archive.Entries) - { + foreach (ZipArchiveEntry entry in archive.Entries) { if (string.IsNullOrEmpty(entry.Name)) continue; if (!entry.Name.EndsWith(WidgetPackPaths.PackExtension, StringComparison.OrdinalIgnoreCase)) continue; string staged = Path.Combine(scratch, $"{installed.Count + failed}-{entry.Name}"); - try - { - entry.ExtractToFile(staged, overwrite: true); - var pack = WidgetPackInstaller.Install(staged); + try { + entry.ExtractToFile(staged, true); + WidgetPackInstaller.InstalledPack? pack = WidgetPackInstaller.Install(staged); if (pack != null) installed.Add(pack); else failed++; } - catch - { + catch { failed++; } } } - catch - { + catch { return null; } - finally - { - try - { + finally { + try { if (Directory.Exists(scratch)) - Directory.Delete(scratch, recursive: true); + Directory.Delete(scratch, true); + } + catch { + /**/ } - catch { /**/ } } if (installed.Count == 0 && failed == 0) return null; @@ -102,9 +86,8 @@ public record InstalledCollection( return new InstalledCollection(manifest, installed, failed); } - private static WidgetCollectionManifest Parse(JsonElement root, string smwcPath) - { - var collection = root.TryGetProperty("collection", out var c) ? c : default; + private static WidgetCollectionManifest Parse(JsonElement root, string smwcPath) { + JsonElement collection = root.TryGetProperty("collection", out JsonElement c) ? c : default; string name = Str(collection, "name"); if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileNameWithoutExtension(smwcPath); @@ -112,8 +95,7 @@ private static WidgetCollectionManifest Parse(JsonElement root, string smwcPath) string version = Str(collection, "version"); if (string.IsNullOrWhiteSpace(version)) version = "1.0.0"; - return new WidgetCollectionManifest - { + return new WidgetCollectionManifest { FormatVersion = Str(root, "format_version"), AppVersion = Str(root, "app_version"), Name = name, @@ -124,16 +106,16 @@ private static WidgetCollectionManifest Parse(JsonElement root, string smwcPath) }; } - private static string Str(JsonElement el, string name) - => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out var v) && - v.ValueKind == JsonValueKind.String + private static string Str(JsonElement el, string name) { + return el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out JsonElement v) && + v.ValueKind == JsonValueKind.String ? v.GetString() ?? string.Empty : string.Empty; + } - private static List StrList(JsonElement el, string name) - { + private static List StrList(JsonElement el, string name) { if (el.ValueKind != JsonValueKind.Object || - !el.TryGetProperty(name, out var v) || + !el.TryGetProperty(name, out JsonElement v) || v.ValueKind != JsonValueKind.Array) return new List(); @@ -143,4 +125,9 @@ private static List StrList(JsonElement el, string name) .Where(s => !string.IsNullOrWhiteSpace(s)) .ToList(); } -} + + public record InstalledCollection( + WidgetCollectionManifest? Manifest, + List Packs, + int Failed); +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs b/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs index ef339d20..02d66539 100644 --- a/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs +++ b/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs @@ -3,23 +3,24 @@ namespace SubathonManager.Data.Widgets; -public static class WidgetDisplayHelper -{ - public static WidgetDisplayKind GetDisplayKind(this Widget widget) => widget.Type switch - { - WidgetType.Image => WidgetDisplayKind.Image, - WidgetType.Video => WidgetDisplayKind.Video, - _ => WidgetPackPaths.TryResolve(widget.HtmlPath, - out _, out _, out _, out _) - ? WidgetDisplayKind.Widget - : WidgetDisplayKind.UnpackedWidget - }; +public static class WidgetDisplayHelper { + public static WidgetDisplayKind GetDisplayKind(this Widget widget) { + return widget.Type switch { + WidgetType.Image => WidgetDisplayKind.Image, + WidgetType.Video => WidgetDisplayKind.Video, + _ => WidgetPackPaths.TryResolve(widget.HtmlPath, + out _, out _, out _, out _) + ? WidgetDisplayKind.Widget + : WidgetDisplayKind.UnpackedWidget + }; + } - public static string GetLabel(this WidgetDisplayKind kind) => kind switch - { - WidgetDisplayKind.Image => "Image", - WidgetDisplayKind.Video => "Video", - WidgetDisplayKind.UnpackedWidget => "Unpacked Widget", - _ => "Widget" - }; -} + public static string GetLabel(this WidgetDisplayKind kind) { + return kind switch { + WidgetDisplayKind.Image => "Image", + WidgetDisplayKind.Video => "Video", + WidgetDisplayKind.UnpackedWidget => "Unpacked Widget", + _ => "Widget" + }; + } +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs b/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs index b3247b1a..0ecb2939 100644 --- a/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs +++ b/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs @@ -3,42 +3,38 @@ namespace SubathonManager.Data.Widgets; -public sealed class WidgetPackFileSystem : IWidgetFileSystem -{ +public sealed class WidgetPackFileSystem : IWidgetFileSystem { private readonly DiskWidgetFileSystem _disk = new(); private readonly ConcurrentDictionary _readers = new(StringComparer.OrdinalIgnoreCase); - public bool Exists(string path) - { - var reader = ReaderFor(path, out var entry); + public bool Exists(string path) { + WidgetPackReader? reader = ReaderFor(path, out string entry); return reader?.Contains(entry) ?? _disk.Exists(path); } - public string? ReadAllText(string path) - { - var reader = ReaderFor(path, out var entry); + public string? ReadAllText(string path) { + WidgetPackReader? reader = ReaderFor(path, out string entry); return reader != null ? reader.ReadText(entry) : _disk.ReadAllText(path); } - public byte[]? ReadAllBytes(string path) - { - var reader = ReaderFor(path, out var entry); + public byte[]? ReadAllBytes(string path) { + WidgetPackReader? reader = ReaderFor(path, out string entry); return reader != null ? reader.Read(entry) : _disk.ReadAllBytes(path); } - public bool IsPacked(string path) => ReaderFor(path, out _) != null; + public bool IsPacked(string path) { + return ReaderFor(path, out _) != null; + } - public string? GetRealFilePath(string path) - { - var reader = ReaderFor(path, out var entry); + public string? GetRealFilePath(string path) { + WidgetPackReader? reader = ReaderFor(path, out string entry); return reader != null ? reader.Materialize(entry) : _disk.GetRealFilePath(path); } - public IEnumerable EnumerateFiles(string directory) - { - var reader = ReaderFor(directory, out var prefix, out var packId, - out var version, requireEntry: false); - + public IEnumerable EnumerateFiles(string directory) { + WidgetPackReader? reader = ReaderFor(directory, out string prefix, out string packId, + out string version, false); + if (reader == null) return _disk.EnumerateFiles(directory); string? mountRoot = MountRootFor(Path.Combine(directory, "_")); @@ -51,18 +47,17 @@ public IEnumerable EnumerateFiles(string directory) .ToList(); } - public bool Unpack(string packedPath, string targetDir) - { - var reader = ReaderFor(packedPath, out _, out _, out _, requireEntry: false); + public bool Unpack(string packedPath, string targetDir) { + WidgetPackReader? reader = ReaderFor(packedPath, out _, out _, out _, false); return reader != null && reader.ExtractAll(targetDir); } - private WidgetPackReader? ReaderFor(string path, out string entry) => - ReaderFor(path, out entry, out _, out _); + private WidgetPackReader? ReaderFor(string path, out string entry) { + return ReaderFor(path, out entry, out _, out _); + } private WidgetPackReader? ReaderFor(string path, out string entry, out string packId, out string version, - bool requireEntry = true) - { + bool requireEntry = true) { entry = string.Empty; packId = string.Empty; version = string.Empty; @@ -70,7 +65,7 @@ public bool Unpack(string packedPath, string targetDir) if (string.IsNullOrWhiteSpace(path)) return null; string probe = requireEntry ? path : Path.Combine(path, "_"); - var location = WidgetPackPaths.Resolve(probe); + WidgetPackPaths.PackLocation? location = WidgetPackPaths.Resolve(probe); if (location == null) return null; packId = location.PackIdStr; @@ -83,11 +78,12 @@ public bool Unpack(string packedPath, string targetDir) key => new WidgetPackReader(key, WidgetPackPaths.CacheDirFor(key))); } - private static string? MountRootFor(string path) => WidgetPackPaths.Resolve(path)?.MountRootStr; + private static string? MountRootFor(string path) { + return WidgetPackPaths.Resolve(path)?.MountRootStr; + } - private static string TrimProbe(string resolved) - { + private static string TrimProbe(string resolved) { int slash = resolved.LastIndexOf('/'); return slash < 0 ? string.Empty : resolved[..slash]; } -} +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetPackInstaller.cs b/SubathonManager.Data/Widgets/WidgetPackInstaller.cs index aedf71a2..67f56f90 100644 --- a/SubathonManager.Data/Widgets/WidgetPackInstaller.cs +++ b/SubathonManager.Data/Widgets/WidgetPackInstaller.cs @@ -1,12 +1,13 @@ using System.IO.Compression; using System.Text.Json; using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Data.Widgets; -public class WidgetPackManifest -{ +public class WidgetPackManifest { public string FormatVersion { get; init; } = "1"; public string AppVersion { get; init; } = string.Empty; public string PackId { get; init; } = string.Empty; @@ -27,35 +28,29 @@ public class WidgetPackManifest public float ScaleY { get; init; } = 1; } -public static class WidgetPackInstaller -{ +public static class WidgetPackInstaller { public const string ManifestFileName = "widget.json"; - public record InstalledPack(WidgetPackManifest Manifest, string HtmlPath, string PackFile); - public static WidgetPackManifest? ReadManifest(string smwPath) - { - try - { + public static WidgetPackManifest? ReadManifest(string smwPath) { + try { using var stream = new FileStream(smwPath, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - var entry = archive.Entries.FirstOrDefault(e => + ZipArchiveEntry? entry = archive.Entries.FirstOrDefault(e => string.Equals(e.FullName.Replace('\\', '/'), ManifestFileName, StringComparison.OrdinalIgnoreCase)); if (entry == null) return null; using var reader = new StreamReader(entry.Open()); - using var doc = JsonDocument.Parse(reader.ReadToEnd()); + using JsonDocument doc = JsonDocument.Parse(reader.ReadToEnd()); return Parse(doc.RootElement, smwPath); } - catch - { + catch { return null; } } - public static InstalledPack? Install(string smwPath) - { - var manifest = ReadManifest(smwPath); + public static InstalledPack? Install(string smwPath) { + WidgetPackManifest? manifest = ReadManifest(smwPath); if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) return null; string packId = manifest.PackId; @@ -64,21 +59,19 @@ public record InstalledPack(WidgetPackManifest Manifest, string HtmlPath, string string target = WidgetPackPaths.PackFile(packId, version); - try - { + try { Directory.CreateDirectory(Path.GetDirectoryName(target)!); if (!string.Equals(Path.GetFullPath(smwPath), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase)) - File.Copy(smwPath, target, overwrite: true); + File.Copy(smwPath, target, true); string cacheDir = WidgetPackPaths.CacheDirFor(target); - if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, recursive: true); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, true); WidgetPackMemoryCache.InvalidatePack(target); WidgetPackPaths.InvalidateVersionCache(packId); WidgetPackPaths.InvalidateResolveCache(); } - catch - { + catch { return null; } @@ -86,11 +79,10 @@ public record InstalledPack(WidgetPackManifest Manifest, string HtmlPath, string return new InstalledPack(manifest, htmlPath, target); } - public static InstalledPack? MountInPlace(string smwPath) - { + public static InstalledPack? MountInPlace(string smwPath) { if (!File.Exists(smwPath)) return null; - var manifest = ReadManifest(smwPath); + WidgetPackManifest? manifest = ReadManifest(smwPath); if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) return null; string full = Path.GetFullPath(smwPath); @@ -103,13 +95,11 @@ public record InstalledPack(WidgetPackManifest Manifest, string HtmlPath, string return new InstalledPack(manifest, WidgetPackPaths.EntryPathIn(mountRoot, manifest.Entry), full); } - public static string? DropIntoImports(string smwPath) - { - try - { + public static string? DropIntoImports(string smwPath) { + try { if (!File.Exists(smwPath)) return null; - var installed = Install(smwPath); + InstalledPack? installed = Install(smwPath); if (installed != null) return installed.PackFile; Directory.CreateDirectory(WidgetPackPaths.PackedRoot); @@ -117,52 +107,47 @@ public record InstalledPack(WidgetPackManifest Manifest, string HtmlPath, string if (string.Equals(Path.GetFullPath(smwPath), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase)) return target; - File.Copy(smwPath, target, overwrite: true); + File.Copy(smwPath, target, true); return target; } - catch - { + catch { return null; } } - public static int SweepCache(IEnumerable liveWidgetPaths) - { + public static int SweepCache(IEnumerable liveWidgetPaths) { string cacheRoot = WidgetPackPaths.CacheRoot; if (!Directory.Exists(cacheRoot)) return 0; var live = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var path in liveWidgetPaths) - { - var location = WidgetPackPaths.Resolve(path); + foreach (string path in liveWidgetPaths) { + WidgetPackPaths.PackLocation? location = WidgetPackPaths.Resolve(path); if (location != null) live.Add(WidgetPackPaths.CacheDirFor(location.PackFileStr)); } - int removed = 0; + var removed = 0; - try - { - foreach (var dir in Directory.EnumerateDirectories(cacheRoot)) - { + try { + foreach (string dir in Directory.EnumerateDirectories(cacheRoot)) { if (live.Contains(dir)) continue; - try - { - Directory.Delete(dir, recursive: true); + try { + Directory.Delete(dir, true); removed++; } - catch { /**/ } + catch { + /**/ + } } } - catch { /**/ } + catch { + /**/ + } return removed; } - public record PackUpdate(string PackFile, string Version, string Entry); - - public static PackUpdate? FindUpdate(string widgetHtmlPath) - { - var location = WidgetPackPaths.Resolve(widgetHtmlPath); + public static PackUpdate? FindUpdate(string widgetHtmlPath) { + WidgetPackPaths.PackLocation? location = WidgetPackPaths.Resolve(widgetHtmlPath); if (location == null) return null; return WidgetPackPaths.IsInPresets(location.PackFileStr) @@ -170,16 +155,14 @@ public record PackUpdate(string PackFile, string Version, string Entry); : FindVersionedFolderUpdate(location); } - private static PackUpdate? FindPresetUpdate(WidgetPackPaths.PackLocation location) - { - var current = WidgetCatalog.EntryForPackFile(location.PackFileStr); + private static PackUpdate? FindPresetUpdate(WidgetPackPaths.PackLocation location) { + WidgetCatalogEntry? current = WidgetCatalog.EntryForPackFile(location.PackFileStr); if (current == null || string.IsNullOrWhiteSpace(current.PackId)) return null; PackUpdate? best = null; string bestVersion = current.Version; - foreach (var candidate in WidgetCatalog.EntriesForPackId(current.PackId)) - { + foreach (WidgetCatalogEntry candidate in WidgetCatalog.EntriesForPackId(current.PackId)) { if (candidate.Source != WidgetCatalogSource.Preset) continue; if (string.Equals(candidate.PackPath, current.PackPath, StringComparison.OrdinalIgnoreCase)) continue; if (WidgetPackPaths.CompareVersions(candidate.Version, bestVersion) <= 0) continue; @@ -191,13 +174,11 @@ public record PackUpdate(string PackFile, string Version, string Entry); return best; } - private static PackUpdate? FindVersionedFolderUpdate(WidgetPackPaths.PackLocation location) - { + private static PackUpdate? FindVersionedFolderUpdate(WidgetPackPaths.PackLocation location) { if (!WidgetPackPaths.IsVersionName(location.VersionStr)) return null; string? best = null; - foreach (var candidate in WidgetPackPaths.VersionsIn(location.PackFolderStr)) - { + foreach (string candidate in WidgetPackPaths.VersionsIn(location.PackFolderStr)) { if (!WidgetPackPaths.IsVersionName(candidate)) continue; if (WidgetPackPaths.CompareVersions(candidate, location.VersionStr) <= 0) continue; if (best == null || WidgetPackPaths.CompareVersions(candidate, best) > 0) best = candidate; @@ -209,11 +190,12 @@ public record PackUpdate(string PackFile, string Version, string Entry); return new PackUpdate(file, best, ReadManifest(file)?.Entry ?? string.Empty); } - public static string? FindNewerVersion(string widgetHtmlPath) => FindUpdate(widgetHtmlPath)?.Version; + public static string? FindNewerVersion(string widgetHtmlPath) { + return FindUpdate(widgetHtmlPath)?.Version; + } - private static WidgetPackManifest Parse(JsonElement root, string smwPath) - { - var widget = root.TryGetProperty("widget", out var w) ? w : default; + private static WidgetPackManifest Parse(JsonElement root, string smwPath) { + JsonElement widget = root.TryGetProperty("widget", out JsonElement w) ? w : default; string name = Str(widget, "name"); if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileNameWithoutExtension(smwPath); @@ -226,13 +208,16 @@ private static WidgetPackManifest Parse(JsonElement root, string smwPath) string version = Str(widget, "widget_version"); if (string.IsNullOrWhiteSpace(version)) version = "1.0.0"; - var size = widget.ValueKind == JsonValueKind.Object && widget.TryGetProperty("size", - out var s) ? s : default; - var scale = widget.ValueKind == JsonValueKind.Object && widget.TryGetProperty("scale", - out var c) ? c : default; + JsonElement size = widget.ValueKind == JsonValueKind.Object && widget.TryGetProperty("size", + out JsonElement s) + ? s + : default; + JsonElement scale = widget.ValueKind == JsonValueKind.Object && widget.TryGetProperty("scale", + out JsonElement c) + ? c + : default; - return new WidgetPackManifest - { + return new WidgetPackManifest { FormatVersion = Str(root, "version"), AppVersion = Str(root, "app_version"), PackId = packId, @@ -251,15 +236,16 @@ private static WidgetPackManifest Parse(JsonElement root, string smwPath) }; } - private static string Str(JsonElement el, string name) - => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String + private static string Str(JsonElement el, string name) { + return el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out JsonElement v) && + v.ValueKind == JsonValueKind.String ? v.GetString() ?? string.Empty : string.Empty; + } - private static List StrList(JsonElement el, string name) - { + private static List StrList(JsonElement el, string name) { if (el.ValueKind != JsonValueKind.Object || - !el.TryGetProperty(name, out var v) || + !el.TryGetProperty(name, out JsonElement v) || v.ValueKind != JsonValueKind.Array) return new List(); @@ -270,15 +256,21 @@ private static List StrList(JsonElement el, string name) .ToList(); } - private static int Int(JsonElement el, string name, int fallback) - => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, - out var v) && v.TryGetInt32(out var i) + private static int Int(JsonElement el, string name, int fallback) { + return el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, + out JsonElement v) && v.TryGetInt32(out int i) ? i : fallback; + } - private static float Flt(JsonElement el, string name, float fallback) - => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, - out var v) && v.TryGetSingle(out var f) && f > 0 + private static float Flt(JsonElement el, string name, float fallback) { + return el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, + out JsonElement v) && v.TryGetSingle(out float f) && f > 0 ? f : fallback; -} + } + + public record InstalledPack(WidgetPackManifest Manifest, string HtmlPath, string PackFile); + + public record PackUpdate(string PackFile, string Version, string Entry); +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs b/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs index 052a4d23..2d9bfd62 100644 --- a/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs +++ b/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs @@ -1,7 +1,6 @@ namespace SubathonManager.Data.Widgets; -internal static class WidgetPackMemoryCache -{ +internal static class WidgetPackMemoryCache { private const long Budget = 32 * 1024 * 1024; public const long MaxEntrySize = 2 * 1024 * 1024; @@ -12,20 +11,21 @@ internal static class WidgetPackMemoryCache private static long _size; - private sealed class CacheItem(string key, byte[] data) - { - public string Key { get; } = key; - public byte[] Data { get; } = data; + internal static long CurrentSize { + get { + lock (CacheLock) { + return _size; + } + } } - public static string MakeKey(string packFile, string entry) => $"{packFile}|{entry}"; + public static string MakeKey(string packFile, string entry) { + return $"{packFile}|{entry}"; + } - public static bool TryGet(string key, out byte[] data) - { - lock (CacheLock) - { - if (!Map.TryGetValue(key, out var node)) - { + public static bool TryGet(string key, out byte[] data) { + lock (CacheLock) { + if (!Map.TryGetValue(key, out LinkedListNode? node)) { data = []; return false; } @@ -37,25 +37,21 @@ public static bool TryGet(string key, out byte[] data) } } - public static void Set(string key, byte[] data) - { + public static void Set(string key, byte[] data) { if (data.LongLength >= MaxEntrySize) return; - lock (CacheLock) - { - if (Map.TryGetValue(key, out var existing)) - { + lock (CacheLock) { + if (Map.TryGetValue(key, out LinkedListNode? existing)) { _size -= existing.Value.Data.LongLength; Order.Remove(existing); Map.Remove(key); } - var node = Order.AddFirst(new CacheItem(key, data)); + LinkedListNode node = Order.AddFirst(new CacheItem(key, data)); Map[key] = node; _size += data.LongLength; - while (_size > Budget && Order.Last is { } tail) - { + while (_size > Budget && Order.Last is { } tail) { Order.RemoveLast(); Map.Remove(tail.Value.Key); _size -= tail.Value.Data.LongLength; @@ -63,19 +59,16 @@ public static void Set(string key, byte[] data) } } - public static void InvalidatePack(string packFile) - { + public static void InvalidatePack(string packFile) { string prefix = packFile + "|"; - lock (CacheLock) - { - var doomed = Map.Keys + lock (CacheLock) { + List doomed = Map.Keys .Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .ToList(); - foreach (var key in doomed) - { - if (!Map.TryGetValue(key, out var node)) continue; + foreach (string key in doomed) { + if (!Map.TryGetValue(key, out LinkedListNode? node)) continue; Order.Remove(node); Map.Remove(key); _size -= node.Value.Data.LongLength; @@ -83,18 +76,16 @@ public static void InvalidatePack(string packFile) } } - public static void Clear() - { - lock (CacheLock) - { + public static void Clear() { + lock (CacheLock) { Map.Clear(); Order.Clear(); _size = 0; } } - internal static long CurrentSize - { - get { lock (CacheLock) return _size; } + private sealed class CacheItem(string key, byte[] data) { + public string Key { get; } = key; + public byte[] Data { get; } = data; } -} +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetPackPaths.cs b/SubathonManager.Data/Widgets/WidgetPackPaths.cs index 2f2f39cb..ce83c848 100644 --- a/SubathonManager.Data/Widgets/WidgetPackPaths.cs +++ b/SubathonManager.Data/Widgets/WidgetPackPaths.cs @@ -1,12 +1,22 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.RegularExpressions; using SubathonManager.Core; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Data.Widgets; -public static partial class WidgetPackPaths -{ +public static partial class WidgetPackPaths { public const string PackExtension = ".smw"; + public const string DefaultGroup = "widgets"; + private const int MaxMountDepth = 16; + + private static readonly Lock VersionCacheLock = new(); + private static readonly Dictionary> VersionCache = new(StringComparer.OrdinalIgnoreCase); + + private static readonly Lock ResolveCacheLock = new(); + private static readonly Dictionary ResolveCache = new(StringComparer.OrdinalIgnoreCase); public static string ImportsRoot => Path.GetFullPath(Path.Combine("./imports", "widgets")); public static string PackedRoot => Path.Combine(ImportsRoot, "packed"); @@ -14,65 +24,63 @@ public static partial class WidgetPackPaths public static string CacheRoot => Path.GetFullPath(Path.Combine("./cache", "widgets")); public static string PresetsRoot => Path.GetFullPath("./presets"); - public static string MountRoot(string packId, string version) - => Path.Combine(PackedRoot, packId, version); + public static string MountRoot(string packId, string version) { + return Path.Combine(PackedRoot, packId, version); + } - public static string PackFile(string packId, string version) - => Path.Combine(PackedRoot, packId, version + PackExtension); + public static string PackFile(string packId, string version) { + return Path.Combine(PackedRoot, packId, version + PackExtension); + } - public static string PackFolder(string packId) => Path.Combine(PackedRoot, packId); + public static string PackFolder(string packId) { + return Path.Combine(PackedRoot, packId); + } - private static readonly Lock VersionCacheLock = new(); - private static readonly Dictionary> VersionCache = new(StringComparer.OrdinalIgnoreCase); + public static List InstalledVersions(string packId) { + return VersionsIn(PackFolder(packId)); + } - public static List InstalledVersions(string packId) => VersionsIn(PackFolder(packId)); - public static List VersionsIn(string folder) - { - lock (VersionCacheLock) - { - if (VersionCache.TryGetValue(folder, out var cached)) return cached; + public static List VersionsIn(string folder) { + lock (VersionCacheLock) { + if (VersionCache.TryGetValue(folder, out List? cached)) return cached; var versions = new List(); if (Directory.Exists(folder)) - { - try - { + try { versions = Directory.EnumerateFiles(folder, "*" + PackExtension) .Select(Path.GetFileNameWithoutExtension) .Where(v => !string.IsNullOrWhiteSpace(v)) .Select(v => v!) .ToList(); } - catch { /**/ } - } + catch { + /**/ + } VersionCache[folder] = versions; return versions; } } - public static string DisplayVersion(string? version) - => string.IsNullOrWhiteSpace(version) + public static string DisplayVersion(string? version) { + return string.IsNullOrWhiteSpace(version) ? string.Empty : VersionSeparatorRegex().Replace(version, "."); + } - public static void InvalidateVersionCache(string? packId = null) - { - lock (VersionCacheLock) - { + public static void InvalidateVersionCache(string? packId = null) { + lock (VersionCacheLock) { if (packId == null) VersionCache.Clear(); else VersionCache.Remove(PackFolder(packId)); } } - public static int CompareVersions(string left, string right) - { - var a = VersionSegments(left); - var b = VersionSegments(right); + public static int CompareVersions(string left, string right) { + List a = VersionSegments(left); + List b = VersionSegments(right); - for (int i = 0; i < Math.Max(a.Count, b.Count); i++) - { + for (var i = 0; i < Math.Max(a.Count, b.Count); i++) { long x = i < a.Count ? a[i] : 0; long y = i < b.Count ? b[i] : 0; if (x != y) return x.CompareTo(y); @@ -81,42 +89,43 @@ public static int CompareVersions(string left, string right) return string.Compare(left, right, StringComparison.OrdinalIgnoreCase); } - private static List VersionSegments(string version) - => VersionSegmentRegex().Matches(version ?? string.Empty) - .Select(m => long.TryParse(m.Value, out var n) ? n : 0L) + private static List VersionSegments(string version) { + return VersionSegmentRegex().Matches(version ?? string.Empty) + .Select(m => long.TryParse(m.Value, out long n) ? n : 0L) .ToList(); - public const string DefaultGroup = "widgets"; + } + + public static string NormalizeGroup(string? group) { + return string.IsNullOrWhiteSpace(group) ? DefaultGroup : group.Trim(); + } - public static string NormalizeGroup(string? group) - => string.IsNullOrWhiteSpace(group) ? DefaultGroup : group.Trim(); - - public static bool IsInPresets(string packFileOrFolder) - { - try - { + public static bool IsInPresets(string packFileOrFolder) { + try { return Path.GetFullPath(packFileOrFolder) .StartsWith(PresetsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); } - catch { return false; } + catch { + return false; + } } - public static bool IsVersionName(string? name) - => !string.IsNullOrWhiteSpace(name) && VersionNameRegex().IsMatch(name); + public static bool IsVersionName(string? name) { + return !string.IsNullOrWhiteSpace(name) && VersionNameRegex().IsMatch(name); + } - public static bool IsInGlobalStore(string packFileOrFolder) - { - try - { + public static bool IsInGlobalStore(string packFileOrFolder) { + try { string full = Path.GetFullPath(packFileOrFolder); return full.StartsWith(PackedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) - || full.StartsWith(PresetsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + || full.StartsWith(PresetsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + catch { + return false; } - catch { return false; } } - public static string UnpackRootFor(PackLocation location, string author, string group, string name) - { + public static string UnpackRootFor(PackLocation location, string author, string group, string name) { if (IsInGlobalStore(location.PackFolderStr)) return UnpackRoot(author, group, name, location.VersionStr); @@ -124,8 +133,7 @@ public static string UnpackRootFor(PackLocation location, string author, string return Path.Combine(container, "unpacked", location.PackIdStr, location.VersionStr); } - public static string UnpackRoot(string author, string group, string name, string version) - { + public static string UnpackRoot(string author, string group, string name, string version) { string authorFolder = Slug(author); if (string.IsNullOrEmpty(authorFolder)) authorFolder = "unknown"; @@ -141,85 +149,77 @@ public static string UnpackRoot(string author, string group, string name, string return Path.Combine(UnpackedRoot, authorFolder, groupFolder, nameFolder, versionFolder); } - private static string SanitizeSegment(string? value) => SafeFileName.Sanitize(value); - - public static string EntryPath(string packId, string version, string entry) - => Path.Combine(MountRoot(packId, version), entry.Replace('/', Path.DirectorySeparatorChar)); - - public record PackLocation(string PackFileStr, string PackFolderStr, string MountRootStr, string PackIdStr, string VersionStr); + private static string SanitizeSegment(string? value) { + return SafeFileName.Sanitize(value); + } - private static readonly Lock ResolveCacheLock = new(); - private static readonly Dictionary ResolveCache = new(StringComparer.OrdinalIgnoreCase); - private const int MaxMountDepth = 16; + public static string EntryPath(string packId, string version, string entry) { + return Path.Combine(MountRoot(packId, version), entry.Replace('/', Path.DirectorySeparatorChar)); + } - public static PackLocation? Resolve(string widgetPath) - { + public static PackLocation? Resolve(string widgetPath) { if (string.IsNullOrWhiteSpace(widgetPath)) return null; string full; - try { full = Path.GetFullPath(widgetPath); } - catch { return null; } + try { + full = Path.GetFullPath(widgetPath); + } + catch { + return null; + } string? dir = Path.GetDirectoryName(full); if (dir == null) return null; - var found = ResolveDirectory(dir); + PackLocation? found = ResolveDirectory(dir); return found; } - private static PackLocation? ResolveDirectory(string directory) - { - lock (ResolveCacheLock) - { - if (ResolveCache.TryGetValue(directory, out var cached)) return cached; + private static PackLocation? ResolveDirectory(string directory) { + lock (ResolveCacheLock) { + if (ResolveCache.TryGetValue(directory, out PackLocation? cached)) return cached; } PackLocation? result = null; string? current = directory; - for (int depth = 0; depth < MaxMountDepth && current != null; depth++) - { + for (var depth = 0; depth < MaxMountDepth && current != null; depth++) { string candidate = current + PackExtension; - if (File.Exists(candidate)) - { + if (File.Exists(candidate)) { string packFolder = Path.GetDirectoryName(current) ?? current; result = new PackLocation( - PackFileStr: candidate, - PackFolderStr: packFolder, - MountRootStr: current, - PackIdStr: Path.GetFileName(packFolder), - VersionStr: Path.GetFileName(current)); + candidate, + packFolder, + current, + Path.GetFileName(packFolder), + Path.GetFileName(current)); break; } current = Path.GetDirectoryName(current); } - lock (ResolveCacheLock) - { + lock (ResolveCacheLock) { ResolveCache[directory] = result; } return result; } - public static void InvalidateResolveCache() - { - lock (ResolveCacheLock) - { + public static void InvalidateResolveCache() { + lock (ResolveCacheLock) { ResolveCache.Clear(); } } public static bool TryResolve(string widgetPath, out string packFile, out string entry, - out string packId, out string version) - { + out string packId, out string version) { packFile = string.Empty; entry = string.Empty; packId = string.Empty; version = string.Empty; - var found = Resolve(widgetPath); + PackLocation? found = Resolve(widgetPath); if (found == null) return false; packFile = found.PackFileStr; @@ -230,35 +230,38 @@ public static bool TryResolve(string widgetPath, out string packFile, out string return true; } - public static string EntryPathIn(string mountRoot, string entry) - => Path.Combine(mountRoot, entry.Replace('/', Path.DirectorySeparatorChar)); - - public static string CacheDirFor(string packFile) - { + public static string EntryPathIn(string mountRoot, string entry) { + return Path.Combine(mountRoot, entry.Replace('/', Path.DirectorySeparatorChar)); + } + + public static string CacheDirFor(string packFile) { string key; - try { key = Path.GetFullPath(packFile).ToLowerInvariant(); } - catch { key = packFile.ToLowerInvariant(); } + try { + key = Path.GetFullPath(packFile).ToLowerInvariant(); + } + catch { + key = packFile.ToLowerInvariant(); + } - byte[] hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(key)); - string folder = $"{Slug(Path.GetFileNameWithoutExtension(packFile))}-{Convert.ToHexString(hash)[..8].ToLowerInvariant()}"; + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(key)); + var folder = + $"{Slug(Path.GetFileNameWithoutExtension(packFile))}-{Convert.ToHexString(hash)[..8].ToLowerInvariant()}"; return Path.Combine(CacheRoot, folder); } - public static string MakePackId(string author, string group, string name) - { + public static string MakePackId(string author, string group, string name) { string n = Slug(name); if (string.IsNullOrEmpty(n)) n = "widget"; - var parts = new[] { Slug(author), Slug(NormalizeGroup(group)), n } + IEnumerable parts = new[] { Slug(author), Slug(NormalizeGroup(group)), n } .Where(p => !string.IsNullOrEmpty(p)); return string.Join('.', parts); } - public static string Slug(string? value) - { + public static string Slug(string? value) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; string slug = SlugStripRegex().Replace(value.Trim().ToLowerInvariant(), "-"); slug = SlugCollapseRegex().Replace(slug, "-").Trim('-'); @@ -279,4 +282,11 @@ public static string Slug(string? value) [GeneratedRegex(@"(?<=\d)-(?=\d)")] private static partial Regex VersionSeparatorRegex(); -} + + public record PackLocation( + string PackFileStr, + string PackFolderStr, + string MountRootStr, + string PackIdStr, + string VersionStr); +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetPackReader.cs b/SubathonManager.Data/Widgets/WidgetPackReader.cs index 93df97bd..f5eed730 100644 --- a/SubathonManager.Data/Widgets/WidgetPackReader.cs +++ b/SubathonManager.Data/Widgets/WidgetPackReader.cs @@ -3,37 +3,31 @@ namespace SubathonManager.Data.Widgets; -internal sealed class WidgetPackReader(string packFile, string cacheDir) -{ +internal sealed class WidgetPackReader(string packFile, string cacheDir) { private const long MaterializeThreshold = WidgetPackMemoryCache.MaxEntrySize; private readonly Lock _indexLock = new(); private Dictionary? _index; private DateTime _indexedAt; - private readonly record struct IndexedEntry(string FullName, long Length); - - private Dictionary Index - { - get - { - lock (_indexLock) - { - var stamp = File.Exists(packFile) ? File.GetLastWriteTimeUtc(packFile) : DateTime.MinValue; + private Dictionary Index { + get { + lock (_indexLock) { + DateTime stamp = File.Exists(packFile) ? File.GetLastWriteTimeUtc(packFile) : DateTime.MinValue; if (_index != null && stamp == _indexedAt) return _index; var index = new Dictionary(StringComparer.OrdinalIgnoreCase); - try - { + try { using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - foreach (var entry in archive.Entries) - { + foreach (ZipArchiveEntry entry in archive.Entries) { if (string.IsNullOrEmpty(entry.Name)) continue; // dir index[Normalize(entry.FullName)] = new IndexedEntry(entry.FullName, entry.Length); } } - catch { /**/ } + catch { + /**/ + } WidgetPackMemoryCache.InvalidatePack(packFile); _index = index; @@ -43,20 +37,22 @@ private Dictionary Index } } - public bool Contains(string entry) => Index.ContainsKey(Normalize(entry)); - public IEnumerable Entries => Index.Keys; - public long LengthOf(string entry) - => Index.TryGetValue(Normalize(entry), out var found) ? found.Length : -1; + public bool Contains(string entry) { + return Index.ContainsKey(Normalize(entry)); + } + + public long LengthOf(string entry) { + return Index.TryGetValue(Normalize(entry), out IndexedEntry found) ? found.Length : -1; + } - public byte[]? Read(string entry) - { + public byte[]? Read(string entry) { string key = Normalize(entry); string cacheKey = WidgetPackMemoryCache.MakeKey(packFile, key); - if (WidgetPackMemoryCache.TryGet(cacheKey, out var cached)) return cached; - if (!Index.TryGetValue(key, out var indexed)) return null; + if (WidgetPackMemoryCache.TryGet(cacheKey, out byte[] cached)) return cached; + if (!Index.TryGetValue(key, out IndexedEntry indexed)) return null; byte[]? bytes = ReadRaw(indexed.FullName); if (bytes == null) return null; @@ -67,57 +63,49 @@ public long LengthOf(string entry) return bytes; } - public string? ReadText(string entry) - { - var bytes = Read(entry); + public string? ReadText(string entry) { + byte[]? bytes = Read(entry); if (bytes == null) return null; return bytes is [0xEF, 0xBB, 0xBF, ..] ? Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3) : Encoding.UTF8.GetString(bytes); } - public string? Materialize(string entry) - { + public string? Materialize(string entry) { string key = Normalize(entry); - if (!Index.TryGetValue(key, out var indexed)) return null; + if (!Index.TryGetValue(key, out IndexedEntry indexed)) return null; if (indexed.Length < MaterializeThreshold) return null; string target = Path.Combine(cacheDir, key.Replace('/', Path.DirectorySeparatorChar)); - try - { + try { var info = new FileInfo(target); if (info.Exists && info.Length == indexed.Length) return target; Directory.CreateDirectory(Path.GetDirectoryName(target)!); string temp = target + ".partial"; using (var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) - { - var zipEntry = archive.GetEntry(indexed.FullName); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) { + ZipArchiveEntry? zipEntry = archive.GetEntry(indexed.FullName); if (zipEntry == null) return null; - zipEntry.ExtractToFile(temp, overwrite: true); + zipEntry.ExtractToFile(temp, true); } - File.Move(temp, target, overwrite: true); + File.Move(temp, target, true); return target; } - catch - { + catch { return null; } } - public bool ExtractAll(string targetDir) - { - try - { + public bool ExtractAll(string targetDir) { + try { Directory.CreateDirectory(targetDir); using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - foreach (var entry in archive.Entries) - { + foreach (ZipArchiveEntry entry in archive.Entries) { if (string.IsNullOrEmpty(entry.Name)) continue; string destination = Path.GetFullPath( @@ -128,36 +116,36 @@ public bool ExtractAll(string targetDir) continue; Directory.CreateDirectory(Path.GetDirectoryName(destination)!); - entry.ExtractToFile(destination, overwrite: true); + entry.ExtractToFile(destination, true); } return true; } - catch - { + catch { return false; } } - private byte[]? ReadRaw(string fullName) - { - try - { + private byte[]? ReadRaw(string fullName) { + try { using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - var entry = archive.GetEntry(fullName); + ZipArchiveEntry? entry = archive.GetEntry(fullName); if (entry == null) return null; - using var entryStream = entry.Open(); + using Stream entryStream = entry.Open(); using var buffer = new MemoryStream(entry.Length > 0 ? (int)Math.Min(entry.Length, int.MaxValue) : 0); entryStream.CopyTo(buffer); return buffer.ToArray(); } - catch - { + catch { return null; } } - private static string Normalize(string entry) => entry.Replace('\\', '/').TrimStart('/'); + private static string Normalize(string entry) { + return entry.Replace('\\', '/').TrimStart('/'); + } + + private readonly record struct IndexedEntry(string FullName, long Length); } \ No newline at end of file diff --git a/SubathonManager.Integration/BlerpChatService.cs b/SubathonManager.Integration/BlerpChatService.cs index 009bcda9..729fda91 100644 --- a/SubathonManager.Integration/BlerpChatService.cs +++ b/SubathonManager.Integration/BlerpChatService.cs @@ -1,44 +1,43 @@ -using SubathonManager.Core.Models; +using System.Text.RegularExpressions; using SubathonManager.Core.Enums; -using System.Text.RegularExpressions; using SubathonManager.Core.Events; +using SubathonManager.Core.Models; + namespace SubathonManager.Integration; -public static class BlerpChatService -{ - public static bool ParseMessage(string message, SubathonEventSource source) - { +public static class BlerpChatService { + public static bool ParseMessage(string message, SubathonEventSource source) { // user Blerp is verified prior to here var regex = new Regex( @"^([^\s]+)\s+\w+\s+(\d+)\s+(bits|beets)\b", RegexOptions.IgnoreCase ); - var match = regex.Match(message); + Match match = regex.Match(message); if (!match.Success) return false; - - var user = match.Groups[1].Value; - var amount = match.Groups[2].Value; - var currency = match.Groups[3].Value; - - SubathonEvent subathonEvent = new SubathonEvent() - { + + string user = match.Groups[1].Value; + string amount = match.Groups[2].Value; + string currency = match.Groups[3].Value; + + var subathonEvent = new SubathonEvent { User = user, Currency = currency.ToLower(), Value = $"{amount}", - Source = source == SubathonEventSource.Simulated ? SubathonEventSource.Simulated : SubathonEventSource.Blerp, + Source = + source == SubathonEventSource.Simulated ? SubathonEventSource.Simulated : SubathonEventSource.Blerp, EventType = currency.Equals("bits", StringComparison.OrdinalIgnoreCase) - ? SubathonEventType.BlerpBits : SubathonEventType.BlerpBeets + ? SubathonEventType.BlerpBits + : SubathonEventType.BlerpBeets }; - + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); return true; } - public static void SimulateBlerpMessage(long amount, string bitsBeets) - { - if (!bitsBeets.Equals("bits", StringComparison.CurrentCultureIgnoreCase) && + public static void SimulateBlerpMessage(long amount, string bitsBeets) { + if (!bitsBeets.Equals("bits", StringComparison.CurrentCultureIgnoreCase) && !bitsBeets.Equals("beets", StringComparison.CurrentCultureIgnoreCase)) return; var message = $"SYSTEM used {amount} {bitsBeets} to play a thing"; diff --git a/SubathonManager.Integration/DevTunnelsService.cs b/SubathonManager.Integration/DevTunnelsService.cs index b769af85..98377a57 100644 --- a/SubathonManager.Integration/DevTunnelsService.cs +++ b/SubathonManager.Integration/DevTunnelsService.cs @@ -2,9 +2,9 @@ using DevTunnels.Client; using DevTunnels.Client.Authentication; using DevTunnels.Client.Hosting; +using DevTunnels.Client.Installer; using DevTunnels.Client.Ports; using DevTunnels.Client.Tunnels; -using DevTunnels.Client.Installer; using Microsoft.Extensions.Logging; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -16,19 +16,18 @@ namespace SubathonManager.Integration; public class DevTunnelsService( ILogger? logger, IConfig config, - IDevTunnelsClient client) : IDisposable, IAppService -{ + IDevTunnelsClient client) : IDisposable, IAppService { private readonly string _configSection = "DevTunnels"; - private bool _disposed = false; - - private IDevTunnelHostSession? _session; - private CancellationTokenSource? _cts; - private DateTime? _lastTokenErrorAt; // Serialises concurrent StartTunnelAsync calls so at most one session-start attempt // runs at a time. Without this, two webhook integrations firing StartTunnelAsync // concurrently could both pass the _session != null guard and launch two CLI processes. private readonly SemaphoreSlim _startLock = new(1, 1); + private CancellationTokenSource? _cts; + private bool _disposed; + private DateTime? _lastTokenErrorAt; + + private IDevTunnelHostSession? _session; // Publicly readable so the UI can show the live URL without subscribing to events. public string? PublicBaseUrl { get; private set; } @@ -36,16 +35,14 @@ public class DevTunnelsService( public bool IsLoggedIn { get; private set; } public bool IsTunnelRunning { get; private set; } - public async Task StartAsync(CancellationToken ct = default) - { + public async Task StartAsync(CancellationToken ct = default) { // 1. Probe CLI - var probe = await client.ProbeCliAsync(ct); + DevTunnelCliProbeResult probe = await client.ProbeCliAsync(ct); IsCliInstalled = probe.IsInstalled; BroadcastCliStatus(probe); - if (!probe.IsInstalled) - { + if (!probe.IsInstalled) { logger?.LogInformation("[DevTunnels] CLI not installed, skipping auto-start"); BroadcastLoginStatus(null); BroadcastTunnelStatus(false, null); @@ -53,14 +50,14 @@ public async Task StartAsync(CancellationToken ct = default) } // 2. Check login - var login = await client.GetLoginStatusAsync(ct); + DevTunnelLoginStatus login = await client.GetLoginStatusAsync(ct); IsLoggedIn = login.IsLoggedIn; BroadcastLoginStatus(login); - if (!login.IsLoggedIn) - { - logger?.LogInformation("[DevTunnels] Not logged in; tunnel will start on demand when a webhook integration is enabled"); + if (!login.IsLoggedIn) { + logger?.LogInformation( + "[DevTunnels] Not logged in; tunnel will start on demand when a webhook integration is enabled"); BroadcastTunnelStatus(false, null); return; } @@ -72,65 +69,63 @@ public async Task StartAsync(CancellationToken ct = default) BroadcastTunnelStatus(false, null); } + public async Task StopAsync(CancellationToken ct = default) { + await StopTunnelAsync(); + + IsCliInstalled = false; + IsLoggedIn = false; + + BroadcastCliStatus(null); + BroadcastLoginStatus(null); + BroadcastTunnelStatus(false, null); + } + [ExcludeFromCodeCoverage] - public async Task TryInstallAsync(CancellationToken ct = default) - { - var probe = await client.ProbeCliAsync(ct); - if (probe.IsInstalled) - { + public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + [ExcludeFromCodeCoverage] + public async Task TryInstallAsync(CancellationToken ct = default) { + DevTunnelCliProbeResult probe = await client.ProbeCliAsync(ct); + if (probe.IsInstalled) { logger?.LogInformation("[DevTunnels] Install requested but already installed..."); return true; } + var installer = new DevTunnelCliInstaller(); logger?.LogInformation("[DevTunnels] Installing detected installer..."); string? detected = await installer.DetectInstallerAsync(ct); logger?.LogInformation("[DevTunnels] Installer: {Detected}", detected ?? "none"); - - var result = await installer.InstallAsync(ct); + + DevTunnelCliInstallResult result = await installer.InstallAsync(ct); if (result.Success) - { - logger?.LogInformation("[DevTunnels] Installed successfully via {Method}. Path: {Path}", result.InstallerUsed, result.InstalledPath); - } + logger?.LogInformation("[DevTunnels] Installed successfully via {Method}. Path: {Path}", + result.InstallerUsed, result.InstalledPath); else - { logger?.LogError("[DevTunnels] Install failed: {Reason}", result.FailureReason); - } return result.Success; } - public async Task StopAsync(CancellationToken ct = default) - { - await StopTunnelAsync(); - - IsCliInstalled = false; - IsLoggedIn = false; - - BroadcastCliStatus(null); - BroadcastLoginStatus(null); - BroadcastTunnelStatus(false, null); - } - // Interactive actions called from the UI - public async Task RefreshCliStatusAsync(CancellationToken ct = default) - { - var probe = await client.ProbeCliAsync(ct); + public async Task RefreshCliStatusAsync(CancellationToken ct = default) { + DevTunnelCliProbeResult probe = await client.ProbeCliAsync(ct); IsCliInstalled = probe.IsInstalled; BroadcastCliStatus(probe); return probe; } - public async Task LoginAsync(LoginProvider provider, CancellationToken ct = default) - { - var status = await client.LoginAsync(provider, ct); + public async Task LoginAsync(LoginProvider provider, CancellationToken ct = default) { + DevTunnelLoginStatus status = await client.LoginAsync(provider, ct); IsLoggedIn = status.IsLoggedIn; BroadcastLoginStatus(status); return status; } - public async Task LogoutAsync(CancellationToken ct = default) - { + public async Task LogoutAsync(CancellationToken ct = default) { await StopTunnelAsync(); await client.LogoutAsync(ct); IsLoggedIn = false; @@ -139,36 +134,32 @@ public async Task LogoutAsync(CancellationToken ct = defau return new DevTunnelLoginStatus { Status = "Logged out" }; } - public async Task StartTunnelAsync(CancellationToken ct = default) - { + public async Task StartTunnelAsync(CancellationToken ct = default) { // Fast path: no lock needed if a session is already running. if (_session != null) return; await _startLock.WaitAsync(ct); - try - { + try { // Re-check under the lock in case another caller just finished starting. if (_session != null) return; - BroadcastTunnelStatus(false, null, starting: true); + BroadcastTunnelStatus(false, null, true); - var serverPort = int.TryParse(config.Get("Server", "Port", "14040"), out var p) ? p : 14040; - var tunnelId = config.Get(_configSection, "TunnelId", string.Empty); + int serverPort = int.TryParse(config.Get("Server", "Port", "14040"), out int p) ? p : 14040; + string? tunnelId = config.Get(_configSection, "TunnelId", string.Empty); // The Azure DevTunnels CLI internally assigns a random tunnel ID that may not match // the short-name format required by StartHostSessionAsync validation. We always use // the label (name) we chose as the persistent ID; the CLI accepts it as a host arg. // Also clear any cluster-qualified IDs persisted by older code (e.g. "name.abc.usw2"). - if (!string.IsNullOrWhiteSpace(tunnelId) && !DevTunnelValidation.IsValidTunnelId(tunnelId)) - { + if (!string.IsNullOrWhiteSpace(tunnelId) && !DevTunnelValidation.IsValidTunnelId(tunnelId)) { logger?.LogInformation("[DevTunnels] Stored tunnel ID is not in short-name format; resetting"); tunnelId = string.Empty; if (config.Set(_configSection, "TunnelId", string.Empty)) config.Save(); } - - if (!string.IsNullOrWhiteSpace(tunnelId) && tunnelId.StartsWith("subathon-")) - { + + if (!string.IsNullOrWhiteSpace(tunnelId) && tunnelId.StartsWith("subathon-")) { logger?.LogWarning("[DevTunnels] Legacy tunnel ID found. Resetting"); tunnelId = string.Empty; if (config.Set(_configSection, "TunnelId", string.Empty)) @@ -176,28 +167,25 @@ public async Task StartTunnelAsync(CancellationToken ct = default) IntegrationEvents.RaiseDevTunnelLegacyNotification(); } - if (string.IsNullOrWhiteSpace(tunnelId)) - { - try - { + if (string.IsNullOrWhiteSpace(tunnelId)) { + try { var prefix = $"sm{serverPort}-"; - var list = await client.ListTunnelsAsync(ct); - var found = list.Tunnels.FirstOrDefault(t => t.TunnelId.StartsWith(prefix, StringComparison.Ordinal)); - if (found != null) - { + DevTunnelList list = await client.ListTunnelsAsync(ct); + DevTunnelStatus? found = + list.Tunnels.FirstOrDefault(t => t.TunnelId.StartsWith(prefix, StringComparison.Ordinal)); + if (found != null) { tunnelId = found.TunnelId.Split('.')[0]; - logger?.LogInformation("[DevTunnels] Recovered existing tunnel '{Id}' from account list", tunnelId); + logger?.LogInformation("[DevTunnels] Recovered existing tunnel '{Id}' from account list", + tunnelId); if (config.Set(_configSection, "TunnelId", tunnelId)) config.Save(); } } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[DevTunnels] Failed to list tunnels; will generate a new ID"); } - if (string.IsNullOrWhiteSpace(tunnelId)) - { + if (string.IsNullOrWhiteSpace(tunnelId)) { tunnelId = $"sm{serverPort}-{Guid.NewGuid():N}"; if (config.Set(_configSection, "TunnelId", tunnelId)) config.Save(); @@ -208,14 +196,13 @@ public async Task StartTunnelAsync(CancellationToken ct = default) // The port must be pre-registered on the tunnel; passing PortNumber to StartHostSessionAsync // is for ephemeral tunnels only and causes a service-side error on named persistent tunnels. logger?.LogInformation("[DevTunnels] Ensuring tunnel '{Id}' and port {Port} exist", tunnelId, serverPort); - - try - { + + try { await client.GetTunnelAsync(tunnelId, ct); } - catch (Exception ex) - { - logger?.LogWarning(ex, "[DevTunnels] Failed to find a pre-existing tunnel. Trying to create or update."); + catch (Exception ex) { + logger?.LogWarning(ex, + "[DevTunnels] Failed to find a pre-existing tunnel. Trying to create or update."); await client.CreateOrUpdateTunnelAsync( tunnelId, new DevTunnelOptions { AllowAnonymous = true, Description = "SubathonManager webhook tunnel" }, @@ -234,18 +221,16 @@ await client.CreateOrReplacePortAsync( new DevTunnelHostStartOptions { TunnelId = tunnelId, ReadyTimeout = TimeSpan.FromSeconds(30) }, _cts.Token); - try - { + try { await _session.WaitForReadyAsync(_cts.Token); } - catch (InvalidOperationException ex) when (ex.Message.Contains("access token") || (ex.Message.StartsWith("Request ID:"))) - { + catch (InvalidOperationException ex) when (ex.Message.Contains("access token") || + ex.Message.StartsWith("Request ID:")) { logger?.LogWarning(ex, "[DevTunnels] Host token missing for tunnel '{Id}'; re-login to the devtunnel CLI may be required", tunnelId); - if (_lastTokenErrorAt == null || DateTime.Now - _lastTokenErrorAt > TimeSpan.FromMinutes(10)) - { + if (_lastTokenErrorAt == null || DateTime.Now - _lastTokenErrorAt > TimeSpan.FromMinutes(10)) { _lastTokenErrorAt = DateTime.Now; ErrorMessageEvents.RaiseErrorEvent( "WARN", @@ -253,11 +238,12 @@ await client.CreateOrReplacePortAsync( "DevTunnel failed to start: missing host access token. Please re-login to the devtunnel.", DateTime.Now.ToLocalTime()); } + // // await LogoutAsync(ct); return; } - + PublicBaseUrl = _session.PublicUrl?.ToString()?.TrimEnd('/'); IsTunnelRunning = true; @@ -267,49 +253,40 @@ await client.CreateOrReplacePortAsync( // Monitor for unexpected exit in the background _ = MonitorSessionAsync(_cts.Token); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[DevTunnels] Failed to start tunnel"); await StopTunnelAsync(); BroadcastTunnelStatus(false, null); } - finally - { + finally { _startLock.Release(); } } - public async Task DeleteOldTunnelsAsync(CancellationToken ct = default) - { - var serverPort = int.TryParse(config.Get("Server", "Port", "14040"), out var p) ? p : 14040; + public async Task DeleteOldTunnelsAsync(CancellationToken ct = default) { + int serverPort = int.TryParse(config.Get("Server", "Port", "14040"), out int p) ? p : 14040; var prefix = $"sm{serverPort}-"; - var list = await client.ListTunnelsAsync(ct); - foreach (var tunnel in list.Tunnels) - { - var id = tunnel.TunnelId.Split('.')[0]; + DevTunnelList list = await client.ListTunnelsAsync(ct); + foreach (DevTunnelStatus tunnel in list.Tunnels) { + string id = tunnel.TunnelId.Split('.')[0]; if (!id.StartsWith(prefix, StringComparison.Ordinal)) continue; - try - { + try { await client.DeleteTunnelAsync(id, ct); logger?.LogInformation("[DevTunnels] Deleted old tunnel '{Id}'", id); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[DevTunnels] Failed to delete tunnel '{Id}'", id); } } - var storedId = config.Get(_configSection, "TunnelId", string.Empty); + string? storedId = config.Get(_configSection, "TunnelId", string.Empty); if (!string.IsNullOrWhiteSpace(storedId) && storedId.StartsWith(prefix, StringComparison.Ordinal)) - { if (config.Set(_configSection, "TunnelId", string.Empty)) config.Save(); - } } - public async Task StopTunnelAsync() - { + public async Task StopTunnelAsync() { if (_session == null) return; BroadcastTunnelStatus(false, null, stopping: true); @@ -317,11 +294,13 @@ public async Task StopTunnelAsync() PublicBaseUrl = null; IsTunnelRunning = false; - try { await _session.StopAsync(); } - catch (Exception ex) - { + try { + await _session.StopAsync(); + } + catch (Exception ex) { logger?.LogWarning(ex, "[DevTunnels] Failed to stop tunnel"); } + await _session.DisposeAsync(); _session = null; @@ -334,27 +313,24 @@ public async Task StopTunnelAsync() // Helpers - private async Task MonitorSessionAsync(CancellationToken ct) - { - try - { + private async Task MonitorSessionAsync(CancellationToken ct) { + try { if (_session == null) return; await _session.WaitForExitAsync(ct); - if (!ct.IsCancellationRequested && IsTunnelRunning) - { + if (!ct.IsCancellationRequested && IsTunnelRunning) { logger?.LogWarning("[DevTunnels] Host session exited unexpectedly: {Reason}", _session.FailureReason); await StopTunnelAsync(); BroadcastTunnelStatus(false, null); } } - catch (OperationCanceledException) { /**/ } + catch (OperationCanceledException) { + /**/ + } } - private void BroadcastCliStatus(DevTunnelCliProbeResult? probe) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private void BroadcastCliStatus(DevTunnelCliProbeResult? probe) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.DevTunnels, Service = "Cli", Name = probe?.IsInstalled == true ? probe.Version?.ToString() ?? "" : "", @@ -363,10 +339,8 @@ private void BroadcastCliStatus(DevTunnelCliProbeResult? probe) }); } - private void BroadcastLoginStatus(DevTunnelLoginStatus? login) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private void BroadcastLoginStatus(DevTunnelLoginStatus? login) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.DevTunnels, Service = "Login", Name = login?.Username ?? "", @@ -376,29 +350,19 @@ private void BroadcastLoginStatus(DevTunnelLoginStatus? login) }); } - private void BroadcastTunnelStatus(bool running, string? url, bool starting = false, bool stopping = false) - { + private void BroadcastTunnelStatus(bool running, string? url, bool starting = false, bool stopping = false) { IsTunnelRunning = running; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.DevTunnels, Service = "Tunnel", - Name = starting ? "(starting...)" : stopping ? "(stopping...)" : (url ?? ""), + Name = starting ? "(starting...)" : stopping ? "(stopping...)" : url ?? "", Status = running, Configured = running }); } [ExcludeFromCodeCoverage] - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - [ExcludeFromCodeCoverage] - protected virtual void Dispose(bool disposing) - { + protected virtual void Dispose(bool disposing) { if (_disposed) return; _cts?.Cancel(); _cts?.Dispose(); @@ -406,4 +370,4 @@ protected virtual void Dispose(bool disposing) _startLock.Dispose(); _disposed = true; } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/ExternalEventService.cs b/SubathonManager.Integration/ExternalEventService.cs index 9b8efb11..4c0c03ee 100644 --- a/SubathonManager.Integration/ExternalEventService.cs +++ b/SubathonManager.Integration/ExternalEventService.cs @@ -2,33 +2,29 @@ using Microsoft.Extensions.DependencyInjection; using SubathonManager.Core; using SubathonManager.Core.Enums; -using SubathonManager.Core.Models; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; using SubathonManager.Services; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; -public static class ExternalEventService -{ - - public static bool ProcessExternalCommand(Dictionary data) - { +public static class ExternalEventService { + public static bool ProcessExternalCommand(Dictionary data) { data.TryGetValue("command", out JsonElement elemCmd); - if (elemCmd.ValueKind == JsonValueKind.String && Enum.TryParse - (elemCmd.GetString(), ignoreCase: true, out SubathonCommandType cmd) ) - { + if (elemCmd.ValueKind == JsonValueKind.String && Enum.TryParse + (elemCmd.GetString(), true, out SubathonCommandType cmd)) { var source = SubathonEventSource.External; if (data.TryGetValue("source", out JsonElement elemSrc) && elemSrc.ValueKind == JsonValueKind.String - && Enum.TryParse(elemSrc.GetString(), ignoreCase: true, out SubathonEventSource parsedSrc) - && parsedSrc.IsExternalSource()) - { + && Enum.TryParse(elemSrc.GetString(), true, + out SubathonEventSource parsedSrc) + && parsedSrc.IsExternalSource()) source = parsedSrc; - } data.TryGetValue("user", out JsonElement elemUser); - string fallbackUser = "EXTERNAL"; + var fallbackUser = "EXTERNAL"; string user = string.IsNullOrWhiteSpace(elemUser.GetString()) ? fallbackUser : elemUser.GetString()!; data.TryGetValue("message", out JsonElement elemMsg); @@ -40,93 +36,79 @@ public static bool ProcessExternalCommand(Dictionary data) return false; } - - public static bool ProcessExternalSub(Dictionary data) - { + + public static bool ProcessExternalSub(Dictionary data) { data.TryGetValue("type", out JsonElement elemType); string typeStr = elemType.GetString()!; - Enum.TryParse(typeStr, ignoreCase: true, out var type); + Enum.TryParse(typeStr, true, out SubathonEventType type); if (!((SubathonEventType?)type).IsSubscription()) return false; data.TryGetValue("user", out JsonElement elemUser); string user = string.IsNullOrWhiteSpace(elemUser.GetString()) ? "EXTERNAL" : elemUser.GetString()!; - + if (type.GetSource() == SubathonEventSource.KoFi && !string.Equals(user, "SYSTEM")) - { if (Utils.GetConnection(SubathonEventSource.KoFiTunnel, nameof(SubathonEventSource.KoFiTunnel)).Status) - { return false; - } - } - + data.TryGetValue("value", out JsonElement elemValue); - - string value = elemValue.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(elemValue.GetString()) - ? elemValue.GetString()! : "DEFAULT"; + + string value = elemValue.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(elemValue.GetString()) + ? elemValue.GetString()! + : "DEFAULT"; if (string.IsNullOrWhiteSpace(value)) value = "DEFAULT"; - + data.TryGetValue("amount", out JsonElement elemAmount); - - SubathonEvent subathonEvent = new SubathonEvent - { + + var subathonEvent = new SubathonEvent { Currency = type == SubathonEventType.KoFiSub ? "member" : "sub", User = user, Value = $"{value}", EventTypeMeta = $"{value}" }; - if (type != SubathonEventType.KoFiSub) - { + if (type != SubathonEventType.KoFiSub) { data.TryGetValue("seconds", out JsonElement elemSeconds); data.TryGetValue("points", out JsonElement elemPoints); - if (elemSeconds.ValueKind == JsonValueKind.Number) - { - subathonEvent.SecondsValue = elemSeconds.GetDouble(); - } - if (elemPoints.ValueKind == JsonValueKind.Number) { - subathonEvent.PointsValue = elemPoints.GetInt16(); - } + if (elemSeconds.ValueKind == JsonValueKind.Number) subathonEvent.SecondsValue = elemSeconds.GetDouble(); + if (elemPoints.ValueKind == JsonValueKind.Number) subathonEvent.PointsValue = elemPoints.GetInt16(); } - if (elemAmount.ValueKind == JsonValueKind.String) - { + if (elemAmount.ValueKind == JsonValueKind.String) { subathonEvent.Amount = 1; - if (int.TryParse(elemAmount.GetString(), out var amtInt) && amtInt > 0) + if (int.TryParse(elemAmount.GetString(), out int amtInt) && amtInt > 0) subathonEvent.Amount = amtInt; } - else - { + else { subathonEvent.Amount = elemAmount.ValueKind == JsonValueKind.Number ? elemAmount.GetInt16() : 1; } - subathonEvent.Source = user == "SYSTEM" ? SubathonEventSource.Simulated : ((SubathonEventType?)type).GetSource(); + subathonEvent.Source = + user == "SYSTEM" ? SubathonEventSource.Simulated : ((SubathonEventType?)type).GetSource(); subathonEvent.EventType = type; - + data.TryGetValue("id", out JsonElement elemId); - if (elemId.ValueKind == JsonValueKind.String && Guid.TryParse(elemId.GetString()!, out var id)) + if (elemId.ValueKind == JsonValueKind.String && Guid.TryParse(elemId.GetString()!, out Guid id)) subathonEvent.Id = id; - + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); return true; } - - - public static bool ProcessExternalOrder(Dictionary data) - { + + + public static bool ProcessExternalOrder(Dictionary data) { data.TryGetValue("type", out JsonElement elemType); if (elemType.ValueKind != JsonValueKind.String) return false; string typeStr = elemType.GetString()!; - Enum.TryParse(typeStr, ignoreCase: true, out var type); + Enum.TryParse(typeStr, true, out SubathonEventType type); string? goAffProMeta = null; - if (type != SubathonEventType.GoAffProOrder && GoAffProOrderHelper.TryGetStoreByOrderKey(typeStr, out var keyStore)) - { + if (type != SubathonEventType.GoAffProOrder && + GoAffProOrderHelper.TryGetStoreByOrderKey(typeStr, out GoAffProStore? keyStore)) { type = SubathonEventType.GoAffProOrder; goAffProMeta = keyStore.SiteId.ToString(); } - else if (type.GetLegacyGoAffProSiteId() > 0) - { + else if (type.GetLegacyGoAffProSiteId() > 0) { goAffProMeta = type.GetLegacyGoAffProSiteId().ToString(); type = SubathonEventType.GoAffProOrder; } @@ -136,14 +118,10 @@ public static bool ProcessExternalOrder(Dictionary data) if (!((SubathonEventType?)type).IsOrder()) return false; if (type.GetSource() == SubathonEventSource.KoFi && !string.Equals(user, "SYSTEM")) - { if (Utils.GetConnection(SubathonEventSource.KoFiTunnel, nameof(SubathonEventSource.KoFiTunnel)).Status) - { return false; - } - } - + data.TryGetValue("currency", out JsonElement elemCurrency); if (elemCurrency.ValueKind != JsonValueKind.String) return false; @@ -151,49 +129,43 @@ public static bool ProcessExternalOrder(Dictionary data) data.TryGetValue("amount", out JsonElement elemValue); if (elemValue.ValueKind != JsonValueKind.String) return false; - - int amt = 1; - if(data.TryGetValue("quantity", out JsonElement elemQuant)) - { - if (elemQuant.ValueKind == JsonValueKind.String) - { + + var amt = 1; + if (data.TryGetValue("quantity", out JsonElement elemQuant)) { + if (elemQuant.ValueKind == JsonValueKind.String) { if (!int.TryParse(elemQuant.GetString()!, out amt)) return false; } - else if (elemQuant.ValueKind == JsonValueKind.Number) - { - amt = elemQuant.GetInt32(); + else if (elemQuant.ValueKind == JsonValueKind.Number) { + amt = elemQuant.GetInt32(); } - else - { + else { return false; } } - if (!double.TryParse(elemValue.GetString()!, out var value)) return false; - - string orderVal = $"{value}"; - if (type != SubathonEventType.KoFiCommissionOrder) - { - string section = $"{type.GetSource()}"; - string modeKey = $"{type}"; - if (type == SubathonEventType.GoAffProOrder) - { + + if (!double.TryParse(elemValue.GetString()!, out double value)) return false; + + var orderVal = $"{value}"; + if (type != SubathonEventType.KoFiCommissionOrder) { + var section = $"{type.GetSource()}"; + var modeKey = $"{type}"; + if (type == SubathonEventType.GoAffProOrder) { section = nameof(SubathonEventSource.GoAffPro); - modeKey = GoAffProOrderHelper.TryGetStore(goAffProMeta, out var store) + modeKey = GoAffProOrderHelper.TryGetStore(goAffProMeta, out GoAffProStore? store) ? store.InternalName : $"{type}"; } + var config = AppServices.Provider.GetRequiredService(); - var mode = config.GetOrderTypeMode(section, modeKey, OrderTypeModes.Dollar); - - currency = mode switch - { + OrderTypeModes mode = config.GetOrderTypeMode(section, modeKey, OrderTypeModes.Dollar); + + currency = mode switch { OrderTypeModes.Item => "items", OrderTypeModes.Order => "order", _ => currency }; - - switch (mode) - { + + switch (mode) { case OrderTypeModes.Item: orderVal = amt.ToString(); break; @@ -202,9 +174,8 @@ public static bool ProcessExternalOrder(Dictionary data) break; } } - - SubathonEvent subathonEvent = new SubathonEvent - { + + var subathonEvent = new SubathonEvent { Currency = currency, User = user, Value = orderVal, @@ -216,35 +187,30 @@ public static bool ProcessExternalOrder(Dictionary data) }; data.TryGetValue("id", out JsonElement elemId); - if (elemId.ValueKind == JsonValueKind.String && Guid.TryParse(elemId.GetString()!, out var id)) + if (elemId.ValueKind == JsonValueKind.String && Guid.TryParse(elemId.GetString()!, out Guid id)) subathonEvent.Id = id; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); - + return true; } - - public static bool ProcessExternalDonation(Dictionary data) - { + + public static bool ProcessExternalDonation(Dictionary data) { data.TryGetValue("type", out JsonElement elemType); if (elemType.ValueKind != JsonValueKind.String) return false; string typeStr = elemType.GetString()!; - Enum.TryParse(typeStr, ignoreCase: true, out var type); + Enum.TryParse(typeStr, true, out SubathonEventType type); if (!((SubathonEventType?)type).IsCurrencyDonation()) return false; data.TryGetValue("user", out JsonElement elemUser); string user = string.IsNullOrWhiteSpace(elemUser.GetString()) ? "EXTERNAL" : elemUser.GetString()!; - + if (type.GetSource() == SubathonEventSource.KoFi && !string.Equals(user, "SYSTEM")) - { if (Utils.GetConnection(SubathonEventSource.KoFiTunnel, nameof(SubathonEventSource.KoFiTunnel)).Status) - { return false; - } - } - + data.TryGetValue("currency", out JsonElement elemCurrency); if (elemCurrency.ValueKind != JsonValueKind.String) return false; @@ -253,10 +219,9 @@ public static bool ProcessExternalDonation(Dictionary data) data.TryGetValue("amount", out JsonElement elemValue); if (elemValue.ValueKind != JsonValueKind.String) return false; - if (!double.TryParse(elemValue.GetString()!, out var value)) return false; - - SubathonEvent subathonEvent = new SubathonEvent - { + if (!double.TryParse(elemValue.GetString()!, out double value)) return false; + + var subathonEvent = new SubathonEvent { Currency = currency, User = user, Value = $"{value}", @@ -265,11 +230,11 @@ public static bool ProcessExternalDonation(Dictionary data) }; data.TryGetValue("id", out JsonElement elemId); - if (elemId.ValueKind == JsonValueKind.String && Guid.TryParse(elemId.GetString()!, out var id)) + if (elemId.ValueKind == JsonValueKind.String && Guid.TryParse(elemId.GetString()!, out Guid id)) subathonEvent.Id = id; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); - + return true; } } \ No newline at end of file diff --git a/SubathonManager.Integration/FourthWallService.cs b/SubathonManager.Integration/FourthWallService.cs index 6124edd4..4527b63f 100644 --- a/SubathonManager.Integration/FourthWallService.cs +++ b/SubathonManager.Integration/FourthWallService.cs @@ -1,12 +1,18 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text.Json; using Agash.Webhook.Abstractions; -using Fourthwall.Client.Events; using Fourthwall.Client.Authentication; +using Fourthwall.Client.Events; using Fourthwall.Client.Generated; using Fourthwall.Client.Generated.Models.App.Openapi.Endpoint.OpenApiWebhookConfigurationEndpoint; using Fourthwall.Client.Generated.Models.Openapi.Model; +using Fourthwall.Client.Generated.Models.Openapi.Model.DonationV1; +using Fourthwall.Client.Generated.Models.Openapi.Model.GiftPurchaseV1; +using Fourthwall.Client.Generated.Models.Openapi.Model.MembershipSupporterV1; +using Fourthwall.Client.Generated.Models.Openapi.Model.MembershipSupporterV1.Subscription; +using Fourthwall.Client.Generated.Models.Openapi.Model.OrderV1; using Fourthwall.Client.Options; using Fourthwall.Client.Webhooks; using Microsoft.Extensions.Logging; @@ -19,143 +25,181 @@ using SubathonManager.Core.Objects; using SubathonManager.Core.Security; using SubathonManager.Core.Security.Interfaces; +using WebhookConfigurationV1 = + Fourthwall.Client.Generated.Models.Openapi.Model.OpenApiPageResponseCom.Fourthwall.Openapi.Model. + WebhookConfigurationV1; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; -public class FourthWallService(ILogger? logger, IConfig config, IHttpClientFactory httpClientFactory, DevTunnelsService devTunnels, ISecureStorage secureStorage) - : IWebhookIntegration -{ +public class FourthWallService( + ILogger? logger, + IConfig config, + IHttpClientFactory httpClientFactory, + DevTunnelsService devTunnels, + ISecureStorage secureStorage) + : IWebhookIntegration { private readonly string _configSection = "FourthWall"; - public string WebhookPath => "/api/webhooks/fourthwall"; + + private readonly FourthwallWebhookHandler _handler = new(new FourthwallWebhookSignatureVerifier()); internal readonly string _oAuthURl = "https://oauth.subathonmanager.app/auth/fourthwall/login"; internal readonly string _refreshURl = "https://oauth.subathonmanager.app/auth/fourthwall/refresh"; - - internal Action OpenBrowser = url => Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); + + public readonly Dictionary MembershipNames = new(); + + internal Action OpenBrowser = + url => Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); private string? AccessToken => secureStorage.GetOrDefault(StorageKeys.FourthWallAccessToken, string.Empty); private string? RefreshToken => secureStorage.GetOrDefault(StorageKeys.FourthWallRefreshToken, string.Empty); private string? ShopName { get; set; } - - public readonly Dictionary MembershipNames = new(); + public string WebhookPath => "/api/webhooks/fourthwall"; + + public async Task StartAsync(CancellationToken ct = default) { + IntegrationEvents.ConnectionUpdated += OnTunnelUpdated; + + Utils.PendingOAuthCallback = null; + bool enabled = HasTokenFile(); + + if (enabled) { + logger?.LogInformation("[FourthWall] Webhook listener ready at {Path}", WebhookPath); + _ = devTunnels.StartTunnelAsync(ct); + } + else { + logger?.LogInformation("[FourthWall] Has not been setup before. Integration is disabled"); + BroadcastStatus(false, null); + } + + await Task.CompletedTask; + } + + + public Task StopAsync(CancellationToken ct = default) { + IntegrationEvents.ConnectionUpdated -= OnTunnelUpdated; + BroadcastStatus(false, null); + + return Task.CompletedTask; + } + + public async Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, + CancellationToken ct = default) { + string contentType = headers.TryGetValue("Content-Type", out string? ct2) + ? ct2 + : "application/x-www-form-urlencoded"; + + Dictionary newHeaders = headers.ToDictionary( + kvp => kvp.Key, + kvp => new[] { kvp.Value } + ); + IReadOnlyDictionary readOnlyHeaders = newHeaders; + + var request = new WebhookRequest { + Method = "POST", + Path = WebhookPath, + Body = rawBody, + ContentType = contentType, + Headers = readOnlyHeaders + }; + + WebhookHandleResult result = await _handler.HandleAsync(request, + new FourthwallWebhookOptions { + SigningSecret = "", + SignatureMode = FourthwallWebhookSignatureMode.PlatformAppWebhook + }, ct); + + if (!result.IsKnownEvent || result.Event is null) { + logger?.LogDebug("[FourthWall] Received unknown/unsupported FourthWall event type"); + return; + } + + SubathonEvent? ev = MapToSubathonEvent(result.Event); + if (ev != null) { + SubathonEvents.RaiseSubathonEventCreated(ev); + logger?.LogDebug("[FourthWall] Raised {EventType} from {User}", ev.EventType, ev.User); + } + } - private readonly FourthwallWebhookHandler _handler = new(signatureVerifier: new FourthwallWebhookSignatureVerifier()); - [ExcludeFromCodeCoverage] - private bool CheckExpiry() - { + private bool CheckExpiry() { if (string.IsNullOrWhiteSpace(AccessToken)) return false; - + DateTime? expires = Utils.GetAccessTokenExpiry(AccessToken); if (expires == null) return false; - bool isExpired = DateTime.UtcNow >= expires.Value.AddSeconds(-60); + bool isExpired = DateTime.UtcNow >= expires.Value.AddSeconds(-60); return isExpired; } [ExcludeFromCodeCoverage] - private async Task CheckForTokenAsync(CancellationToken ct = default) - { - if (!HasTokenFile()) - { + private async Task CheckForTokenAsync(CancellationToken ct = default) { + if (!HasTokenFile()) { await StartOAuthFlowAsync(); - - if (string.IsNullOrWhiteSpace(AccessToken) || string.IsNullOrWhiteSpace(RefreshToken)) - { - return false; - } + + if (string.IsNullOrWhiteSpace(AccessToken) || string.IsNullOrWhiteSpace(RefreshToken)) return false; } - else if (CheckExpiry()) - { - var success = await StartOAuthRefreshAsync(ct); - if (!success) - { + else if (CheckExpiry()) { + bool success = await StartOAuthRefreshAsync(ct); + if (!success) { RevokeTokenFile(); await StartOAuthFlowAsync(); } + if (string.IsNullOrWhiteSpace(AccessToken)) return false; } return !string.IsNullOrWhiteSpace(AccessToken) && !CheckExpiry(); } - public async Task StartAsync(CancellationToken ct = default) - { - IntegrationEvents.ConnectionUpdated += OnTunnelUpdated; - - Utils.PendingOAuthCallback = null; - bool enabled = HasTokenFile(); - - if (enabled) - { - logger?.LogInformation("[FourthWall] Webhook listener ready at {Path}", WebhookPath); - _ = devTunnels.StartTunnelAsync(ct); - } - else - { - logger?.LogInformation("[FourthWall] Has not been setup before. Integration is disabled"); - BroadcastStatus(false, null); - } - await Task.CompletedTask; - } - - [ExcludeFromCodeCoverage] - public async Task Initialize(CancellationToken ct = default) - { - var tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); - if (!tunnelConn.Status) { - return; - } - - var canConnect = await CheckForTokenAsync(ct); - if (!canConnect || string.IsNullOrWhiteSpace(AccessToken)) - { + public async Task Initialize(CancellationToken ct = default) { + IntegrationConnection tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); + if (!tunnelConn.Status) return; + + bool canConnect = await CheckForTokenAsync(ct); + if (!canConnect || string.IsNullOrWhiteSpace(AccessToken)) { RevokeTokenFile(); BroadcastStatus(false, null); return; } - - var enabled = HasTokenFile(); - var client = new FourthwallApiClient(new HttpClientRequestAdapter(new FourthwallBearerAuthenticationProvider(accessToken: AccessToken))); - - var shop = await client.OpenApi.V10.Shops.Current.GetAsync(cancellationToken: ct); + + bool enabled = HasTokenFile(); + var client = + new FourthwallApiClient( + new HttpClientRequestAdapter(new FourthwallBearerAuthenticationProvider(AccessToken))); + + ShopV1? shop = await client.OpenApi.V10.Shops.Current.GetAsync(cancellationToken: ct); logger?.LogInformation("[FourthWall] connected to {shopName}", shop?.Name ?? "none"); - if (shop == null) - { + if (shop == null) { BroadcastStatus(false, null); return; - }; - + } + + ; + ShopName = shop.Name; - var webhooks = await client.OpenApi.V10.Webhooks.GetAsync(cancellationToken: ct); + WebhookConfigurationV1? webhooks = await client.OpenApi.V10.Webhooks.GetAsync(cancellationToken: ct); - bool hasWh = false; + var hasWh = false; if (webhooks is { Results: not null }) - { - foreach (var webhookConfigurationV1 in webhooks.Results) - { + foreach (Fourthwall.Client.Generated.Models.Openapi.Model.WebhookConfigurationV1 webhookConfigurationV1 in + webhooks.Results) if (!string.IsNullOrWhiteSpace(webhookConfigurationV1.Url) && - webhookConfigurationV1.Url.Contains(tunnelConn.Name.Replace("https://", ""), StringComparison.CurrentCultureIgnoreCase)) - { + webhookConfigurationV1.Url.Contains(tunnelConn.Name.Replace("https://", ""), + StringComparison.CurrentCultureIgnoreCase)) { // TODO what if we add more future scopes, we'd need to compare allowed_types. hasWh = true; logger?.LogDebug("Webhook found, no need to make a new one for fourthwall"); break; } - } - } - if (!hasWh) - { + if (!hasWh) { string? fullUrl = !string.IsNullOrWhiteSpace(tunnelConn.Name) && tunnelConn.Name != "(starting...)" ? tunnelConn.Name.TrimEnd('/') + WebhookPath : null; - WebhookConfigurationCreateRequest req = new WebhookConfigurationCreateRequest - { + var req = new WebhookConfigurationCreateRequest { Url = fullUrl, - AllowedTypes = - [ + AllowedTypes = [ WebhookConfigurationCreateRequest_allowedTypes.ORDER_PLACED, WebhookConfigurationCreateRequest_allowedTypes.DONATION, WebhookConfigurationCreateRequest_allowedTypes.SUBSCRIPTION_PURCHASED, @@ -163,76 +207,62 @@ public async Task Initialize(CancellationToken ct = default) WebhookConfigurationCreateRequest_allowedTypes.GIFT_PURCHASE ] }; - var resp = await client.OpenApi.V10.Webhooks.PostAsync(req, cancellationToken: ct); - if (resp == null || string.IsNullOrWhiteSpace(resp.Url)) - { + Fourthwall.Client.Generated.Models.Openapi.Model.WebhookConfigurationV1? resp = + await client.OpenApi.V10.Webhooks.PostAsync(req, cancellationToken: ct); + if (resp == null || string.IsNullOrWhiteSpace(resp.Url)) { logger?.LogWarning("[FourthWall] Unable to create Webhook configuration"); BroadcastStatus(false, null); return; } + hasWh = true; - logger?.LogInformation("[FourthWall] Webhook successfully made: {WHId}", resp.Id ); + logger?.LogInformation("[FourthWall] Webhook successfully made: {WHId}", resp.Id); } - var memberships = await client.OpenApi.V10.Memberships.Tiers.GetAsync(cancellationToken: ct); + List? memberships = + await client.OpenApi.V10.Memberships.Tiers.GetAsync(cancellationToken: ct); if (memberships is { Count: > 0 }) - { - memberships.ForEach(x => - { + memberships.ForEach(x => { if (string.IsNullOrWhiteSpace(x.Id) || string.IsNullOrWhiteSpace(x.Name)) return; - + MembershipNames[x.Id] = x.Name; }); - } IntegrationEvents.RaiseFourthWallMembershipsSynced(MembershipNames); - + // Seed status; include public URL if the tunnel is already running tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); BroadcastStatus(enabled, tunnelConn.Status ? tunnelConn.Name : null); await Task.CompletedTask; } - - public Task StopAsync(CancellationToken ct = default) - { - IntegrationEvents.ConnectionUpdated -= OnTunnelUpdated; - BroadcastStatus(false, null); - - return Task.CompletedTask; - } - [ExcludeFromCodeCoverage] - private async Task StartOAuthRefreshAsync(CancellationToken ct = default) - { + private async Task StartOAuthRefreshAsync(CancellationToken ct = default) { logger?.LogDebug("Refreshing FourthWall tokens..."); if (!HasTokenFile()) return false; if (string.IsNullOrWhiteSpace(RefreshToken)) return false; - using var client = httpClientFactory.CreateClient(nameof(FourthWallService)); - using var body = new FormUrlEncodedContent(new[] - { - new KeyValuePair("refresh_token", RefreshToken), + using HttpClient client = httpClientFactory.CreateClient(nameof(FourthWallService)); + using var body = new FormUrlEncodedContent(new[] { + new KeyValuePair("refresh_token", RefreshToken) }); - var response = await client.PostAsync(_refreshURl, body, ct); + HttpResponseMessage response = await client.PostAsync(_refreshURl, body, ct); - if (!response.IsSuccessStatusCode) - { - var error = await response.Content.ReadAsStringAsync(ct); + if (!response.IsSuccessStatusCode) { + string error = await response.Content.ReadAsStringAsync(ct); logger?.LogWarning("[FourthWall] Token refresh failed ({Status}): {Error}", response.StatusCode, error); return false; } - var responseJson = await response.Content.ReadAsStringAsync(ct); + string responseJson = await response.Content.ReadAsStringAsync(ct); var tokens = JsonSerializer.Deserialize>(responseJson); - var newAccess = tokens?.GetValueOrDefault("access_token").GetString(); - var newRefresh = tokens?.GetValueOrDefault("refresh_token").GetString() ?? RefreshToken; + string? newAccess = tokens?.GetValueOrDefault("access_token").GetString(); + string? newRefresh = tokens?.GetValueOrDefault("refresh_token").GetString() ?? RefreshToken; - if (string.IsNullOrWhiteSpace(newAccess)) - { + if (string.IsNullOrWhiteSpace(newAccess)) { logger?.LogWarning("[FourthWall] Token refresh returned no access_token"); return false; } @@ -243,72 +273,66 @@ private async Task StartOAuthRefreshAsync(CancellationToken ct = default) logger?.LogDebug("[FourthWall] Tokens refreshed successfully"); return true; } - - private async Task StartOAuthFlowAsync() - { + + private async Task StartOAuthFlowAsync() { RevokeTokenFile(); Utils.PendingOAuthCallback = null; logger?.LogDebug("Opening FourthWall OAuth..."); OpenBrowser(_oAuthURl); - var (newAccess, newRefresh) = await WaitForProtocolCallbackAsync(); - if (!string.IsNullOrEmpty(AccessToken) || string.IsNullOrEmpty(RefreshToken)) - { + (string? newAccess, string? newRefresh) = await WaitForProtocolCallbackAsync(); + if (!string.IsNullOrEmpty(AccessToken) || string.IsNullOrEmpty(RefreshToken)) { secureStorage.Set(StorageKeys.FourthWallAccessToken, newAccess!); secureStorage.Set(StorageKeys.FourthWallRefreshToken, newRefresh!); } } - private async Task<(string?, string?)> WaitForProtocolCallbackAsync(CancellationToken ct = default) - { - var timeout = DateTime.Now.AddMinutes(15); - while (DateTime.Now < timeout && !ct.IsCancellationRequested) - { - var cb = Utils.PendingOAuthCallback; - if (cb?.Provider == "fourthwall" && (!string.IsNullOrEmpty(cb.AccessToken) || !string.IsNullOrEmpty(cb.RefreshToken))) - { + + private async Task<(string?, string?)> WaitForProtocolCallbackAsync(CancellationToken ct = default) { + DateTime timeout = DateTime.Now.AddMinutes(15); + while (DateTime.Now < timeout && !ct.IsCancellationRequested) { + OAuthCallback? cb = Utils.PendingOAuthCallback; + if (cb?.Provider == "fourthwall" && + (!string.IsNullOrEmpty(cb.AccessToken) || !string.IsNullOrEmpty(cb.RefreshToken))) { Utils.PendingOAuthCallback = null; return (cb.AccessToken, cb.RefreshToken); } + await Task.Delay(250, ct); } + return (null, null); } - private bool HasTokenFile() - { + private bool HasTokenFile() { return secureStorage.Exists(StorageKeys.FourthWallAccessToken) && secureStorage.Exists(StorageKeys.FourthWallRefreshToken) && !string.IsNullOrWhiteSpace(AccessToken) && !string.IsNullOrWhiteSpace(RefreshToken); } [ExcludeFromCodeCoverage] - public void RevokeTokenFile() - { + public void RevokeTokenFile() { secureStorage.Delete(StorageKeys.FourthWallAccessToken); secureStorage.Delete(StorageKeys.FourthWallRefreshToken); } [ExcludeFromCodeCoverage] - private void OnTunnelUpdated(IntegrationConnection connection) - { + private void OnTunnelUpdated(IntegrationConnection connection) { if (connection is not { Source: SubathonEventSource.DevTunnels, Service: "Tunnel" }) return; bool enabled = HasTokenFile(); - if (enabled && connection.Status) - { + if (enabled && connection.Status) { Task.Run(() => Initialize()); return; } + BroadcastStatus(enabled, connection.Status ? connection.Name : null); } - private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) - { + private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { string? fullUrl = !string.IsNullOrWhiteSpace(tunnelBaseUrl) && tunnelBaseUrl != "(starting...)" ? tunnelBaseUrl.TrimEnd('/') + WebhookPath : null; - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Name = fullUrl ?? "", Status = enabled && fullUrl != null, Source = SubathonEventSource.FourthWall, @@ -320,242 +344,194 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) logger?.LogInformation("[FourthWall] Public webhook URL: {Url}", fullUrl); } - public async Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, CancellationToken ct = default) - { - var contentType = headers.TryGetValue("Content-Type", out var ct2) ? ct2 - : "application/x-www-form-urlencoded"; - - var newHeaders = headers.ToDictionary( - kvp => kvp.Key, - kvp => new[] { kvp.Value } - ); - IReadOnlyDictionary readOnlyHeaders = newHeaders; - - var request = new WebhookRequest - { - Method = "POST", - Path = WebhookPath, - Body = rawBody, - ContentType = contentType, - Headers = readOnlyHeaders - }; - - var result = await _handler.HandleAsync(request, new FourthwallWebhookOptions - { - SigningSecret = "", - SignatureMode = FourthwallWebhookSignatureMode.PlatformAppWebhook - }, ct); - - if (!result.IsKnownEvent || result.Event is null) - { - logger?.LogDebug("[FourthWall] Received unknown/unsupported FourthWall event type"); - return; - } - var ev = MapToSubathonEvent(result.Event); - if (ev != null) - { - SubathonEvents.RaiseSubathonEventCreated(ev); - logger?.LogDebug("[FourthWall] Raised {EventType} from {User}", ev.EventType, ev.User); - } - } - - public SubathonEvent? MapToSubathonEvent(FourthwallWebhookEvent fwEvent) - { - try - { - var sourceMode = config.GetOrderTypeMode(_configSection, + public SubathonEvent? MapToSubathonEvent(FourthwallWebhookEvent fwEvent) { + try { + OrderTypeModes sourceMode = config.GetOrderTypeMode(_configSection, $"{SubathonEventType.FourthWallOrder}", OrderTypeModes.Dollar); - var sourceMode2 = config.GetOrderTypeMode(_configSection, + OrderTypeModes sourceMode2 = config.GetOrderTypeMode(_configSection, $"{SubathonEventType.FourthWallGiftOrder}", OrderTypeModes.Dollar); var defaultCurrency = "USD"; - string username = "FourthWall Customer"; - if (fwEvent.TestMode) username = "FourthWall Test";//"SYSTEM"; + var username = "FourthWall Customer"; + if (fwEvent.TestMode) username = "FourthWall Test"; //"SYSTEM"; SubathonEvent? ev = null; - if (fwEvent is FourthwallDonationWebhookEvent donationEvent) - { - var d = donationEvent.Data; + if (fwEvent is FourthwallDonationWebhookEvent donationEvent) { + DonationV1 d = donationEvent.Data; if (!fwEvent.TestMode && !string.IsNullOrWhiteSpace(d.Username)) username = d.Username.Split(' ').First(); - ev = new SubathonEvent - { + ev = new SubathonEvent { Id = Utils.TryParseGuid(d.Id), - Source = string.Equals(username,"SYSTEM") ? SubathonEventSource.Simulated : SubathonEventSource.FourthWall, + Source = string.Equals(username, "SYSTEM") + ? SubathonEventSource.Simulated + : SubathonEventSource.FourthWall, EventType = SubathonEventType.FourthWallDonation, User = username, Value = - d.Amounts?.Total?.Value?.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) ?? + d.Amounts?.Total?.Value?.ToString("F2", CultureInfo.InvariantCulture) ?? "0.00", Currency = !string.IsNullOrWhiteSpace(d.Amounts?.Total?.Currency) ? d.Amounts?.Total?.Currency : defaultCurrency, - EventTimestamp = d.CreatedAt?.LocalDateTime ?? DateTime.Now.ToLocalTime(), + EventTimestamp = d.CreatedAt?.LocalDateTime ?? DateTime.Now.ToLocalTime() }; } - else if (fwEvent is FourthwallOrderPlacedWebhookEvent orderPlacedEvent) - { - var order = orderPlacedEvent.Data; + else if (fwEvent is FourthwallOrderPlacedWebhookEvent orderPlacedEvent) { + OrderV1 order = orderPlacedEvent.Data; if (!string.Equals("ORDER", order.Source?.Order?.Type ?? "", StringComparison.CurrentCultureIgnoreCase) - && !string.Equals("SAMPLES_ORDER", order.Source?.Order?.Type ?? "", StringComparison.CurrentCultureIgnoreCase)) - { + && !string.Equals("SAMPLES_ORDER", order.Source?.Order?.Type ?? "", + StringComparison.CurrentCultureIgnoreCase)) return null; - } - + //if (order.Status != OrderV1_status.CONFIRMED) return null; if (!fwEvent.TestMode && !string.IsNullOrWhiteSpace(order.Username)) username = order.Username.Split(' ').First(); if (string.Equals("SAMPLES_ORDER", order.Source?.Order?.Type ?? "", StringComparison.CurrentCultureIgnoreCase)) - { username = "Internal Samples"; - } var itemCount = 0; double totalValue = 0; double totalDirect = 0; string currency = order.Amounts?.Subtotal?.Currency ?? defaultCurrency; - + double costs = 0; double prices = 0; - order.Offers?.ForEach(x => - { - itemCount += (x.Variant?.Quantity ?? 1); - costs += (x.Variant?.Cost?.Value ?? 0); - prices += (x.Variant?.Price?.Value ?? 0); + order.Offers?.ForEach(x => { + itemCount += x.Variant?.Quantity ?? 1; + costs += x.Variant?.Cost?.Value ?? 0; + prices += x.Variant?.Price?.Value ?? 0; }); - - totalValue += (order.Amounts?.Subtotal?.Value ?? 0); - totalDirect += (order.Amounts?.Donation?.Value ?? 0); - var profit = Math.Max(prices - costs, 0); + totalValue += order.Amounts?.Subtotal?.Value ?? 0; + totalDirect += order.Amounts?.Donation?.Value ?? 0; + + double profit = Math.Max(prices - costs, 0); if (string.Equals("SAMPLES_ORDER", order.Source?.Order?.Type ?? "", - StringComparison.CurrentCultureIgnoreCase)) - { - logger?.LogInformation("[FourthWall] Samples order placed. Setting profit from {Profit} to 0.", profit); + StringComparison.CurrentCultureIgnoreCase)) { + logger?.LogInformation("[FourthWall] Samples order placed. Setting profit from {Profit} to 0.", + profit); profit = 0; } + totalDirect += profit; - ev = new SubathonEvent - { + ev = new SubathonEvent { Id = Utils.TryParseGuid(order.Id), - Source = string.Equals(username,"SYSTEM") ? SubathonEventSource.Simulated : SubathonEventSource.FourthWall, + Source = string.Equals(username, "SYSTEM") + ? SubathonEventSource.Simulated + : SubathonEventSource.FourthWall, EventType = SubathonEventType.FourthWallOrder, User = username, - Value = sourceMode switch - { + Value = sourceMode switch { OrderTypeModes.Item => $"{itemCount}", OrderTypeModes.Order => "New", - _ => totalValue.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) + _ => totalValue.ToString("F2", CultureInfo.InvariantCulture) }, - Currency = sourceMode switch - { + Currency = sourceMode switch { OrderTypeModes.Item => "items", OrderTypeModes.Order => "order", _ => string.IsNullOrWhiteSpace(currency) ? currency : defaultCurrency }, Amount = Math.Max(itemCount, 1), - SecondaryValue = $"{totalDirect.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}|{ + SecondaryValue = $"{totalDirect.ToString("F2", CultureInfo.InvariantCulture)}|{ (!string.IsNullOrWhiteSpace(currency) ? currency : defaultCurrency)}", EventTimestamp = order.CreatedAt?.LocalDateTime ?? DateTime.Now.ToLocalTime() }; - } - else if (fwEvent is FourthwallGiftPurchaseWebhookEvent giftPurchaseWebhookEvent) - { - var order = giftPurchaseWebhookEvent.Data; + else if (fwEvent is FourthwallGiftPurchaseWebhookEvent giftPurchaseWebhookEvent) { + GiftPurchaseV1 order = giftPurchaseWebhookEvent.Data; if (!fwEvent.TestMode && !string.IsNullOrWhiteSpace(order.Username)) username = order.Username.Split(' ').First(); var itemCount = 1; if (order.Quantity != null) itemCount = order.Quantity.Value; - + double totalValue = 0; double totalDirect = 0; string currency = order.Amounts?.Subtotal?.Currency ?? defaultCurrency; - + totalValue += order.Amounts?.Subtotal?.Value ?? 0; totalDirect += order.Amounts?.Profit?.Value ?? 0; - ev = new SubathonEvent - { + ev = new SubathonEvent { Id = Utils.TryParseGuid(order.Id), - Source = string.Equals(username,"SYSTEM") ? SubathonEventSource.Simulated : SubathonEventSource.FourthWall, + Source = string.Equals(username, "SYSTEM") + ? SubathonEventSource.Simulated + : SubathonEventSource.FourthWall, EventType = SubathonEventType.FourthWallGiftOrder, User = username, - Value = sourceMode2 switch - { + Value = sourceMode2 switch { OrderTypeModes.Item => $"{itemCount}", OrderTypeModes.Order => "New Gift", - _ => totalValue.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) + _ => totalValue.ToString("F2", CultureInfo.InvariantCulture) }, - Currency = sourceMode2 switch - { + Currency = sourceMode2 switch { OrderTypeModes.Item => "items", OrderTypeModes.Order => "order", _ => string.IsNullOrWhiteSpace(currency) ? currency : defaultCurrency }, Amount = Math.Max(itemCount, 1), - SecondaryValue = $"{totalDirect.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}|{ + SecondaryValue = $"{totalDirect.ToString("F2", CultureInfo.InvariantCulture)}|{ (!string.IsNullOrWhiteSpace(currency) ? currency : defaultCurrency)}", EventTimestamp = order.CreatedAt?.LocalDateTime ?? DateTime.Now.ToLocalTime() }; - } - else if (fwEvent is FourthwallSubscriptionPurchasedWebhookEvent subscriptionPurchasedWebhookEvent) - { - var data = subscriptionPurchasedWebhookEvent.Data; + else if (fwEvent is FourthwallSubscriptionPurchasedWebhookEvent subscriptionPurchasedWebhookEvent) { + MembershipSupporterV1 data = subscriptionPurchasedWebhookEvent.Data; if (!fwEvent.TestMode && !string.IsNullOrWhiteSpace(data.Nickname)) username = data.Nickname.Split(' ').First(); - var subscription = data.Subscription?.Active; + Active? subscription = data.Subscription?.Active; if (subscription == null) return null; var tierName = "DEFAULT"; MembershipNames.TryGetValue(subscription.Variant?.TierId ?? "DEFAULT", out tierName); if (string.IsNullOrWhiteSpace(tierName)) tierName = "DEFAULT"; - ev = new SubathonEvent - { + ev = new SubathonEvent { Id = fwEvent.TestMode ? Guid.NewGuid() : Utils.TryParseGuid(data.Id), - Source = string.Equals(username,"SYSTEM") ? SubathonEventSource.Simulated : SubathonEventSource.FourthWall, + Source = string.Equals(username, "SYSTEM") + ? SubathonEventSource.Simulated + : SubathonEventSource.FourthWall, EventType = SubathonEventType.FourthWallMembership, User = username, Value = tierName, EventTypeMeta = tierName, Currency = "member", Amount = subscription.Variant?.Interval == MembershipTierVariantV1_interval.ANNUAL ? 12 : 1, - SecondaryValue = $"{(subscription.Variant?.Amount?.Value ?? 0).ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}|{ - (!string.IsNullOrWhiteSpace(subscription.Variant?.Amount?.Currency) ? subscription.Variant?.Amount?.Currency : defaultCurrency)}", + SecondaryValue = + $"{(subscription.Variant?.Amount?.Value ?? 0).ToString("F2", CultureInfo.InvariantCulture)}|{ + (!string.IsNullOrWhiteSpace(subscription.Variant?.Amount?.Currency) ? subscription.Variant?.Amount?.Currency : defaultCurrency)}", EventTimestamp = subscriptionPurchasedWebhookEvent.CreatedAt.LocalDateTime }; } - else if (fwEvent is FourthwallSubscriptionChangedWebhookEvent subscriptionChangedWebhookEvent) - { - var data = subscriptionChangedWebhookEvent.Data; + else if (fwEvent is FourthwallSubscriptionChangedWebhookEvent subscriptionChangedWebhookEvent) { + MembershipSupporterV1 data = subscriptionChangedWebhookEvent.Data; if (!fwEvent.TestMode && !string.IsNullOrWhiteSpace(data.Nickname)) username = data.Nickname.Split(' ').First(); - var subscription = data.Subscription?.Active; + Active? subscription = data.Subscription?.Active; if (subscription == null) return null; var tierName = "DEFAULT"; MembershipNames.TryGetValue(subscription.Variant?.TierId ?? "DEFAULT", out tierName); if (string.IsNullOrWhiteSpace(tierName)) tierName = "DEFAULT"; - ev = new SubathonEvent - { + ev = new SubathonEvent { Id = fwEvent.TestMode ? Guid.NewGuid() : Utils.TryParseGuid(data.Id), - Source = string.Equals(username,"SYSTEM") ? SubathonEventSource.Simulated : SubathonEventSource.FourthWall, + Source = string.Equals(username, "SYSTEM") + ? SubathonEventSource.Simulated + : SubathonEventSource.FourthWall, EventType = SubathonEventType.FourthWallMembership, User = username, Value = tierName, EventTypeMeta = tierName, Currency = "member", Amount = subscription.Variant?.Interval == MembershipTierVariantV1_interval.ANNUAL ? 12 : 1, - SecondaryValue = $"{(subscription.Variant?.Amount?.Value ?? 0).ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}|{ - (!string.IsNullOrWhiteSpace(subscription.Variant?.Amount?.Currency) ? subscription.Variant?.Amount?.Currency : defaultCurrency)}", + SecondaryValue = + $"{(subscription.Variant?.Amount?.Value ?? 0).ToString("F2", CultureInfo.InvariantCulture)}|{ + (!string.IsNullOrWhiteSpace(subscription.Variant?.Amount?.Currency) ? subscription.Variant?.Amount?.Currency : defaultCurrency)}", EventTimestamp = subscriptionChangedWebhookEvent.CreatedAt.LocalDateTime }; } + return ev; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[FourthWall] Failed to map FourthWall event to SubathonEvent"); return null; } } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/GoAffProService.cs b/SubathonManager.Integration/GoAffProService.cs index 68c83994..d8618b86 100644 --- a/SubathonManager.Integration/GoAffProService.cs +++ b/SubathonManager.Integration/GoAffProService.cs @@ -1,10 +1,10 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text.Json; -using Microsoft.Extensions.Logging; using GoAffPro.Client; using GoAffPro.Client.Generated.Models; using GoAffPro.Client.Generated.User.Sites; +using Microsoft.Extensions.Logging; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -13,51 +13,57 @@ using SubathonManager.Core.Objects; using SubathonManager.Core.Security; using SubathonManager.Core.Security.Interfaces; -using SubathonManager.Services; // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; -public class GoAffProService(ILogger? logger, IConfig config, ISecureStorage secureStorage, ITimerService? timerService = null) : IDisposable, IAppService -{ - private bool _disposed = false; +public class GoAffProService( + ILogger? logger, + IConfig config, + ISecureStorage secureStorage, + ITimerService? timerService = null) : IDisposable, IAppService { + private readonly string _configSection = "GoAffPro"; private GoAffProClient? _client; private CancellationTokenSource? _detectorCts; - private readonly string _configSection = "GoAffPro"; - - internal Uri Endpoint = new Uri("https://api.goaffpro.com/v1/", UriKind.Absolute); + private bool _disposed; + + + private readonly HashSet _siteIds = new(); + + internal Uri Endpoint = new("https://api.goaffpro.com/v1/", UriKind.Absolute); internal int MaxRetries = 20; + // for alt backfill checking + internal bool BackfillMode = FeatureFlags.GoAffProBackfillPollingEnabled; + internal TimeSpan BackfillLookback = TimeSpan.FromHours(2); + internal TimeSpan PollInterval = TimeSpan.FromSeconds(30); + internal int PageSize = 100; + internal int MaxPagesPerPoll = 50; + internal TimeSpan PruneInterval = TimeSpan.FromMinutes(15); + private string? Email => secureStorage.GetOrDefault(StorageKeys.GoAffProEmail, string.Empty); private string? Password => secureStorage.GetOrDefault(StorageKeys.GoAffProPassword, string.Empty); - - private HashSet _siteIds = new(); - - public async Task StartAsync(CancellationToken ct = default) - { + public async Task StartAsync(CancellationToken ct = default) { await StopAsync(ct); timerService?.Register("goaffpro-auth-check", TimeSpan.FromHours(48), ReconnectCheck); - + if (string.IsNullOrWhiteSpace(Email) || string.IsNullOrWhiteSpace(Password)) return; _siteIds.Clear(); - try - { + try { _client = await GoAffProClient.CreateLoggedInAsync( - options: new GoAffProClientOptions() - { + options: new GoAffProClientOptions { MaxRetries = MaxRetries, Timeout = TimeSpan.FromSeconds(30), BaseUrl = Endpoint }, email: Email, password: Password, cancellationToken: ct); - - IntegrationConnection conn = new IntegrationConnection - { + + var conn = new IntegrationConnection { Name = "", Status = true, Source = SubathonEventSource.GoAffPro, @@ -65,20 +71,16 @@ public async Task StartAsync(CancellationToken ct = default) }; IntegrationEvents.RaiseConnectionUpdate(conn); } - catch (Exception e) - { + catch (Exception e) { _client = null; logger?.LogWarning(e, "[GoAffPro] Failed to login to GoAffPro"); return; } - + UserSiteListResponse? sitesResponse; - try - { - sitesResponse = await _client.Api.User.Sites.GetAsync(reqConfig => - { - reqConfig.QueryParameters.FieldsAsGetFieldsQueryParameterType = - [ + try { + sitesResponse = await _client.Api.User.Sites.GetAsync(reqConfig => { + reqConfig.QueryParameters.FieldsAsGetFieldsQueryParameterType = [ GetFieldsQueryParameterType.Currency, GetFieldsQueryParameterType.Id, GetFieldsQueryParameterType.Name, @@ -88,26 +90,24 @@ public async Task StartAsync(CancellationToken ct = default) reqConfig.QueryParameters.Offset = 0; }, ct); } - catch (Exception e) - { + catch (Exception e) { logger?.LogWarning(e, "[GoAffPro] Failed to fetch connected sites"); await StopAsync(ct); return; } - + if (sitesResponse?.Sites == null) return; - foreach (var site in sitesResponse.Sites.Where(site => site is { Id: not null, Status: UserSite_status.Approved })) - { + foreach (UserSite site in sitesResponse.Sites.Where(site => site is + { Id: not null, Status: UserSite_status.Approved })) { // dynamically provision any store on the account we haven't seen before - var store = GoAffProStoreRegistry.GetOrProvision(site.Id!.Value, site.Name ?? ""); + GoAffProStore store = GoAffProStoreRegistry.GetOrProvision(site.Id!.Value, site.Name ?? ""); if (!store.Enabled) continue; if (!_siteIds.Add(site.Id.Value)) continue; GoAffProStoreRegistry.MarkActiveOnAccount(site.Id.Value); string currency = !string.IsNullOrWhiteSpace(site.Currency) ? site.Currency : "USD"; - IntegrationConnection conn = new IntegrationConnection - { + var conn = new IntegrationConnection { Name = currency, Status = true, Source = SubathonEventSource.GoAffPro, @@ -115,35 +115,196 @@ public async Task StartAsync(CancellationToken ct = default) }; IntegrationEvents.RaiseConnectionUpdate(conn); } - + _detectorCts = new CancellationTokenSource(); - if (!int.TryParse(config.Get(_configSection, "DaysOffset", "0"), out var daysOffset)) daysOffset = 0; - + if (!int.TryParse(config.Get(_configSection, "DaysOffset", "0"), out int daysOffset)) daysOffset = 0; + _client.OrderObserverStartTime = DateTimeOffset.UtcNow - TimeSpan.FromDays(daysOffset); logger?.LogInformation("[GoAffPro] Started GoAffPro service with {Count} connected sites", _siteIds.Count); - - _ = Task.Run(async() => { + + if (BackfillMode) { + GoAffProClient client = _client; + CancellationToken token = _detectorCts.Token; + _ = Task.Run(() => PollWithBackfillAsync(client, TimeSpan.FromDays(daysOffset), token), token); + return; + } + + _ = Task.Run(async () => { logger?.LogInformation("[GoAffPro] GoAffPro is now polling for orders..."); - await foreach (var order in _client.NewOrdersAsync( - pollingInterval: TimeSpan.FromSeconds(30), - pageSize: 100, - cancellationToken: _detectorCts.Token)) - { + await foreach (UserOrderFeedItem order in _client.NewOrdersAsync( + TimeSpan.FromSeconds(30), + 100, + _detectorCts.Token)) HandleOrder(order); - } logger?.LogInformation("[GoAffPro] GoAffPro polling finished"); }, _detectorCts.Token); } - public void SimulateOrder(decimal total, int itemCount, decimal commissionTotal, GoAffProStore? affilStore, string currency = "USD") - { - string id = Guid.NewGuid().ToString(); + private async Task PollWithBackfillAsync(GoAffProClient client, TimeSpan initialLookback, CancellationToken ct) { + logger?.LogInformation("[GoAffPro] GoAffPro is now polling for orders (backfill mode, {Hours:0.##}h window)...", + BackfillLookback.TotalHours); + + Dictionary seen = new(); + string? sinceId = null; + DateTimeOffset sinceIdSetAt = default; + DateTimeOffset lastPrune = DateTimeOffset.UtcNow; + var first = true; + + while (!ct.IsCancellationRequested) { + try { + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset? createdAtMin = null; + string? pollSinceId = null; + + if (first) { + TimeSpan lookback = initialLookback > BackfillLookback ? initialLookback : BackfillLookback; + createdAtMin = now - lookback; + } + else if (sinceId != null && now - sinceIdSetAt < BackfillLookback) { + pollSinceId = sinceId; + } + else { + sinceId = null; + createdAtMin = now - BackfillLookback; + } + + List orders = await FetchOrdersAsync(client, createdAtMin, pollSinceId, ct); + first = false; + + if (now - lastPrune >= PruneInterval) { + PruneSeen(seen, now); + lastPrune = now; + } + + string? maxId = null; + foreach (UserOrderFeedItem order in orders.OrderBy(o => o.CreatedAt ?? DateTimeOffset.MinValue)) { + string? id = GetOrderId(order); + if (id == null) continue; + if (IsHigherId(id, maxId)) maxId = id; + if (!seen.TryAdd($"{id}|{order.Status}", now)) continue; + HandleOrder(order); + } + + if (maxId != null) { + if (sinceId == null) sinceIdSetAt = now; + if (IsHigherId(maxId, sinceId)) sinceId = maxId; + } + } + catch (OperationCanceledException) { + break; + } + catch (Exception e) { + logger?.LogWarning(e, "[GoAffPro] Failed to poll for orders"); + } + + try { + await Task.Delay(PollInterval, ct); + } + catch (OperationCanceledException) { + break; + } + } + + logger?.LogInformation("[GoAffPro] GoAffPro polling finished"); + } + + private async Task> FetchOrdersAsync(GoAffProClient client, DateTimeOffset? createdAtMin, + string? sinceId, CancellationToken ct) { + List all = new(); + var offset = 0; + + for (var page = 0; page < MaxPagesPerPoll; page++) { + ct.ThrowIfCancellationRequested(); + int pageOffset = offset; + UserOrderFeedResponse? response = await client.Api.User.Feed.Orders.GetAsync(reqConfig => { + reqConfig.QueryParameters.Limit = PageSize; + reqConfig.QueryParameters.Offset = pageOffset; + if (createdAtMin.HasValue) reqConfig.QueryParameters.CreatedAtMin = createdAtMin.Value; + if (!string.IsNullOrWhiteSpace(sinceId)) reqConfig.QueryParameters.SinceId = sinceId; + }, ct); + + List? orders = response?.Orders; + if (orders == null || orders.Count == 0) break; + all.AddRange(orders); + if (orders.Count < PageSize) break; + offset += orders.Count; + } + + return all; + } + + private void PruneSeen(Dictionary seen, DateTimeOffset now) { + TimeSpan maxAge = BackfillLookback + (PollInterval * 2); + List? stale = null; + foreach (KeyValuePair entry in seen) { + if (now - entry.Value <= maxAge) continue; + stale ??= new List(); + stale.Add(entry.Key); + } + + if (stale == null) return; + foreach (string key in stale) seen.Remove(key); + } + + private static string? GetOrderId(UserOrderFeedItem order) { + if (order.Id == null) return null; + if (!string.IsNullOrWhiteSpace(order.Id.String)) return order.Id.String; + return order.Id.Integer?.ToString(CultureInfo.InvariantCulture); + } + + private static bool IsHigherId(string candidate, string? current) { + if (string.IsNullOrWhiteSpace(current)) return true; + if (long.TryParse(candidate, NumberStyles.Integer, CultureInfo.InvariantCulture, out long a) && + long.TryParse(current, NumberStyles.Integer, CultureInfo.InvariantCulture, out long b)) + return a > b; + return string.CompareOrdinal(candidate, current) > 0; + } + + public Task StopAsync(CancellationToken ct = default) { + timerService?.Unregister("goaffpro-auth-check"); + + foreach (GoAffProStore store in GoAffProStoreRegistry.All()) { + GoAffProStoreRegistry.MarkActiveOnAccount(store.SiteId, false); + var conn = new IntegrationConnection { + Name = "", + Status = false, + Source = SubathonEventSource.GoAffPro, + Service = store.InternalName, + Configured = false + }; + IntegrationEvents.RaiseConnectionUpdate(conn); + } + + var connection = new IntegrationConnection { + Name = "", + Status = false, + Source = SubathonEventSource.GoAffPro, + Service = nameof(SubathonEventSource.GoAffPro), + Configured = !string.IsNullOrWhiteSpace(Email) && !string.IsNullOrWhiteSpace(Password) + }; + IntegrationEvents.RaiseConnectionUpdate(connection); + + if (_client != null && _detectorCts is { IsCancellationRequested: false }) + _detectorCts.Cancel(); + _client = null; + return Task.CompletedTask; + } + + [ExcludeFromCodeCoverage] + public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + public void SimulateOrder(decimal total, int itemCount, decimal commissionTotal, GoAffProStore? affilStore, + string currency = "USD") { + var id = Guid.NewGuid().ToString(); // id is meant to be a long but w/e - UserOrderFeedItem order = new UserOrderFeedItem(); + var order = new UserOrderFeedItem(); int idInt = affilStore?.SiteId > 0 ? affilStore.SiteId : int.MaxValue; - order.SiteId = new UserOrderFeedItem.UserOrderFeedItem_site_id() { Integer = idInt }; - order.Id = new UserOrderFeedItem.UserOrderFeedItem_id() { String = id}; + order.SiteId = new UserOrderFeedItem.UserOrderFeedItem_site_id { Integer = idInt }; + order.Id = new UserOrderFeedItem.UserOrderFeedItem_id { String = id }; order.Number = "SIMULATED"; order.Total = total.ToString(CultureInfo.InvariantCulture); order.Subtotal = total.ToString(CultureInfo.InvariantCulture); @@ -151,20 +312,16 @@ public void SimulateOrder(decimal total, int itemCount, decimal commissionTotal, order.Currency = currency; order.Status = "approved"; order.CreatedAt = DateTimeOffset.UtcNow; - order.LineItems = new List() - { - new UserOrderLineItem() - { + order.LineItems = new List { + new() { Quantity = itemCount } }; HandleOrder(order); } - private void HandleOrder(UserOrderFeedItem order) - { - try - { + private void HandleOrder(UserOrderFeedItem order) { + try { // If an order comes in as new and then approved, only one is added due to unique id's if (order.Id == null || order.SiteId == null || order.LineItems == null || @@ -173,8 +330,7 @@ private void HandleOrder(UserOrderFeedItem order) !string.Equals(order.Status, "new", StringComparison.OrdinalIgnoreCase))) return; // new and approved can both come in, but same id will mean it doesn't add twice - SubathonEvent ev = new SubathonEvent - { + var ev = new SubathonEvent { Id = Utils.CreateGuidFromUniqueString(!string.IsNullOrWhiteSpace(order.Id.String) ? order.Id.String : order.Id!.Integer.ToString()), @@ -184,46 +340,42 @@ private void HandleOrder(UserOrderFeedItem order) ev.EventTimestamp = order.CreatedAt.Value.LocalDateTime; ev.User = "New Order!"; - if (!string.IsNullOrWhiteSpace(order.Number) && order.Number == "SIMULATED") - { + if (!string.IsNullOrWhiteSpace(order.Number) && order.Number == "SIMULATED") { ev.User = "SIMULATED"; ev.Source = SubathonEventSource.Simulated; // check based on eventType in event service } - var site = order.SiteId!.Integer; - if (site == null || !GoAffProStoreRegistry.TryGetBySiteId((int)site, out var store)) return; + int? site = order.SiteId!.Integer; + if (site == null || !GoAffProStoreRegistry.TryGetBySiteId((int)site, out GoAffProStore? store)) return; if (!store.Enabled) return; // we will listen for these sites regardless in orders, but will ignore if not enabled. - var enabled = config.GetBool(_configSection, $"{store.InternalName}.Enabled", true); + bool enabled = config.GetBool(_configSection, $"{store.InternalName}.Enabled", true); if (!enabled) return; - var sourceMode = config.GetOrderTypeMode(_configSection, + OrderTypeModes sourceMode = config.GetOrderTypeMode(_configSection, store.InternalName, OrderTypeModes.Dollar); - ev.Currency = sourceMode switch - { + ev.Currency = sourceMode switch { OrderTypeModes.Item => "items", OrderTypeModes.Order => "order", _ => order.Currency }; - int itemCount = 0; - foreach (var item in order.LineItems) - { + var itemCount = 0; + foreach (UserOrderLineItem item in order.LineItems) { itemCount += item.Quantity ?? 0; itemCount -= item.RefundQuantity ?? 0; } + ev.Amount = itemCount; - switch (sourceMode) - { + switch (sourceMode) { case OrderTypeModes.Dollar: ev.Value = $"{order.Subtotal}"; break; case OrderTypeModes.Order: ev.Value = "New"; break; - default: - { + default: { ev.Value = $"{itemCount}"; break; } @@ -239,49 +391,15 @@ private void HandleOrder(UserOrderFeedItem order) SubathonEvents.RaiseSubathonEventCreated(ev); } - catch (Exception e) - { - logger?.LogWarning(e, "[GoAffPro] Failed to consume order. Data: {Serialize}", JsonSerializer.Serialize(order.AdditionalData)); + catch (Exception e) { + logger?.LogWarning(e, "[GoAffPro] Failed to consume order. Data: {Serialize}", + JsonSerializer.Serialize(order.AdditionalData)); } } - public Task StopAsync(CancellationToken ct = default) - { - timerService?.Unregister("goaffpro-auth-check"); - - foreach (var store in GoAffProStoreRegistry.All()) - { - GoAffProStoreRegistry.MarkActiveOnAccount(store.SiteId, false); - IntegrationConnection conn = new IntegrationConnection - { - Name = "", - Status = false, - Source = SubathonEventSource.GoAffPro, - Service = store.InternalName, - Configured = false - }; - IntegrationEvents.RaiseConnectionUpdate(conn); - } - - IntegrationConnection connection = new IntegrationConnection - { - Name = "", - Status = false, - Source = SubathonEventSource.GoAffPro, - Service = nameof(SubathonEventSource.GoAffPro), - Configured = !string.IsNullOrWhiteSpace(Email) && !string.IsNullOrWhiteSpace(Password) - }; - IntegrationEvents.RaiseConnectionUpdate(connection); - - if (_client != null && _detectorCts is { IsCancellationRequested: false }) - _detectorCts.Cancel(); - _client = null; - return Task.CompletedTask; - } - - public async Task ReconnectCheck(CancellationToken ct = default) - { - var conn = Utils.GetConnection(SubathonEventSource.GoAffPro, nameof(SubathonEventSource.GoAffPro)); + public async Task ReconnectCheck(CancellationToken ct = default) { + IntegrationConnection conn = + Utils.GetConnection(SubathonEventSource.GoAffPro, nameof(SubathonEventSource.GoAffPro)); if (!conn.Status) return; await StopAsync(ct); @@ -290,17 +408,9 @@ public async Task ReconnectCheck(CancellationToken ct = default) } [ExcludeFromCodeCoverage] - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - [ExcludeFromCodeCoverage] - protected virtual void Dispose(bool disposing) - { + protected virtual void Dispose(bool disposing) { if (_disposed) return; - if (_client != null && _detectorCts is { IsCancellationRequested: true }) + if (_client != null && _detectorCts is { IsCancellationRequested: true }) _detectorCts.Cancel(); _client?.Dispose(); _disposed = true; diff --git a/SubathonManager.Integration/JuniperService.cs b/SubathonManager.Integration/JuniperService.cs index 777afc45..9244357b 100644 --- a/SubathonManager.Integration/JuniperService.cs +++ b/SubathonManager.Integration/JuniperService.cs @@ -5,14 +5,15 @@ using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; -using SubathonManager.Core.Objects; using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; namespace SubathonManager.Integration; -public class JuniperService(ILogger? logger, IHttpClientFactory httpClientFactory, - ITimerService? timerService = null) : IDisposable, IAppService -{ +public class JuniperService( + ILogger? logger, + IHttpClientFactory httpClientFactory, + ITimerService? timerService = null) : IDisposable, IAppService { internal const string OrdersSumBase = "https://prod.orders.junipercreates.com/orders/sum"; internal const string FixedEndTime = "2099-11-20T07:59:00.000Z"; private const int MaxIdsPerRequest = 10; @@ -20,37 +21,23 @@ public class JuniperService(ILogger? logger, IHttpClientFactory private static readonly TimeSpan Interval = TimeSpan.FromSeconds(180); private static readonly TimeSpan HttpTimeout = TimeSpan.FromSeconds(20); + private bool _errored; + private bool _lastBroadcastStatus; private DateTime _lastFetchTime = DateTime.UtcNow; private bool _running; - private bool _errored; - private bool _lastBroadcastStatus; public bool Connected => _running && !_errored; - internal static List MakeQueryUrls(IReadOnlyList productIds, DateTime startTime) - { - var urls = new List(); - string start = startTime.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); - for (int i = 0; i < productIds.Count; i += MaxIdsPerRequest) - { - string joined = string.Join(",", productIds.Skip(i).Take(MaxIdsPerRequest)); - urls.Add($"{OrdersSumBase}?productIds={joined}&startTime={start}&endTime={FixedEndTime}"); - } - return urls; - } - - public Task StartAsync(CancellationToken ct = default) - { + public Task StartAsync(CancellationToken ct = default) { timerService?.Unregister(TimerKey); _errored = false; - var productIds = JuniperStoreRegistry.AllValidProductIds(); - if (productIds.Count == 0) - { + List productIds = JuniperStoreRegistry.AllValidProductIds(); + if (productIds.Count == 0) { logger?.LogInformation("[Juniper] No products tracked. Integration inactive."); _running = false; - BroadcastStatus(force: true); + BroadcastStatus(true); return Task.CompletedTask; } @@ -58,45 +45,54 @@ public Task StartAsync(CancellationToken ct = default) _lastFetchTime = DateTime.UtcNow; timerService?.Register(TimerKey, Interval, PollAsync); logger?.LogInformation("[Juniper] Tracking started ({Count} products)", productIds.Count); - BroadcastStatus(force: true); + BroadcastStatus(true); return Task.CompletedTask; } - public Task StopAsync(CancellationToken ct = default) - { + public Task StopAsync(CancellationToken ct = default) { timerService?.Unregister(TimerKey); _running = false; - BroadcastStatus(force: true); + BroadcastStatus(true); return Task.CompletedTask; } - public async Task RestartAsync() - { + public void Dispose() { + timerService?.Unregister(TimerKey); + } + + internal static List MakeQueryUrls(IReadOnlyList productIds, DateTime startTime) { + var urls = new List(); + var start = startTime.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); + for (var i = 0; i < productIds.Count; i += MaxIdsPerRequest) { + string joined = string.Join(",", productIds.Skip(i).Take(MaxIdsPerRequest)); + urls.Add($"{OrdersSumBase}?productIds={joined}&startTime={start}&endTime={FixedEndTime}"); + } + + return urls; + } + + public async Task RestartAsync() { await StopAsync(); await StartAsync(); } - public void Dispose() => timerService?.Unregister(TimerKey); - - internal async Task PollAsync(CancellationToken ct) - { + internal async Task PollAsync(CancellationToken ct) { if (!_running) return; - var startTime = _lastFetchTime; + DateTime startTime = _lastFetchTime; _lastFetchTime = DateTime.UtcNow; - var productIds = JuniperStoreRegistry.AllValidProductIds(); + List productIds = JuniperStoreRegistry.AllValidProductIds(); if (productIds.Count == 0) return; - bool anyError = false; - foreach (var url in MakeQueryUrls(productIds, startTime)) - { + var anyError = false; + foreach (string url in MakeQueryUrls(productIds, startTime)) { string? json = await GetStringAsync(url, ct); - if (json == null) - { + if (json == null) { anyError = true; continue; } + if (!HandleOrdersResponse(json, startTime)) anyError = true; } @@ -105,41 +101,33 @@ internal async Task PollAsync(CancellationToken ct) BroadcastStatus(); } - internal bool HandleOrdersResponse(string json, DateTime windowStart) - { - try - { - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; + internal bool HandleOrdersResponse(string json, DateTime windowStart) { + try { + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; logger?.LogDebug("[Juniper] orders/sum response ({Kind}): {Body}", root.ValueKind, json); if (root.ValueKind != JsonValueKind.Object || - !root.TryGetProperty("products_details", out var products) || - products.ValueKind != JsonValueKind.Object) - { + !root.TryGetProperty("products_details", out JsonElement products) || + products.ValueKind != JsonValueKind.Object) { logger?.LogWarning("[Juniper] orders/sum response missing products_details: {Body}", json); return false; } - foreach (JsonProperty product in products.EnumerateObject()) - { + foreach (JsonProperty product in products.EnumerateObject()) { string productId = product.Name; - if (!product.Value.TryGetProperty("units_sold", out var unitsEl) || + if (!product.Value.TryGetProperty("units_sold", out JsonElement unitsEl) || !unitsEl.TryGetInt32(out int unitsSold)) continue; if (unitsSold <= 0) continue; - if (!JuniperStoreRegistry.TryGetProduct(productId, out var juniperProduct)) - { - continue; - } - - SubathonEvent ev = new SubathonEvent - { + if (!JuniperStoreRegistry.TryGetProduct(productId, out JuniperProduct? juniperProduct)) continue; + + var ev = new SubathonEvent { Amount = unitsSold, EventTypeMeta = productId, Currency = "sales", @@ -151,26 +139,23 @@ internal bool HandleOrdersResponse(string json, DateTime windowStart) }; SubathonEvents.RaiseSubathonEventCreated(ev); } - + return true; } - catch (JsonException ex) - { + catch (JsonException ex) { logger?.LogWarning(ex, "[Juniper] Bad orders/sum response: {Body}", json); return false; } } - public static void Simulate(string meta, int count) - { + public static void Simulate(string meta, int count) { if (count <= 0) count = 1; meta = meta?.Trim() ?? ""; - string name = JuniperOrderHelper.TryGetProduct(meta, out var product) + string name = JuniperOrderHelper.TryGetProduct(meta, out JuniperProduct? product) ? product.ProductName : "Test Product"; - SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent - { + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.JuniperMerchSale, EventTypeMeta = meta, @@ -182,15 +167,13 @@ public static void Simulate(string meta, int count) }); } - private void BroadcastStatus(bool force = false) - { - var stores = JuniperStoreRegistry.AllStores(); + private void BroadcastStatus(bool force = false) { + IReadOnlyList stores = JuniperStoreRegistry.AllStores(); bool status = Connected; if (!force && status == _lastBroadcastStatus) return; _lastBroadcastStatus = status; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.JuniperCreates, Service = nameof(SubathonEventSource.JuniperCreates), Name = stores.Count == 0 ? "" : $"{stores.Count} store(s)", @@ -198,44 +181,38 @@ private void BroadcastStatus(bool force = false) Configured = stores.Count > 0 }); - foreach (var store in stores) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + foreach (JuniperStore store in stores) + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.JuniperCreates, Service = store.StoreName, Name = $"{store.Products.Count} tracked", Status = status && store.Enabled, Configured = true }); - } } - private async Task GetStringAsync(string url, CancellationToken ct) - { - using var client = httpClientFactory.CreateClient(nameof(JuniperService)); + private async Task GetStringAsync(string url, CancellationToken ct) { + using HttpClient client = httpClientFactory.CreateClient(nameof(JuniperService)); client.Timeout = HttpTimeout; if (!client.DefaultRequestHeaders.UserAgent.Any()) - client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) SubathonManager"); - try - { - var response = await client.GetAsync(url, ct); - if (!response.IsSuccessStatusCode) - { + client.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) SubathonManager"); + try { + HttpResponseMessage response = await client.GetAsync(url, ct); + if (!response.IsSuccessStatusCode) { logger?.LogWarning("[Juniper] {Status} from {Url}", response.StatusCode, url); return null; } + return await response.Content.ReadAsStringAsync(ct); } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { logger?.LogWarning("[Juniper] Timeout fetching {Url}", url); return null; } - catch (HttpRequestException ex) - { + catch (HttpRequestException ex) { logger?.LogWarning(ex, "[Juniper] Request failed for {Url}", url); return null; } } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/KoFiService.cs b/SubathonManager.Integration/KoFiService.cs index 558e57c9..8dc3fe87 100644 --- a/SubathonManager.Integration/KoFiService.cs +++ b/SubathonManager.Integration/KoFiService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net.Http.Headers; using System.Text; using Agash.Webhook.Abstractions; @@ -16,161 +17,151 @@ namespace SubathonManager.Integration; -public class KoFiService(ILogger? logger, IConfig config, IHttpClientFactory httpClientFactory, DevTunnelsService devTunnels, ISecureStorage secureStorage) - : IWebhookIntegration -{ - private readonly string _configSection = "KoFi"; - +public class KoFiService( + ILogger? logger, + IConfig config, + IHttpClientFactory httpClientFactory, + DevTunnelsService devTunnels, + ISecureStorage secureStorage) + : IWebhookIntegration { // Headers forwarded verbatim to any configured forward URLs. // Hop-by-hop and transport headers are excluded; they don't apply end-to-end. - private static readonly HashSet _skipForwardHeaders = new(StringComparer.OrdinalIgnoreCase) - { + private static readonly HashSet _skipForwardHeaders = new(StringComparer.OrdinalIgnoreCase) { "host", "connection", "transfer-encoding", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "upgrade" }; - public string WebhookPath => "/api/webhooks/kofi"; + private readonly string _configSection = "KoFi"; private readonly KoFiWebhookHandler _handler = new(); - public async Task StartAsync(CancellationToken ct = default) - { + public string WebhookPath => "/api/webhooks/kofi"; + + public async Task StartAsync(CancellationToken ct = default) { IntegrationEvents.ConnectionUpdated += OnTunnelUpdated; - var token = secureStorage.Get(StorageKeys.KoFiVerificationToken); // config.GetFromEncoded(_configSection, "VerificationToken", string.Empty); + string? + token = secureStorage.Get(StorageKeys + .KoFiVerificationToken); // config.GetFromEncoded(_configSection, "VerificationToken", string.Empty); bool enabled = !string.IsNullOrWhiteSpace(token); - if (enabled) - { + if (enabled) { logger?.LogInformation("[Ko-Fi] Webhook listener ready at {Path}", WebhookPath); // Start tunnel on demand; no need to open a public endpoint if no token is set. // Fire-and-forget: OnTunnelUpdated will broadcast the composed URL once ready. _ = devTunnels.StartTunnelAsync(ct); } - else - { + else { logger?.LogInformation("[Ko-Fi] No verification token configured; Ko-Fi integration is disabled"); } // Seed status; include public URL if the tunnel is already running - var tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); + IntegrationConnection tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); BroadcastStatus(enabled, tunnelConn.Status ? tunnelConn.Name : null); await Task.CompletedTask; } - public Task StopAsync(CancellationToken ct = default) - { + public Task StopAsync(CancellationToken ct = default) { IntegrationEvents.ConnectionUpdated -= OnTunnelUpdated; BroadcastStatus(false, null); return Task.CompletedTask; } - // Re-broadcast our own status whenever the shared tunnel changes so consumers - // (e.g. KoFiWebhookSettings) always see the composed public URL without having - // to know about DevTunnels or manually concatenate paths. - private void OnTunnelUpdated(IntegrationConnection connection) - { - if (connection is not { Source: SubathonEventSource.DevTunnels, Service: "Tunnel" }) return; - var token = secureStorage.Get(StorageKeys.KoFiVerificationToken); - bool enabled = !string.IsNullOrWhiteSpace(token); - BroadcastStatus(enabled, connection.Status ? connection.Name : null); - } - - private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) - { - // Compose the full public URL from the tunnel base + our own path. - // WebhookPath is the single source of truth for the path segment. - string? fullUrl = !string.IsNullOrWhiteSpace(tunnelBaseUrl) && tunnelBaseUrl != "(starting...)" - ? tunnelBaseUrl.TrimEnd('/') + WebhookPath - : null; - - // Only report as connected when both the token is configured and the tunnel - // is actually up with a public URL. A token alone means nothing without reachability. - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { - Name = fullUrl ?? "", - Status = enabled && fullUrl != null, - Source = SubathonEventSource.KoFiTunnel, - Service = nameof(SubathonEventSource.KoFiTunnel), - Configured = enabled - }); - - if (fullUrl != null) - logger?.LogInformation("[Ko-Fi] Public webhook URL: {Url}", fullUrl); - } - - public async Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, CancellationToken ct = default) - { + public async Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, + CancellationToken ct = default) { // Forward the raw request to any configured URLs before verifying; the // forwarded service (e.g. StreamerBot) needs the same unmodified payload. await ForwardRequestAsync(rawBody, headers, ct); - var token = secureStorage.Get(StorageKeys.KoFiVerificationToken); - if (string.IsNullOrWhiteSpace(token)) - { + string? token = secureStorage.Get(StorageKeys.KoFiVerificationToken); + if (string.IsNullOrWhiteSpace(token)) { logger?.LogWarning("[Ko-Fi] Received webhook but no verification token is configured"); return; } // Ko-Fi always sends application/x-www-form-urlencoded; use the inbound // Content-Type if present, fall back to the known Ko-Fi value. - var contentType = headers.TryGetValue("Content-Type", out var ct2) ? ct2 + string contentType = headers.TryGetValue("Content-Type", out string? ct2) + ? ct2 : "application/x-www-form-urlencoded"; - var request = new WebhookRequest - { + var request = new WebhookRequest { Method = "POST", Path = WebhookPath, Body = rawBody, ContentType = contentType }; - var result = await _handler.HandleAsync(request, new KoFiWebhookOptions { VerificationToken = token }, ct); + WebhookHandleResult result = + await _handler.HandleAsync(request, new KoFiWebhookOptions { VerificationToken = token }, ct); - if (!result.IsAuthenticated) - { + if (!result.IsAuthenticated) { logger?.LogWarning("[Ko-Fi] Webhook authentication failed: {Reason}", result.FailureReason); return; } - if (!result.IsKnownEvent || result.Event is null) - { + if (!result.IsKnownEvent || result.Event is null) { logger?.LogDebug("[Ko-Fi] Received unknown/unsupported Ko-Fi event type"); return; } - var ev = MapToSubathonEvent(result.Event); - if (ev != null) - { + SubathonEvent? ev = MapToSubathonEvent(result.Event); + if (ev != null) { SubathonEvents.RaiseSubathonEventCreated(ev); logger?.LogDebug("[Ko-Fi] Raised {EventType} from {User}", ev.EventType, ev.User); } } - private SubathonEvent? MapToSubathonEvent(KoFiWebhookEvent koFiEvent) - { - try - { - var sourceMode = config.GetOrderTypeMode(_configSection, + // Re-broadcast our own status whenever the shared tunnel changes so consumers + // (e.g. KoFiWebhookSettings) always see the composed public URL without having + // to know about DevTunnels or manually concatenate paths. + private void OnTunnelUpdated(IntegrationConnection connection) { + if (connection is not { Source: SubathonEventSource.DevTunnels, Service: "Tunnel" }) return; + string? token = secureStorage.Get(StorageKeys.KoFiVerificationToken); + bool enabled = !string.IsNullOrWhiteSpace(token); + BroadcastStatus(enabled, connection.Status ? connection.Name : null); + } + + private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { + // Compose the full public URL from the tunnel base + our own path. + // WebhookPath is the single source of truth for the path segment. + string? fullUrl = !string.IsNullOrWhiteSpace(tunnelBaseUrl) && tunnelBaseUrl != "(starting...)" + ? tunnelBaseUrl.TrimEnd('/') + WebhookPath + : null; + + // Only report as connected when both the token is configured and the tunnel + // is actually up with a public URL. A token alone means nothing without reachability. + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { + Name = fullUrl ?? "", + Status = enabled && fullUrl != null, + Source = SubathonEventSource.KoFiTunnel, + Service = nameof(SubathonEventSource.KoFiTunnel), + Configured = enabled + }); + + if (fullUrl != null) + logger?.LogInformation("[Ko-Fi] Public webhook URL: {Url}", fullUrl); + } + + private SubathonEvent? MapToSubathonEvent(KoFiWebhookEvent koFiEvent) { + try { + OrderTypeModes sourceMode = config.GetOrderTypeMode(_configSection, $"{SubathonEventType.KoFiShopOrder}", OrderTypeModes.Dollar); var defaultCurrency = "USD"; string username = koFiEvent.FromName ?? "Ko-Fi Supporter"; if (!koFiEvent.IsPublic) username = "Ko-Fi Supporter"; - return koFiEvent switch - { - KoFiDonationEvent d => new SubathonEvent - { + return koFiEvent switch { + KoFiDonationEvent d => new SubathonEvent { Id = Utils.TryParseGuid(d.MessageId), Source = SubathonEventSource.KoFi, EventType = SubathonEventType.KoFiDonation, User = username, - Value = d.Amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), + Value = d.Amount.ToString("F2", CultureInfo.InvariantCulture), Currency = string.IsNullOrWhiteSpace(d.Currency) ? d.Currency : defaultCurrency, - EventTimestamp = d.Timestamp.LocalDateTime, + EventTimestamp = d.Timestamp.LocalDateTime }, - KoFiSubscriptionStartedEvent s => new SubathonEvent - { + KoFiSubscriptionStartedEvent s => new SubathonEvent { Id = Utils.TryParseGuid(s.MessageId), Source = SubathonEventSource.KoFi, EventType = SubathonEventType.KoFiSub, @@ -178,10 +169,9 @@ public async Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary new SubathonEvent - { + KoFiSubscriptionRenewedEvent r => new SubathonEvent { Id = Utils.TryParseGuid(r.MessageId), Source = SubathonEventSource.KoFi, EventType = SubathonEventType.KoFiSub, @@ -189,102 +179,88 @@ public async Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary new SubathonEvent - { + KoFiShopOrderEvent shop => new SubathonEvent { Id = Utils.TryParseGuid(shop.MessageId), Source = SubathonEventSource.KoFi, EventType = SubathonEventType.KoFiShopOrder, User = username, - Value = sourceMode switch - { - OrderTypeModes.Item => $"{shop.ShopItems?.Count ?? 1}", - OrderTypeModes.Order => "New", - _ => shop.Amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) - }, - Currency = sourceMode switch - { - OrderTypeModes.Item => "items", - OrderTypeModes.Order => "order", - _ => string.IsNullOrWhiteSpace(shop.Currency) ? shop.Currency : defaultCurrency - }, + Value = sourceMode switch { + OrderTypeModes.Item => $"{shop.ShopItems?.Count ?? 1}", + OrderTypeModes.Order => "New", + _ => shop.Amount.ToString("F2", CultureInfo.InvariantCulture) + }, + Currency = sourceMode switch { + OrderTypeModes.Item => "items", + OrderTypeModes.Order => "order", + _ => string.IsNullOrWhiteSpace(shop.Currency) ? shop.Currency : defaultCurrency + }, Amount = shop.ShopItems?.Count ?? 1, - SecondaryValue = $"{shop.Amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}|{ + SecondaryValue = $"{shop.Amount.ToString("F2", CultureInfo.InvariantCulture)}|{ (string.IsNullOrWhiteSpace(shop.Currency) ? shop.Currency : defaultCurrency)}", - EventTimestamp = shop.Timestamp.LocalDateTime, + EventTimestamp = shop.Timestamp.LocalDateTime }, - KoFiCommissionEvent comm => new SubathonEvent - { + KoFiCommissionEvent comm => new SubathonEvent { Id = Utils.TryParseGuid(comm.MessageId), Source = SubathonEventSource.KoFi, EventType = SubathonEventType.KoFiCommissionOrder, User = username, - Value = comm.Amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), + Value = comm.Amount.ToString("F2", CultureInfo.InvariantCulture), Currency = string.IsNullOrWhiteSpace(comm.Currency) ? comm.Currency : defaultCurrency, Amount = 1, - EventTimestamp = comm.Timestamp.LocalDateTime, + EventTimestamp = comm.Timestamp.LocalDateTime }, _ => null }; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[Ko-Fi] Failed to map Ko-Fi event to SubathonEvent"); return null; } } - private async Task ForwardRequestAsync(byte[] rawBody, IReadOnlyDictionary headers, CancellationToken ct) - { + private async Task ForwardRequestAsync(byte[] rawBody, IReadOnlyDictionary headers, + CancellationToken ct) { string? forwardUrlsRaw = config.Get(_configSection, "ForwardUrls", string.Empty); if (string.IsNullOrWhiteSpace(forwardUrlsRaw)) return; - var urls = forwardUrlsRaw + string[] urls = forwardUrlsRaw .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var client = httpClientFactory.CreateClient(nameof(KoFiService)); + HttpClient client = httpClientFactory.CreateClient(nameof(KoFiService)); - foreach (var url in urls) - { - try - { + foreach (string url in urls) + try { using var content = new ByteArrayContent(rawBody); // Forward all inbound headers except hop-by-hop ones. // Content-Type is applied to the content itself; everything else goes on the request. - foreach (var (key, value) in headers) - { + foreach ((string key, string value) in headers) { if (_skipForwardHeaders.Contains(key)) continue; - if (key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) - { - if (MediaTypeHeaderValue.TryParse(value, out var mt)) + if (key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) { + if (MediaTypeHeaderValue.TryParse(value, out MediaTypeHeaderValue? mt)) content.Headers.ContentType = mt; } - else if (key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) - { + else if (key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) { // HttpClient sets this automatically from ByteArrayContent } - else - { + else { content.Headers.TryAddWithoutValidation(key, value); } } // Ensure Content-Type is set even if the inbound request omitted it - content.Headers.ContentType ??= new MediaTypeHeaderValue("application/x-www-form-urlencoded") - { + content.Headers.ContentType ??= new MediaTypeHeaderValue("application/x-www-form-urlencoded") { CharSet = Encoding.UTF8.WebName }; - var response = await client.PostAsync(url, content, ct); + HttpResponseMessage response = await client.PostAsync(url, content, ct); logger?.LogDebug("[Ko-Fi] Forwarded webhook to {Url}: {Status}", url, response.StatusCode); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[Ko-Fi] Failed to forward webhook to {Url}", url); } - } } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/MakeShipService.cs b/SubathonManager.Integration/MakeShipService.cs index 08dc17e8..4c76b532 100644 --- a/SubathonManager.Integration/MakeShipService.cs +++ b/SubathonManager.Integration/MakeShipService.cs @@ -11,63 +11,52 @@ namespace SubathonManager.Integration; -public partial class MakeShipService(ILogger? logger, IHttpClientFactory httpClientFactory, - ITimerService? timerService) : IAppService -{ +public partial class MakeShipService( + ILogger? logger, + IHttpClientFactory httpClientFactory, + ITimerService? timerService) : IAppService { // pledges and campaign sales must always end up as checked for by item count / quantity // because we poll and wrap up delta between (ignoring bootup or initial number) private const string PreProductApiBase = "https://api.preproduct.io/api/preproducts/"; private const string PreProductShopQuery = "?shop=makeship-store.myshopify.com"; private const string StorefrontBase = "https://storefront.makeship.com"; + private const string TimerKey = "makeship-poll"; + private const int InvalidAfterConsecutiveFailures = 3; + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30); private static readonly TimeSpan HttpTimeout = TimeSpan.FromSeconds(20); - - [GeneratedRegex("""<[^>]*id="preproduct-pledge"[^>]*data-id="(\d+)""", RegexOptions.IgnoreCase)] - private static partial Regex PetitionPledgeIdRegex(); - - [GeneratedRegex("""<[^>]*data-id="(\d+)"[^>]*id="preproduct-pledge""", RegexOptions.IgnoreCase)] - private static partial Regex PetitionPledgeIdAltRegex(); - - [GeneratedRegex(@"gid://shopify/Product/(\d+)", RegexOptions.IgnoreCase)] - private static partial Regex CampaignProductIdRegex(); - - private const string TimerKey = "makeship-poll"; private static readonly TimeSpan StaggerDelay = TimeSpan.FromSeconds(2); - private int _pollSweepRunning; - private readonly HashSet _syncedSinceStart = new(); private readonly Dictionary _contentFailures = new(); - private const int InvalidAfterConsecutiveFailures = 3; + private readonly HashSet _syncedSinceStart = new(); private readonly Lock _syncLock = new(); - private bool _running; private bool _lastBroadcastStatus; + private int _pollSweepRunning; + private bool _running; public bool Connected { get; private set; } - public Task StartAsync(CancellationToken ct = default) - { + public Task StartAsync(CancellationToken ct = default) { UnregisterTimers(); - lock (_syncLock) - { + lock (_syncLock) { _syncedSinceStart.Clear(); _contentFailures.Clear(); } - var trackings = MakeShipTrackingRegistry.All(); - foreach (var tracking in trackings) - { - var classified = MakeShipTrackingRegistry.ClassifyUrl(tracking.Url); + List trackings = MakeShipTrackingRegistry.All(); + foreach (MakeShipTracking tracking in trackings) { + MakeShipProductType classified = MakeShipTrackingRegistry.ClassifyUrl(tracking.Url); if (classified == MakeShipProductType.Invalid) continue; if (!string.IsNullOrEmpty(tracking.ShopifyProductId)) tracking.ProductType = classified; else if (tracking.ProductType == MakeShipProductType.Invalid) tracking.ProductType = MakeShipProductType.Unknown; } - if (trackings.Count == 0) - { + + if (trackings.Count == 0) { logger?.LogInformation("[MakeShip] No urls tracked. Integration inactive."); _running = false; - BroadcastStatus(force: true); + BroadcastStatus(true); return Task.CompletedTask; } @@ -81,90 +70,99 @@ public Task StartAsync(CancellationToken ct = default) return Task.CompletedTask; } - public Task StopAsync(CancellationToken ct = default) - { + public Task StopAsync(CancellationToken ct = default) { UnregisterTimers(); _running = false; Connected = false; - BroadcastStatus(force: true); + BroadcastStatus(true); return Task.CompletedTask; } - public async Task RestartAsync() - { + [GeneratedRegex("""<[^>]*id="preproduct-pledge"[^>]*data-id="(\d+)""", RegexOptions.IgnoreCase)] + private static partial Regex PetitionPledgeIdRegex(); + + [GeneratedRegex("""<[^>]*data-id="(\d+)"[^>]*id="preproduct-pledge""", RegexOptions.IgnoreCase)] + private static partial Regex PetitionPledgeIdAltRegex(); + + [GeneratedRegex(@"gid://shopify/Product/(\d+)", RegexOptions.IgnoreCase)] + private static partial Regex CampaignProductIdRegex(); + + public async Task RestartAsync() { await StopAsync(); await StartAsync(); } - private void UnregisterTimers() => timerService?.Unregister(TimerKey); + private void UnregisterTimers() { + timerService?.Unregister(TimerKey); + } [ExcludeFromCodeCoverage] - private Task PollTimerTick(CancellationToken ct) - { + private Task PollTimerTick(CancellationToken ct) { if (Interlocked.CompareExchange(ref _pollSweepRunning, 1, 0) != 0) return Task.CompletedTask; - _ = Task.Run(async () => - { - try { await PollAllAsync(ct); } - finally { Interlocked.Exchange(ref _pollSweepRunning, 0); } + _ = Task.Run(async () => { + try { + await PollAllAsync(ct); + } + finally { + Interlocked.Exchange(ref _pollSweepRunning, 0); + } }, ct); return Task.CompletedTask; } [ExcludeFromCodeCoverage] - private async Task PollAllAsync(CancellationToken ct) - { - bool first = true; - foreach (var tracking in MakeShipTrackingRegistry.All()) - { + private async Task PollAllAsync(CancellationToken ct) { + var first = true; + foreach (MakeShipTracking tracking in MakeShipTrackingRegistry.All()) { if (!_running || ct.IsCancellationRequested) return; if (!first) - { - try { await Task.Delay(StaggerDelay, ct); } - catch (OperationCanceledException) { return; } - } + try { + await Task.Delay(StaggerDelay, ct); + } + catch (OperationCanceledException) { + return; + } + first = false; - try - { + try { await PollTrackingAsync(tracking, ct); } - catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (OperationCanceledException) { + return; + } + catch (Exception ex) { logger?.LogWarning(ex, "[MakeShip] Failed to poll {Url}", tracking.Url); } } - var trackings = MakeShipTrackingRegistry.All(); + List trackings = MakeShipTrackingRegistry.All(); Connected = trackings.Count > 0 && trackings.Any(IsTracked); BroadcastStatus(); } - private static bool IsTracked(MakeShipTracking t) => - t.ProductType is MakeShipProductType.Petition or MakeShipProductType.Campaign - && !string.IsNullOrEmpty(t.ShopifyProductId); + private static bool IsTracked(MakeShipTracking t) { + return t.ProductType is MakeShipProductType.Petition or MakeShipProductType.Campaign + && !string.IsNullOrEmpty(t.ShopifyProductId); + } - internal async Task PollTrackingAsync(MakeShipTracking tracking, CancellationToken ct) - { + internal async Task PollTrackingAsync(MakeShipTracking tracking, CancellationToken ct) { if (tracking.ProductType == MakeShipProductType.Invalid) return; - if (string.IsNullOrEmpty(tracking.ShopifyProductId) && !await ResolveTrackingAsync(tracking, ct)) - { + if (string.IsNullOrEmpty(tracking.ShopifyProductId) && !await ResolveTrackingAsync(tracking, ct)) { MakeShipTrackingRegistry.RaiseTrackingUpdated(tracking); return; } int previousPledges = tracking.Orders; int previousSales = tracking.Sales; - bool refreshed = tracking.ProductType switch - { + bool refreshed = tracking.ProductType switch { MakeShipProductType.Petition => await RefreshPetitionAsync(tracking, ct), MakeShipProductType.Campaign => await RefreshCampaignAsync(tracking, ct), _ => false }; tracking.PollFailing = !refreshed; - if (!refreshed) - { + if (!refreshed) { logger?.LogWarning("[MakeShip] Poll failed for {Type} '{Name}' ({Url})", tracking.ProductType, tracking.Name, tracking.Url); MakeShipTrackingRegistry.RaiseTrackingUpdated(tracking); @@ -172,10 +170,11 @@ internal async Task PollTrackingAsync(MakeShipTracking tracking, CancellationTok } bool firstSyncSinceStart; - lock (_syncLock) { firstSyncSinceStart = _syncedSinceStart.Add(tracking.Id); } + lock (_syncLock) { + firstSyncSinceStart = _syncedSinceStart.Add(tracking.Id); + } - if (!firstSyncSinceStart) - { + if (!firstSyncSinceStart) { (int previous, int current) = tracking.ProductType == MakeShipProductType.Petition ? (previousPledges, tracking.Orders) : (previousSales, tracking.Sales); @@ -186,34 +185,32 @@ internal async Task PollTrackingAsync(MakeShipTracking tracking, CancellationTok MakeShipTrackingRegistry.RaiseTrackingUpdated(tracking); } - private bool RegisterContentFailure(MakeShipTracking tracking) - { + private bool RegisterContentFailure(MakeShipTracking tracking) { int failures; - lock (_syncLock) - { + lock (_syncLock) { failures = _contentFailures.GetValueOrDefault(tracking.Id) + 1; _contentFailures[tracking.Id] = failures; } - if (failures < InvalidAfterConsecutiveFailures) - { + + if (failures < InvalidAfterConsecutiveFailures) { logger?.LogInformation("[MakeShip] Content failure {Count}/{Max} for {Url}; retrying next poll", failures, InvalidAfterConsecutiveFailures, tracking.Url); return false; } + tracking.ProductType = MakeShipProductType.Invalid; return true; } - private void ClearContentFailures(Guid trackingId) - { - lock (_syncLock) { _contentFailures.Remove(trackingId); } + private void ClearContentFailures(Guid trackingId) { + lock (_syncLock) { + _contentFailures.Remove(trackingId); + } } - private async Task ResolveTrackingAsync(MakeShipTracking tracking, CancellationToken ct) - { - var type = MakeShipTrackingRegistry.ClassifyUrl(tracking.Url); - if (type == MakeShipProductType.Invalid) - { + private async Task ResolveTrackingAsync(MakeShipTracking tracking, CancellationToken ct) { + MakeShipProductType type = MakeShipTrackingRegistry.ClassifyUrl(tracking.Url); + if (type == MakeShipProductType.Invalid) { tracking.ProductType = MakeShipProductType.Invalid; logger?.LogWarning("[MakeShip] Url is not a petition or product campaign: {Url}", tracking.Url); return false; @@ -226,8 +223,7 @@ private async Task ResolveTrackingAsync(MakeShipTracking tracking, Cancell ? MatchFirst(html, PetitionPledgeIdRegex(), PetitionPledgeIdAltRegex()) : MatchFirst(html, CampaignProductIdRegex()); - if (string.IsNullOrEmpty(productId)) - { + if (string.IsNullOrEmpty(productId)) { if (RegisterContentFailure(tracking)) logger?.LogWarning("[MakeShip] Could not find a product id on page (not a trackable {Type}?): {Url}", type, tracking.Url); @@ -244,25 +240,22 @@ private async Task ResolveTrackingAsync(MakeShipTracking tracking, Cancell return true; } - private async Task RefreshPetitionAsync(MakeShipTracking tracking, CancellationToken ct) - { + private async Task RefreshPetitionAsync(MakeShipTracking tracking, CancellationToken ct) { string? json = await GetStringAsync( $"{PreProductApiBase}{tracking.ShopifyProductId}.json{PreProductShopQuery}", ct); if (json == null) return false; - try - { - using var doc = JsonDocument.Parse(json); - if (FindProperty(doc.RootElement, "name") is { ValueKind: JsonValueKind.String } nameEl) - { - var name = nameEl.GetString(); + try { + using JsonDocument doc = JsonDocument.Parse(json); + if (FindProperty(doc.RootElement, "name") is { ValueKind: JsonValueKind.String } nameEl) { + string? name = nameEl.GetString(); if (!string.IsNullOrWhiteSpace(name) && !tracking.Name.Equals(name)) tracking.Name = name; } + if (FindProperty(doc.RootElement, "sales_actual") is { } salesEl && TryGetInt(salesEl, out int sales)) tracking.Sales = sales; } - catch (JsonException ex) - { + catch (JsonException ex) { logger?.LogWarning(ex, "[MakeShip] Bad preproduct json for {Name}", tracking.Name); return false; } @@ -271,25 +264,21 @@ private async Task RefreshPetitionAsync(MakeShipTracking tracking, Cancell $"{StorefrontBase}/orders/petitions/{tracking.ShopifyProductId}/pledges/count", ct); } - private async Task RefreshCampaignAsync(MakeShipTracking tracking, CancellationToken ct) - { + private async Task RefreshCampaignAsync(MakeShipTracking tracking, CancellationToken ct) { string? json = await GetStringAsync( $"{StorefrontBase}/products/{tracking.ShopifyProductId}/sales-quantity", ct); - if (json == null) - { + if (json == null) { logger?.LogWarning("[MakeShip] No sales-quantity for product {Id} ({Url})", tracking.ShopifyProductId, tracking.Url); return false; } - try - { - using var doc = JsonDocument.Parse(json); + try { + using JsonDocument doc = JsonDocument.Parse(json); if (FindProperty(doc.RootElement, "quantity") is { } qtyEl && TryGetInt(qtyEl, out int quantity)) tracking.Sales = quantity; } - catch (JsonException ex) - { + catch (JsonException ex) { logger?.LogWarning(ex, "[MakeShip] Bad sales-quantity json for {Name}", tracking.Name); return false; } @@ -298,56 +287,50 @@ private async Task RefreshCampaignAsync(MakeShipTracking tracking, Cancell $"{StorefrontBase}/orders/petitions/{tracking.ShopifyProductId}/pledges/count", ct); } - private async Task RefreshPledgeCountAsync(MakeShipTracking tracking, string url, CancellationToken ct) - { + private async Task RefreshPledgeCountAsync(MakeShipTracking tracking, string url, CancellationToken ct) { string? body = await GetStringAsync(url, ct); - if (body == null) - { + if (body == null) { logger?.LogWarning("[MakeShip] Pledge count request failed for '{Name}' ({Url})", tracking.Name, url); return false; } string trimmed = body.Trim().Trim('"'); if (!int.TryParse(trimmed, out int count)) - { - try - { - using var doc = JsonDocument.Parse(body); - if (FindProperty(doc.RootElement, "count") is { } el && TryGetInt(el, out count)) { } - else if (FindProperty(doc.RootElement, "quantity") is { } el2 && TryGetInt(el2, out count)) { } - else - { + try { + using JsonDocument doc = JsonDocument.Parse(body); + if (FindProperty(doc.RootElement, "count") is { } el && TryGetInt(el, out count)) { + } + else if (FindProperty(doc.RootElement, "quantity") is { } el2 && TryGetInt(el2, out count)) { + } + else { logger?.LogWarning("[MakeShip] Unrecognized pledge count response for '{Name}': {Body}", tracking.Name, Truncate(body, 200)); return false; } } - catch (JsonException) - { + catch (JsonException) { logger?.LogWarning("[MakeShip] Unrecognized pledge count response for '{Name}': {Body}", tracking.Name, Truncate(body, 200)); return false; } - } tracking.Orders = count; return true; } - private static string Truncate(string value, int max) - => value.Length <= max ? value : value[..max] + "..."; + private static string Truncate(string value, int max) { + return value.Length <= max ? value : value[..max] + "..."; + } - private void RaiseNewOrderEvent(MakeShipTracking tracking, int fromCount, int toCount) - { - var eventType = tracking.ProductType == MakeShipProductType.Petition + private void RaiseNewOrderEvent(MakeShipTracking tracking, int fromCount, int toCount) { + SubathonEventType eventType = tracking.ProductType == MakeShipProductType.Petition ? SubathonEventType.MakeShipPledge : SubathonEventType.MakeShipSale; - + string unit = eventType == SubathonEventType.MakeShipPledge ? "pledges" : "sales"; int delta = toCount - fromCount; - SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent - { + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { Id = Utils.CreateGuidFromUniqueString($"makeship-{tracking.Id}-{fromCount}-{toCount}"), Source = SubathonEventSource.MakeShip, EventType = eventType, @@ -363,15 +346,13 @@ private void RaiseNewOrderEvent(MakeShipTracking tracking, int fromCount, int to delta, unit, tracking.Name, fromCount, toCount); } - public static void Simulate(string name, bool isPetition, int count) - { + public static void Simulate(string name, bool isPetition, int count) { if (string.IsNullOrWhiteSpace(name)) name = "Test Plush"; if (count <= 0) count = 1; - var eventType = isPetition ? SubathonEventType.MakeShipPledge : SubathonEventType.MakeShipSale; + SubathonEventType eventType = isPetition ? SubathonEventType.MakeShipPledge : SubathonEventType.MakeShipSale; string unit = isPetition ? "pledges" : "sales"; - SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent - { + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = eventType, EventTypeMeta = name.Trim(), @@ -384,15 +365,13 @@ public static void Simulate(string name, bool isPetition, int count) }); } - private void BroadcastStatus(bool force = false) - { - var trackings = MakeShipTrackingRegistry.All(); + private void BroadcastStatus(bool force = false) { + List trackings = MakeShipTrackingRegistry.All(); int tracked = trackings.Count(IsTracked); bool status = _running && tracked > 0; if (!force && status == _lastBroadcastStatus) return; _lastBroadcastStatus = status; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.MakeShip, Service = nameof(SubathonEventSource.MakeShip), Name = trackings.Count == 0 ? "" : $"{tracked}/{trackings.Count} tracked", @@ -401,64 +380,57 @@ private void BroadcastStatus(bool force = false) }); } - private async Task GetStringAsync(string url, CancellationToken ct) - { - using var client = httpClientFactory.CreateClient(nameof(MakeShipService)); + private async Task GetStringAsync(string url, CancellationToken ct) { + using HttpClient client = httpClientFactory.CreateClient(nameof(MakeShipService)); client.Timeout = HttpTimeout; if (!client.DefaultRequestHeaders.UserAgent.Any()) - client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) SubathonManager"); - try - { - var response = await client.GetAsync(url, ct); - if (!response.IsSuccessStatusCode) - { + client.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) SubathonManager"); + try { + HttpResponseMessage response = await client.GetAsync(url, ct); + if (!response.IsSuccessStatusCode) { logger?.LogDebug("[MakeShip] {Status} from {Url}", response.StatusCode, url); return null; } + return await response.Content.ReadAsStringAsync(ct); } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { logger?.LogDebug("[MakeShip] Timeout fetching {Url}", url); return null; } - catch (HttpRequestException ex) - { + catch (HttpRequestException ex) { logger?.LogDebug(ex, "[MakeShip] Request failed for {Url}", url); return null; } } - private static string? MatchFirst(string input, params Regex[] regexes) - { - foreach (var regex in regexes) - { - var match = regex.Match(input); + private static string? MatchFirst(string input, params Regex[] regexes) { + foreach (Regex regex in regexes) { + Match match = regex.Match(input); if (match.Success) return match.Groups[1].Value; } + return null; } - private static JsonElement? FindProperty(JsonElement element, string name, int depth = 3) - { + private static JsonElement? FindProperty(JsonElement element, string name, int depth = 3) { if (element.ValueKind != JsonValueKind.Object || depth < 0) return null; - if (element.TryGetProperty(name, out var direct)) return direct; - foreach (var child in element.EnumerateObject()) - { + if (element.TryGetProperty(name, out JsonElement direct)) return direct; + foreach (JsonProperty child in element.EnumerateObject()) { if (child.Value.ValueKind != JsonValueKind.Object) continue; if (FindProperty(child.Value, name, depth - 1) is { } found) return found; } + return null; } - private static bool TryGetInt(JsonElement element, out int value) - { + private static bool TryGetInt(JsonElement element, out int value) { value = 0; - return element.ValueKind switch - { + return element.ValueKind switch { JsonValueKind.Number => element.TryGetInt32(out value), JsonValueKind.String => int.TryParse(element.GetString(), out value), _ => false }; } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/OBSService.cs b/SubathonManager.Integration/OBSService.cs index c225fd74..eb2a049b 100644 --- a/SubathonManager.Integration/OBSService.cs +++ b/SubathonManager.Integration/OBSService.cs @@ -1,8 +1,10 @@ using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; using OBSWebsocketDotNet; using OBSWebsocketDotNet.Communication; +using OBSWebsocketDotNet.Types; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -10,6 +12,7 @@ using SubathonManager.Core.Objects; using SubathonManager.Core.Security; using SubathonManager.Core.Security.Interfaces; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; @@ -28,43 +31,32 @@ public sealed record ObsBrowserSourceCard( Guid? RouteId); [ExcludeFromCodeCoverage] -public class OBSService : IAppService -{ - private readonly ILogger? _logger; - private readonly IConfig _config; - private readonly ISecureStorage _secureStorage; - private readonly OBSWebsocket _obs = new(); - private readonly Utils.ServiceReconnectState _reconnectState = - new(TimeSpan.FromSeconds(3), maxRetries: 1000, maxBackoff: TimeSpan.FromSeconds(10), infiniteRetries: true); - - public bool Connected => _obs.IsConnected; - +public class OBSService : IAppService { private const string HelperHotkeyName = "subathonmanager_apply_tweaks"; private const string HelperVersionHotkeyPrefix = "subathonmanager_version_"; private const string ManagedMarkerKey = "subathon_managed"; private const string ScriptFileName = "subathonmanager.lua"; private const string ScriptResourceName = "SubathonManager.Integration.obs.subathonmanager.lua"; + private static readonly TimeSpan ScriptCheckMinInterval = TimeSpan.FromSeconds(5); - public bool HelperScriptActive { get; private set; } - - public string? HelperScriptVersion { get; private set; } + private static readonly Lazy ExpectedScriptVersionLazy = new(ReadEmbeddedScriptVersion); + private readonly IConfig _config; + private readonly ILogger? _logger; + private readonly OBSWebsocket _obs = new(); - public bool HelperScriptOutdated => - HelperScriptActive - && ExpectedHelperScriptVersion != null - && !string.Equals(HelperScriptVersion, ExpectedHelperScriptVersion, StringComparison.Ordinal); + private readonly Utils.ServiceReconnectState _reconnectState = + new(TimeSpan.FromSeconds(3), 1000, TimeSpan.FromSeconds(10), true); - private static readonly Lazy ExpectedScriptVersionLazy = new(ReadEmbeddedScriptVersion); - public static string? ExpectedHelperScriptVersion => ExpectedScriptVersionLazy.Value; + private readonly Lock _scriptCheckLock = new(); + private readonly ISecureStorage _secureStorage; + private DateTime _lastScriptCheckedAt = DateTime.MinValue; + private Task? _scriptCheckTask; - public event Action? HelperScriptStatusChanged; - - public event Action? BrowserSourcesChanged; + private int _startupRefreshDone; - public static string ScriptPath => Path.GetFullPath(Path.Combine("obs", ScriptFileName)); + private volatile bool _stopRequested; - public OBSService(ILogger? logger, IConfig config, ISecureStorage secureStorage) - { + public OBSService(ILogger? logger, IConfig config, ISecureStorage secureStorage) { _logger = logger; _config = config; _secureStorage = secureStorage; @@ -72,152 +64,230 @@ public OBSService(ILogger? logger, IConfig config, ISecureStorage se _obs.Connected += OnConnected; _obs.Disconnected += OnDisconnected; } - - public List GetScenes() - { - return _obs.GetSceneList().Scenes - .Select(s => s.Name) - .ToList(); - } - public string GetCurrentScene() - { - return _obs.GetCurrentProgramScene(); - } + public bool Connected => _obs.IsConnected; + + public bool HelperScriptActive { get; private set; } + + public string? HelperScriptVersion { get; private set; } + + public bool HelperScriptOutdated => + HelperScriptActive + && ExpectedHelperScriptVersion != null + && !string.Equals(HelperScriptVersion, ExpectedHelperScriptVersion, StringComparison.Ordinal); + + public static string? ExpectedHelperScriptVersion => ExpectedScriptVersionLazy.Value; - public Task StartAsync(CancellationToken cancellationToken = default) - { + public static string ScriptPath => Path.GetFullPath(Path.Combine("obs", ScriptFileName)); + + public Task StartAsync(CancellationToken cancellationToken = default) { EnsureScriptFileOnDisk(); _ = Task.Run(TryConnect, cancellationToken); return Task.CompletedTask; } - public Task StopAsync(CancellationToken cancellationToken = default) - { + public Task StopAsync(CancellationToken cancellationToken = default) { _stopRequested = true; _reconnectState.Cts?.Cancel(); if (_obs.IsConnected) _obs.Disconnect(); return Task.CompletedTask; } - private volatile bool _stopRequested; + public event Action? HelperScriptStatusChanged; - public void TryConnect() - { - var host = _config.Get("OBS", "Host", "localhost")!; - var port = _config.Get("OBS", "Port", "4455")!; - var password = _secureStorage.GetOrDefault(StorageKeys.OBSWebSocketPassword, string.Empty); + public event Action? BrowserSourcesChanged; + + public Dictionary> GetCanvases() { + var canvases = new Dictionary>(); + if (!_obs.IsConnected) return canvases; + + ObsVideoSettings? currentCanvas = _obs.GetVideoSettings(); + + canvases.Add("Default", new Dictionary { + { "Width", currentCanvas.BaseWidth }, + { "Height", currentCanvas.BaseHeight } + }); + + ObsVersion? v = _obs.GetVersion(); + + if (v.AvailableRequests.Contains("GetCanvasList")) + try { + JObject? response = _obs.SendRequest("GetCanvasList"); + var count = 1; + if (response != null && response.TryGetValue("canvases", out JToken? _canvases)) + foreach (JToken canvas in _canvases) { +#pragma warning disable CS8600 + // Converting null literal or possible null value to non-nullable type. + string canvasName = (string)canvas["canvasName"] ?? $"Canvas {count}"; + + JToken? videoSettings = canvas["canvasVideoSettings"]; + JToken? flags = canvas["canvasFlags"]; + if ((bool)(flags?["MAIN"] ?? false)) canvases.Remove("Default"); + canvases.Add(canvasName, new Dictionary { + { "Width", (int)(videoSettings?["baseWidth"] ?? 1920) }, + { "Height", (int)(videoSettings?["baseHeight"] ?? 1080) } + }); +#pragma warning restore CS8600 + // Converting null literal or possible null value to non-nullable type. + } + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[OBSService] GetCanvasList request failed"); + } + + if (canvases.Count == 0) + canvases.Add("Default", new Dictionary { + { "Width", currentCanvas.BaseWidth }, + { "Height", currentCanvas.BaseHeight } + }); + + return canvases; + } + + public List GetScenes() { + return _obs.GetSceneList().Scenes + .Select(s => s.Name) + .ToList(); + } + + public string GetCurrentScene() { + return _obs.GetCurrentProgramScene(); + } + + public void TryConnect() { + string host = _config.Get("OBS", "Host", "localhost")!; + string port = _config.Get("OBS", "Port", "4455")!; + string? password = _secureStorage.GetOrDefault(StorageKeys.OBSWebSocketPassword, string.Empty); if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(port)) return; _stopRequested = false; - try - { + try { var url = $"ws://{host}:{port}"; _obs.ConnectAsync(url, password); } - catch (Exception ex) - { + catch (Exception ex) { if (_reconnectState.Retries < 3) _logger?.LogWarning(ex, "[OBSService] Connection attempt failed"); else - { _logger?.LogDebug(ex, "[OBSService] Connection attempt failed"); - } } _ = Task.Run(VerifyConnectionOrRetryAsync); } - private async Task VerifyConnectionOrRetryAsync() - { + private async Task VerifyConnectionOrRetryAsync() { await Task.Delay(TimeSpan.FromSeconds(3)); if (_stopRequested || _obs.IsConnected) return; await ReconnectWithBackoffAsync(); } - private void OnConnected(object? sender, EventArgs e) - { + private void OnConnected(object? sender, EventArgs e) { _logger?.LogInformation("[OBSService] Connected"); _reconnectState.Reset(); _reconnectState.Cts?.Cancel(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.OBS, Service = "OBS", Name = "WebSocket", Status = true }); - _ = Task.Run(CheckHelperScriptAsync); + _ = Task.Run(async () => { + await CheckHelperScriptAsync(true); + await RunStartupBrowserSourceRefreshAsync(); + }); + } + + private async Task RunStartupBrowserSourceRefreshAsync() { + if (Interlocked.Exchange(ref _startupRefreshDone, 1) != 0) return; + + try { + await Task.Delay(TimeSpan.FromSeconds(5)); + if (_stopRequested || !_obs.IsConnected) { + Interlocked.Exchange(ref _startupRefreshDone, 0); + return; + } + + string serverPort = _config.Get("Server", "Port", "14040")!; + List cards = await GetOverlayBrowserSourcesAsync(serverPort); + + var refreshed = 0; + foreach (string sourceName in cards.Select(c => c.SourceName).Distinct(StringComparer.Ordinal)) { + if (!_obs.IsConnected) break; + RefreshBrowserSource(sourceName); + refreshed++; + await Task.Delay(100); + } + + if (refreshed > 0) + _logger?.LogInformation("[OBSService] Refreshed {Count} overlay browser source(s) on startup", + refreshed); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[OBSService] Startup browser source refresh failed"); + } } - private void OnDisconnected(object? sender, ObsDisconnectionInfo e) - { - if (HelperScriptActive) - { + private void OnDisconnected(object? sender, ObsDisconnectionInfo e) { + if (HelperScriptActive) { HelperScriptActive = false; HelperScriptVersion = null; HelperScriptStatusChanged?.Invoke(false); } + BroadcastHelperScriptStatus(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.OBS, Service = "OBS", Name = "WebSocket", Status = false }); - if (_reconnectState.Retries < 2) - { + if (_reconnectState.Retries < 2) { _logger?.LogWarning("[OBSService] Disconnected: {Reason}", e.DisconnectReason ?? "Not Running?"); _ = Task.Run(ReconnectWithBackoffAsync); } } - + [ExcludeFromCodeCoverage] - private async Task ReconnectWithBackoffAsync() - { + private async Task ReconnectWithBackoffAsync() { if (!await _reconnectState.Lock.WaitAsync(0)) return; - try - { + try { _reconnectState.Cts?.Cancel(); _reconnectState.Cts = new CancellationTokenSource(); - var token = _reconnectState.Cts.Token; + CancellationToken token = _reconnectState.Cts.Token; - var host = _config.Get("OBS", "Host", "")!; - var port = _config.Get("OBS", "Port", "")!; + string host = _config.Get("OBS", "Host")!; + string port = _config.Get("OBS", "Port")!; if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(port)) return; - while (!token.IsCancellationRequested && !_obs.IsConnected) - { - if (!_reconnectState.InfiniteRetries && _reconnectState.Retries >= _reconnectState.MaxRetries) - { + while (!token.IsCancellationRequested && !_obs.IsConnected) { + if (!_reconnectState.InfiniteRetries && _reconnectState.Retries >= _reconnectState.MaxRetries) { _logger?.LogError("[OBSService] Max reconnect retries reached"); return; } _reconnectState.Retries++; - var delay = _reconnectState.Backoff; + TimeSpan delay = _reconnectState.Backoff; - if (!_reconnectState.InfiniteRetries && (_reconnectState.Retries < 3 || _reconnectState.Retries % 10 == 0)) - { + if (!_reconnectState.InfiniteRetries && + (_reconnectState.Retries < 3 || _reconnectState.Retries % 10 == 0)) _logger?.LogDebug("[OBSService] Reconnect attempt {N} in {Delay}s", _reconnectState.Retries, delay.TotalSeconds); - } - try - { + try { await Task.Delay(delay, token); - if (!_obs.IsConnected) TryConnect(); + if (!_obs.IsConnected) + TryConnect(); } - catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (OperationCanceledException) { + return; + } + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] Reconnect error"); } @@ -227,15 +297,13 @@ private async Task ReconnectWithBackoffAsync() _reconnectState.MaxBackoff.TotalMilliseconds)); } } - finally - { + finally { _reconnectState.Lock.Release(); } } - public bool SaveConfig(string host, string port, string password, bool forceSave = false) - { - bool hasUpdated = false; + public bool SaveConfig(string host, string port, string password, bool forceSave = false) { + var hasUpdated = false; hasUpdated |= _config.Set("OBS", "Host", host); hasUpdated |= _config.Set("OBS", "Port", port); hasUpdated |= _secureStorage.Set(StorageKeys.OBSWebSocketPassword, password); @@ -244,36 +312,45 @@ public bool SaveConfig(string host, string port, string password, bool forceSave return hasUpdated; } - public (string host, string port, string password) GetConfig() - { + public (string host, string port, string password) GetConfig() { return ( _config.Get("OBS", "Host", "localhost")!, _config.Get("OBS", "Port", "4455")!, _secureStorage.GetOrDefault(StorageKeys.OBSWebSocketPassword, string.Empty)! ); } - - private async Task CheckHelperScriptAsync() - { + + private Task CheckHelperScriptAsync(bool force = false) { + lock (_scriptCheckLock) { + if (_scriptCheckTask is { IsCompleted: false } inFlight) return inFlight; + if (!force && DateTime.UtcNow - _lastScriptCheckedAt < ScriptCheckMinInterval) + return Task.CompletedTask; + + _scriptCheckTask = Task.Run(RunHelperScriptCheckAsync); + return _scriptCheckTask; + } + } + + private async Task RunHelperScriptCheckAsync() { EnsureScriptFileOnDisk(); await Task.Delay(250); if (!_obs.IsConnected) return; - bool active = false; + var active = false; string? version = null; - try - { - var response = _obs.SendRequest("GetHotkeyList"); + var obsStillStarting = false; + + try { + JObject? response = _obs.SendRequest("GetHotkeyList"); var hotkeys = response?["hotkeys"] as JArray; - var names = hotkeys?.Select(h => h?.ToString()?.Trim()).ToList(); + List? names = hotkeys?.Select(h => h?.ToString()?.Trim()).ToList(); active = names != null && names.Any(n => string.Equals(n, HelperHotkeyName, StringComparison.Ordinal)); version = names? .FirstOrDefault(n => n != null && n.StartsWith(HelperVersionHotkeyPrefix, StringComparison.Ordinal))? [HelperVersionHotkeyPrefix.Length..]; - if (active) - { + if (active) { if (version != null && !string.Equals(version, ExpectedHelperScriptVersion, StringComparison.Ordinal)) _logger?.LogWarning( "[OBSService] Helper script detected but outdated (v{Loaded} loaded, v{Expected} available). Reload it in OBS Tools -> Scripts", @@ -285,8 +362,7 @@ private async Task CheckHelperScriptAsync() else _logger?.LogInformation("[OBSService] Helper script detected (v{Version})", version); } - else - { + else { _logger?.LogWarning( "[OBSService] Helper script not loaded in OBS ({Count} hotkeys reported). Add it once via OBS Tools -> Scripts: {Path}", hotkeys?.Count ?? -1, ScriptPath); @@ -294,10 +370,20 @@ private async Task CheckHelperScriptAsync() hotkeys != null ? string.Join(", ", hotkeys.Select(h => h?.ToString())) : ""); } } - catch (Exception ex) - { + catch (ErrorResponseException ex) when (ex.ErrorCode == 201) { + obsStillStarting = true; + _logger?.LogDebug(ex, "[OBSService] Waiting for OBS to startup successfully before checking again"); + } + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] Helper script check failed"); } + finally { + lock (_scriptCheckLock) { + _lastScriptCheckedAt = DateTime.UtcNow; + } + } + + if (obsStillStarting) return; HelperScriptActive = active; HelperScriptVersion = active ? version : null; @@ -305,10 +391,8 @@ private async Task CheckHelperScriptAsync() BroadcastHelperScriptStatus(); } - private void BroadcastHelperScriptStatus() - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private void BroadcastHelperScriptStatus() { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.OBS, Service = "HelperScript", Name = "Helper Script", @@ -317,93 +401,75 @@ private void BroadcastHelperScriptStatus() }); } - public void RecheckHelperScript() - { + public void RecheckHelperScript() { if (!_obs.IsConnected) return; - _ = Task.Run(CheckHelperScriptAsync); + _ = CheckHelperScriptAsync(); } - private static string? ReadEmbeddedScriptContent() - { - using var stream = typeof(OBSService).Assembly.GetManifestResourceStream(ScriptResourceName); + private static string? ReadEmbeddedScriptContent() { + using Stream? stream = typeof(OBSService).Assembly.GetManifestResourceStream(ScriptResourceName); if (stream == null) return null; using var reader = new StreamReader(stream); return reader.ReadToEnd(); } - private static string? ReadEmbeddedScriptVersion() - { - var content = ReadEmbeddedScriptContent(); + private static string? ReadEmbeddedScriptVersion() { + string? content = ReadEmbeddedScriptContent(); if (content == null) return null; - var match = System.Text.RegularExpressions.Regex.Match( + Match match = Regex.Match( content, "SCRIPT_VERSION\\s*=\\s*\"([^\"]+)\""); return match.Success ? match.Groups[1].Value : null; } - private void EnsureScriptFileOnDisk() - { - try - { - var content = ReadEmbeddedScriptContent(); - if (content == null) - { + private void EnsureScriptFileOnDisk() { + try { + string? content = ReadEmbeddedScriptContent(); + if (content == null) { _logger?.LogWarning("[OBSService] Embedded helper script resource not found"); return; } - var path = ScriptPath; + string path = ScriptPath; Directory.CreateDirectory(Path.GetDirectoryName(path)!); if (!File.Exists(path) || File.ReadAllText(path) != content) File.WriteAllText(path, content); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] Failed to write helper script to disk"); } } - - public async Task TryApplyHelperTweaksAsync() - { + + public async Task TryApplyHelperTweaksAsync() { if (!_obs.IsConnected) return; - if (!HelperScriptActive) - { - await CheckHelperScriptAsync(); - } - if (!HelperScriptActive) - { + if (!HelperScriptActive) await CheckHelperScriptAsync(); + if (!HelperScriptActive) { _logger?.LogDebug("[OBSService] Skipping helper tweaks - script not loaded in OBS"); return; } - try - { - var request = new JObject - { + try { + var request = new JObject { ["hotkeyName"] = HelperHotkeyName }; _obs.SendRequest("TriggerHotkeyByName", request); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] TriggerHotkeyByName for helper script failed"); } } - - public async Task> GetOverlayBrowserSourcesAsync(string serverPort) - { + + public async Task> GetOverlayBrowserSourcesAsync(string serverPort) { var cards = new List(); if (!_obs.IsConnected) return cards; - if (HelperScriptActive) - { + if (HelperScriptActive) { await TryApplyHelperTweaksAsync(); await Task.Delay(300); } bool adoptedAny = CollectBrowserSourceCards(serverPort, cards); - if (adoptedAny && HelperScriptActive) - { + if (adoptedAny && HelperScriptActive) { await TryApplyHelperTweaksAsync(); await Task.Delay(300); cards.Clear(); @@ -413,51 +479,44 @@ public async Task> GetOverlayBrowserSourcesAsync(stri return cards; } - private bool CollectBrowserSourceCards(string serverPort, List cards) - { - bool adoptedAny = false; + private bool CollectBrowserSourceCards(string serverPort, List cards) { + var adoptedAny = false; var settingsCache = new Dictionary(); var seenItems = new HashSet(); - foreach (var sceneName in GetScenes()) - { - adoptedAny |= CollectFromScene(sceneName, sceneName, isGroup: false, serverPort, + foreach (string sceneName in GetScenes()) + adoptedAny |= CollectFromScene(sceneName, sceneName, false, serverPort, cards, settingsCache, seenItems); - } return adoptedAny; } private bool CollectFromScene(string sceneName, string scenePath, bool isGroup, string serverPort, List cards, Dictionary settingsCache, - HashSet seenItems) - { - bool adoptedAny = false; + HashSet seenItems) { + var adoptedAny = false; JArray? items; - try - { - var response = _obs.SendRequest(isGroup ? "GetGroupSceneItemList" : "GetSceneItemList", + try { + JObject? response = _obs.SendRequest(isGroup ? "GetGroupSceneItemList" : "GetSceneItemList", new JObject { ["sceneName"] = sceneName }); items = response?["sceneItems"] as JArray; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogDebug(ex, "[OBSService] Failed to list scene items for '{Scene}'", sceneName); return false; } + if (items == null) return false; - foreach (var item in items) - { - if (item?["isGroup"]?.Value() == true) - { + foreach (var item in items) { + if (item?["isGroup"]?.Value() == true) { var groupName = item["sourceName"]?.ToString(); if (!string.IsNullOrEmpty(groupName)) - adoptedAny |= CollectFromScene(groupName, $"{scenePath} / {groupName}", isGroup: true, + adoptedAny |= CollectFromScene(groupName, $"{scenePath} / {groupName}", true, serverPort, cards, settingsCache, seenItems); continue; } - var inputKind = item?["inputKind"]?.ToString() ?? ""; + string inputKind = item?["inputKind"]?.ToString() ?? ""; if (!inputKind.StartsWith("browser_source")) continue; var sourceName = item?["sourceName"]?.ToString(); @@ -465,46 +524,42 @@ private bool CollectFromScene(string sceneName, string scenePath, bool isGroup, int itemId = item?["sceneItemId"]?.Value() ?? -1; bool visible = item?["sceneItemEnabled"]?.Value() ?? true; - if (!settingsCache.TryGetValue(sourceName, out var settings)) - { - try - { - var response = _obs.SendRequest("GetInputSettings", + if (!settingsCache.TryGetValue(sourceName, out JObject? settings)) { + try { + JObject? response = _obs.SendRequest("GetInputSettings", new JObject { ["inputName"] = sourceName }); settings = response?["inputSettings"] as JObject; } - catch { settings = null; } + catch { + settings = null; + } + settingsCache[sourceName] = settings; } + if (settings == null) continue; - var url = settings["url"]?.ToString() ?? ""; + string url = settings["url"]?.ToString() ?? ""; bool managed = settings[ManagedMarkerKey]?.Value() ?? false; - var routeId = TryParseRouteId(url, serverPort, out bool urlMatchesServer); + Guid? routeId = TryParseRouteId(url, serverPort, out bool urlMatchesServer); if (!managed && !urlMatchesServer) continue; if (!managed && urlMatchesServer) - { - try - { - _obs.SendRequest("SetInputSettings", new Newtonsoft.Json.Linq.JObject - { + try { + _obs.SendRequest("SetInputSettings", new JObject { ["inputName"] = sourceName, ["inputSettings"] = new JObject { [ManagedMarkerKey] = true }, ["overlay"] = true }); adoptedAny = true; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogDebug(ex, "[OBSService] Failed to adopt source '{Source}'", sourceName); } - } bool? srgbOff = null; - if (settings["subathon_blend_states"] is JObject states) - { + if (settings["subathon_blend_states"] is JObject states) { var state = states[$"{sceneName}|{itemId}"]?.ToString(); if (state == "srgb_off") srgbOff = true; else if (state == "default") srgbOff = false; @@ -522,105 +577,84 @@ private bool CollectFromScene(string sceneName, string scenePath, bool isGroup, return adoptedAny; } - private static Guid? TryParseRouteId(string url, string serverPort, out bool urlMatchesServer) - { + private static Guid? TryParseRouteId(string url, string serverPort, out bool urlMatchesServer) { urlMatchesServer = false; - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return null; + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri)) return null; if (uri.Port.ToString() != serverPort) return null; - var path = uri.AbsolutePath; + string path = uri.AbsolutePath; const string prefix = "/route/"; if (!path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null; urlMatchesServer = true; - var idPart = path[prefix.Length..].TrimEnd('/'); - return Guid.TryParse(idPart, out var id) ? id : null; + string idPart = path[prefix.Length..].TrimEnd('/'); + return Guid.TryParse(idPart, out Guid id) ? id : null; } - public void SetSceneItemVisible(string sceneName, int sceneItemId, bool visible) - { + public void SetSceneItemVisible(string sceneName, int sceneItemId, bool visible) { if (!_obs.IsConnected) return; - try - { - _obs.SendRequest("SetSceneItemEnabled", new Newtonsoft.Json.Linq.JObject - { + try { + _obs.SendRequest("SetSceneItemEnabled", new JObject { ["sceneName"] = sceneName, ["sceneItemId"] = sceneItemId, ["sceneItemEnabled"] = visible }); } - catch (Exception ex) - { - _logger?.LogWarning(ex, "[OBSService] SetSceneItemEnabled failed for '{Scene}'/{Id}", sceneName, sceneItemId); + catch (Exception ex) { + _logger?.LogWarning(ex, "[OBSService] SetSceneItemEnabled failed for '{Scene}'/{Id}", sceneName, + sceneItemId); } } - public void RefreshBrowserSource(string sourceName) - { + public void RefreshBrowserSource(string sourceName) { if (!_obs.IsConnected) return; - try - { - _obs.SendRequest("PressInputPropertiesButton", new JObject - { + try { + _obs.SendRequest("PressInputPropertiesButton", new JObject { ["inputName"] = sourceName, ["propertyName"] = "refreshnocache" }); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] RefreshBrowserSource failed for '{Source}'", sourceName); } } - public void RemoveBrowserSource(string sourceName) - { + public void RemoveBrowserSource(string sourceName) { if (!_obs.IsConnected) return; - try - { + try { _obs.SendRequest("RemoveInput", new JObject { ["inputName"] = sourceName }); BrowserSourcesChanged?.Invoke(); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] RemoveInput failed for '{Source}'", sourceName); } } - public void SetBrowserSourceSize(string sourceName, int width, int height) - { + public void SetBrowserSourceSize(string sourceName, int width, int height) { if (!_obs.IsConnected) return; - try - { - _obs.SendRequest("SetInputSettings", new JObject - { + try { + _obs.SendRequest("SetInputSettings", new JObject { ["inputName"] = sourceName, - ["inputSettings"] = new JObject - { + ["inputSettings"] = new JObject { ["width"] = width, ["height"] = height }, ["overlay"] = true }); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] SetBrowserSourceSize failed for '{Source}'", sourceName); } } - public async Task RequestBlendMethodAsync(string sourceName, string sceneName, int sceneItemId, bool srgbOff) - { + public async Task RequestBlendMethodAsync(string sourceName, string sceneName, int sceneItemId, bool srgbOff) { if (!_obs.IsConnected) return; - try - { - _obs.SendRequest("SetInputSettings", new JObject - { + try { + _obs.SendRequest("SetInputSettings", new JObject { ["inputName"] = sourceName, - ["inputSettings"] = new JObject - { + ["inputSettings"] = new JObject { [ManagedMarkerKey] = true, - ["subathon_blend_request"] = new JObject - { + ["subathon_blend_request"] = new JObject { ["method"] = srgbOff ? "srgb_off" : "default", ["scene"] = sceneName, ["item"] = sceneItemId @@ -630,27 +664,24 @@ public async Task RequestBlendMethodAsync(string sourceName, string sceneName, i }); await TryApplyHelperTweaksAsync(); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] RequestBlendMethod failed for '{Source}'", sourceName); } } public async Task AddBrowserSource( string sourceName, string url, int width, int height, - string sceneName, bool fitToScreen = false) - { + string sceneName, bool fitToScreen = false) { if (!_obs.IsConnected) throw new InvalidOperationException("OBS is not connected"); - var existingInputs = _obs.GetInputList(); + List? existingInputs = _obs.GetInputList(); string finalName = sourceName; - int count = 1; + var count = 1; while (existingInputs.Any(i => i.InputName == finalName)) finalName = $"{sourceName} ({count++})"; - var settings = new JObject - { + var settings = new JObject { ["url"] = url, ["width"] = width, ["height"] = height, @@ -666,21 +697,16 @@ public async Task AddBrowserSource( _obs.CreateInput(sceneName, finalName, "browser_source", settings, true); await Task.Delay(500); if (fitToScreen) - { - try - { - var sceneItems = _obs.GetSceneItemList(sceneName); - var item = sceneItems.FirstOrDefault(si => si.SourceName == finalName); - if (item != null) - { - var videoSettings = _obs.GetVideoSettings(); - - var transformRequest = new JObject - { + try { + List? sceneItems = _obs.GetSceneItemList(sceneName); + SceneItemDetails? item = sceneItems.FirstOrDefault(si => si.SourceName == finalName); + if (item != null) { + ObsVideoSettings? videoSettings = _obs.GetVideoSettings(); + + var transformRequest = new JObject { ["sceneName"] = sceneName, ["sceneItemId"] = item.ItemId, - ["sceneItemTransform"] = new JObject - { + ["sceneItemTransform"] = new JObject { ["boundsType"] = "OBS_BOUNDS_SCALE_INNER", ["boundsWidth"] = (double)videoSettings.BaseWidth, ["boundsHeight"] = (double)videoSettings.BaseHeight, @@ -692,11 +718,9 @@ public async Task AddBrowserSource( _obs.SendRequest("SetSceneItemTransform", transformRequest); } } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[OBS] SetSceneItemTransform failed"); } - } await TryApplyHelperTweaksAsync(); BrowserSourcesChanged?.Invoke(); diff --git a/SubathonManager.Integration/PallyService.cs b/SubathonManager.Integration/PallyService.cs index 53d93bc8..258657ac 100644 --- a/SubathonManager.Integration/PallyService.cs +++ b/SubathonManager.Integration/PallyService.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Net.WebSockets; using System.Security.Cryptography; using System.Text; @@ -15,39 +16,35 @@ namespace SubathonManager.Integration; -public class PallyService : IAppService, IDisposable -{ +public class PallyService : IAppService, IDisposable { private const string ConfigSection = "PallyGG"; private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(60); - - internal string BaseUrl = "wss://events.pally.gg"; + private readonly IConfig _config; private readonly ILogger? _logger; - private readonly IConfig _config; private readonly ISecureStorage _secureStorage; - - private ClientWebSocket? _socket; private CancellationTokenSource? _cts; private Task? _runTask; - public bool Connected { get; private set; } + private ClientWebSocket? _socket; - private string? ApiKey => _secureStorage.GetOrDefault(StorageKeys.PallyApiKey, string.Empty); - private string Room => (_config.Get(ConfigSection, "Room", string.Empty) ?? string.Empty).Trim(); - private bool Enabled => _config.GetBool(ConfigSection, "Enabled", false); + internal string BaseUrl = "wss://events.pally.gg"; - public PallyService(ILogger? logger, IConfig config, ISecureStorage secureStorage) - { + public PallyService(ILogger? logger, IConfig config, ISecureStorage secureStorage) { _logger = logger; _config = config; _secureStorage = secureStorage; } - public Task StartAsync(CancellationToken ct = default) - { + public bool Connected { get; private set; } + + private string? ApiKey => _secureStorage.GetOrDefault(StorageKeys.PallyApiKey, string.Empty); + private string Room => (_config.Get(ConfigSection, "Room", string.Empty) ?? string.Empty).Trim(); + private bool Enabled => _config.GetBool(ConfigSection, "Enabled"); + + public Task StartAsync(CancellationToken ct = default) { BroadcastStatus(false); - if (!Enabled || string.IsNullOrWhiteSpace(ApiKey)) - { + if (!Enabled || string.IsNullOrWhiteSpace(ApiKey)) { _logger?.LogInformation("[PallyGG] Not configured or disabled. Integration inactive."); return Task.CompletedTask; } @@ -57,17 +54,16 @@ public Task StartAsync(CancellationToken ct = default) return Task.CompletedTask; } - public async Task StopAsync(CancellationToken ct = default) - { - try - { + public async Task StopAsync(CancellationToken ct = default) { + try { if (_cts != null) await _cts.CancelAsync(); if (_socket is { State: WebSocketState.Open }) await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Stopping", CancellationToken.None); } - catch { /**/ } - finally - { + catch { + /**/ + } + finally { _socket?.Dispose(); _socket = null; Connected = false; @@ -76,24 +72,30 @@ public async Task StopAsync(CancellationToken ct = default) } [ExcludeFromCodeCoverage] - public async Task RestartAsync() - { + public void Dispose() { + _cts?.Cancel(); + _socket?.Dispose(); + GC.SuppressFinalize(this); + } + + [ExcludeFromCodeCoverage] + public async Task RestartAsync() { await StopAsync(); await StartAsync(); } - public bool IsKeyEmpty() => string.IsNullOrWhiteSpace(ApiKey); + public bool IsKeyEmpty() { + return string.IsNullOrWhiteSpace(ApiKey); + } [ExcludeFromCodeCoverage] - public bool SaveConfig(string apiKey, string room) - { + public bool SaveConfig(string apiKey, string room) { bool updated = _secureStorage.Set(StorageKeys.PallyApiKey, apiKey.Trim()); updated |= _config.Set(ConfigSection, "Room", room.Trim()); return updated; } - internal Uri BuildUri() - { + internal Uri BuildUri() { string room = Room; string channel = string.IsNullOrWhiteSpace(room) ? "channel=firehose" @@ -102,13 +104,10 @@ internal Uri BuildUri() } [ExcludeFromCodeCoverage] - private async Task RunAsync(CancellationToken token) - { + private async Task RunAsync(CancellationToken token) { var reconnect = new Utils.ServiceReconnectState(TimeSpan.FromSeconds(2), 100, TimeSpan.FromMinutes(5)); - while (!token.IsCancellationRequested) - { - try - { + while (!token.IsCancellationRequested) { + try { _socket?.Dispose(); _socket = new ClientWebSocket(); await _socket.ConnectAsync(BuildUri(), token); @@ -121,17 +120,14 @@ private async Task RunAsync(CancellationToken token) await ListenAsync(_socket, token); } - catch (OperationCanceledException) - { + catch (OperationCanceledException) { break; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning("[PallyGG] Connection error: {Message}", ex.Message); } - if (Connected) - { + if (Connected) { Connected = false; BroadcastStatus(false); } @@ -139,8 +135,7 @@ private async Task RunAsync(CancellationToken token) if (token.IsCancellationRequested) break; reconnect.Retries++; - if (reconnect.Retries >= reconnect.MaxRetries) - { + if (reconnect.Retries >= reconnect.MaxRetries) { _logger?.LogError("[PallyGG] Max reconnect attempts reached. Please reconnect manually."); ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.PallyGG), "PallyGG.gg reconnect failed after maximum retries.", DateTime.Now); @@ -148,8 +143,13 @@ private async Task RunAsync(CancellationToken token) } _logger?.LogDebug("[PallyGG] Reconnecting in {Delay}s", reconnect.Backoff.TotalSeconds); - try { await Task.Delay(reconnect.Backoff, token); } - catch (OperationCanceledException) { break; } + try { + await Task.Delay(reconnect.Backoff, token); + } + catch (OperationCanceledException) { + break; + } + reconnect.Backoff = TimeSpan.FromMilliseconds(Math.Min( reconnect.Backoff.TotalMilliseconds * 2, reconnect.MaxBackoff.TotalMilliseconds)); } @@ -159,121 +159,111 @@ private async Task RunAsync(CancellationToken token) } [ExcludeFromCodeCoverage] - private async Task ListenAsync(ClientWebSocket socket, CancellationToken token) - { + private async Task ListenAsync(ClientWebSocket socket, CancellationToken token) { using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(token); - var pingTask = Task.Run(async () => - { + Task pingTask = Task.Run(async () => { // keepalive - while (!pingCts.Token.IsCancellationRequested && socket.State == WebSocketState.Open) - { + while (!pingCts.Token.IsCancellationRequested && socket.State == WebSocketState.Open) { await Task.Delay(PingInterval, pingCts.Token); if (socket.State != WebSocketState.Open) break; - var ping = Encoding.UTF8.GetBytes("ping"); + byte[] ping = Encoding.UTF8.GetBytes("ping"); await socket.SendAsync(ping, WebSocketMessageType.Text, true, pingCts.Token); } }, pingCts.Token); var buffer = new byte[16 * 1024]; var messageBuilder = new StringBuilder(); - try - { - while (socket.State == WebSocketState.Open && !token.IsCancellationRequested) - { - var result = await socket.ReceiveAsync(buffer, token); - if (result.MessageType == WebSocketMessageType.Close) - { - await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Server closed", CancellationToken.None); + try { + while (socket.State == WebSocketState.Open && !token.IsCancellationRequested) { + WebSocketReceiveResult result = await socket.ReceiveAsync(buffer, token); + if (result.MessageType == WebSocketMessageType.Close) { + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Server closed", + CancellationToken.None); break; } messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); if (!result.EndOfMessage) continue; - string message = messageBuilder.ToString(); + var message = messageBuilder.ToString(); messageBuilder.Clear(); ProcessMessage(message); } } - finally - { + finally { await pingCts.CancelAsync(); - try { await pingTask; } catch { /**/ } + try { + await pingTask; + } + catch { + /**/ + } } } - public bool ProcessMessage(string message, bool simulated = false) - { + public bool ProcessMessage(string message, bool simulated = false) { if (string.IsNullOrWhiteSpace(message) || message == "pong") return false; - try - { - using var json = JsonDocument.Parse(message); - var root = json.RootElement; - if (!root.TryGetProperty("type", out var typeElem) + try { + using JsonDocument json = JsonDocument.Parse(message); + JsonElement root = json.RootElement; + if (!root.TryGetProperty("type", out JsonElement typeElem) || typeElem.GetString() != "campaigntip.notify") return false; - if (!root.TryGetProperty("payload", out var payload) - || !payload.TryGetProperty("campaignTip", out var tip)) return false; + if (!root.TryGetProperty("payload", out JsonElement payload) + || !payload.TryGetProperty("campaignTip", out JsonElement tip)) return false; - if (!tip.TryGetProperty("grossAmountInCents", out var grossElem) + if (!tip.TryGetProperty("grossAmountInCents", out JsonElement grossElem) || grossElem.ValueKind != JsonValueKind.Number) return false; double amount = grossElem.GetInt64() / 100.0; if (amount <= 0) return false; - string user = tip.TryGetProperty("displayName", out var nameElem) + string user = tip.TryGetProperty("displayName", out JsonElement nameElem) && !string.IsNullOrWhiteSpace(nameElem.GetString()) ? nameElem.GetString()! : "Anonymous"; - var subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { User = simulated ? "SYSTEM" : user, Currency = "USD", // USD only - Value = amount.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture), + Value = amount.ToString("0.00", CultureInfo.InvariantCulture), Source = simulated ? SubathonEventSource.Simulated : SubathonEventSource.PallyGG, EventType = SubathonEventType.PallyGGDonation }; - if (payload.TryGetProperty("page", out var page) - && page.TryGetProperty("slug", out var slugElem)) + if (payload.TryGetProperty("page", out JsonElement page) + && page.TryGetProperty("slug", out JsonElement slugElem)) subathonEvent.EventTypeMeta = slugElem.GetString(); - if (!simulated && tip.TryGetProperty("id", out var idElem) + if (!simulated && tip.TryGetProperty("id", out JsonElement idElem) && !string.IsNullOrWhiteSpace(idElem.GetString())) subathonEvent.Id = GuidFromString($"pallygg|{idElem.GetString()}"); SubathonEvents.RaiseSubathonEventCreated(subathonEvent); return true; } - catch (JsonException) - { + catch (JsonException) { _logger?.LogDebug("[PallyGG] Ignored non-json message"); return false; } } - internal static Guid GuidFromString(string input) - { - var hash = MD5.HashData(Encoding.UTF8.GetBytes(input)); + internal static Guid GuidFromString(string input) { + byte[] hash = MD5.HashData(Encoding.UTF8.GetBytes(input)); return new Guid(hash); } - public static void SimulateTip(string value = "10.00") - { - if (!double.TryParse(value, out var val) || val <= 0) return; - SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent - { + public static void SimulateTip(string value = "10.00") { + if (!double.TryParse(value, out double val) || val <= 0) return; + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { User = "SYSTEM", Currency = "USD", - Value = val.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture), + Value = val.ToString("0.00", CultureInfo.InvariantCulture), Source = SubathonEventSource.Simulated, EventType = SubathonEventType.PallyGGDonation }); } - private void BroadcastStatus(bool status) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private void BroadcastStatus(bool status) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.PallyGG, Service = "Socket", Name = Room, @@ -281,12 +271,4 @@ private void BroadcastStatus(bool status) Configured = Enabled && !string.IsNullOrWhiteSpace(ApiKey) }); } - - [ExcludeFromCodeCoverage] - public void Dispose() - { - _cts?.Cancel(); - _socket?.Dispose(); - GC.SuppressFinalize(this); - } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/PicartoService.cs b/SubathonManager.Integration/PicartoService.cs index 067bb6ea..4959b4f9 100644 --- a/SubathonManager.Integration/PicartoService.cs +++ b/SubathonManager.Integration/PicartoService.cs @@ -1,80 +1,95 @@ -using PicartoEventsLib.Clients; -using PicartoEventsLib.Internal; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; -using PicartoEventsLib.Options; using PicartoEventsLib.Abstractions.Models; -using SubathonManager.Core.Models; +using PicartoEventsLib.Clients; +using PicartoEventsLib.Internal; +using PicartoEventsLib.Options; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; -using SubathonManager.Services; -using System.Diagnostics.CodeAnalysis; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; using SubathonManager.Core.Objects; +using SubathonManager.Services; namespace SubathonManager.Integration; -public class PicartoService : IDisposable, IAppService -{ - internal PicartoEventsClient? _eventClient; - internal PicartoChatClient? _chatClient; - - internal bool _disposed = false; - private string? _picartoUsername; - public bool Running; +public class PicartoService : IDisposable, IAppService { + private readonly ILogger? _chatLogger; private readonly Utils.ServiceReconnectState _chatReconnect = new(); - private readonly Utils.ServiceReconnectState _eventsReconnect = new(); - - private bool _eventsConnected; - private bool _chatConnected; - - private readonly ILogger? _logger; private readonly ILogger? _clientLogger; - private readonly ILogger? _chatLogger; private readonly IConfig _config; - + private readonly Utils.ServiceReconnectState _eventsReconnect = new(); + + private readonly ILogger? _logger; + internal PicartoChatClient? _chatClient; + private bool _chatConnected; + + internal bool _disposed; + internal PicartoEventsClient? _eventClient; + + private bool _eventsConnected; + private string? _picartoUsername; + internal PicartoClientOptions Opts = new(); - + public bool Running; + public PicartoService(ILogger? logger, IConfig config, ILogger? clientLogger, - ILogger? chatLogger) - { + ILogger? chatLogger) { _logger = logger; _config = config; _clientLogger = clientLogger; _chatLogger = chatLogger; } - public async Task StartAsync(CancellationToken ct = default) - { + public async Task StartAsync(CancellationToken ct = default) { await StartupServiceAsync(ct); } - - public Task StopAsync(CancellationToken ct = default) - { + + public Task StopAsync(CancellationToken ct = default) { Running = false; Dispose(); return Task.CompletedTask; } - - public async Task StartupServiceAsync(CancellationToken ct = default) - { + + public void Dispose() { + if (_disposed) return; + + _chatReconnect.Dispose(); + _eventsReconnect.Dispose(); + if (_eventClient != null) { + _eventClient.Disconnected -= OnDisconnect; + _eventClient.Connected -= OnConnect; + _eventClient.AlertReceived -= OnAlert; + _ = Task.Run(_eventClient.DisconnectAsync); + } + + if (_chatClient != null) { + _chatClient.Disconnected -= OnDisconnect; + _chatClient.Connected -= OnConnect; + _chatClient.MessageReceived -= OnChatMessage; + _ = Task.Run(_chatClient.DisconnectAsync); + } + + _disposed = true; + } + + public async Task StartupServiceAsync(CancellationToken ct = default) { Running = false; Opts.Channel = string.Empty; - + _picartoUsername = _config.Get("Picarto", "Username", string.Empty)!; bool configured = !string.IsNullOrWhiteSpace(_picartoUsername); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Picarto, Service = "Chat", Name = _picartoUsername, Status = false, Configured = configured }); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Picarto, Service = "Alerts", Name = _picartoUsername, @@ -105,118 +120,103 @@ public async Task StartupServiceAsync(CancellationToken ct = default) } [ExcludeFromCodeCoverage] - private void OnAlert(object? sender, PicartoAlert alert) - { + private void OnAlert(object? sender, PicartoAlert alert) { ProcessAlert(alert); } - public static void ProcessAlert(PicartoAlert alert) - { - SubathonEvent ev = new SubathonEvent - { + public static void ProcessAlert(PicartoAlert alert) { + var ev = new SubathonEvent { Source = alert.Username == "SYSTEM" ? SubathonEventSource.Simulated : SubathonEventSource.Picarto, EventTimestamp = alert.Timestamp.ToLocalTime(), User = alert.Username }; - if (alert is PicartoFollow follow) - { + if (alert is PicartoFollow follow) { ev.EventType = SubathonEventType.PicartoFollow; } - else if (alert is PicartoSubscription sub) - { + else if (alert is PicartoSubscription sub) { ev.EventType = sub.IsGift ? SubathonEventType.PicartoGiftSub : SubathonEventType.PicartoSub; ev.Currency = "sub"; ev.Value = $"T{sub.Tier}"; ev.EventTypeMeta = $"T{sub.Tier}"; ev.Amount = sub.GetMonths() * sub.Quantity; } - else if (alert is PicartoTip tip) - { + else if (alert is PicartoTip tip) { ev.Currency = "kudos"; ev.Value = $"{tip.Amount}"; ev.EventType = SubathonEventType.PicartoTip; } - else + else { return; + } + SubathonEvents.RaiseSubathonEventCreated(ev); } [ExcludeFromCodeCoverage] - private void OnChatMessage(object? sender, PicartoChatMessage chatMessage) - { + private void OnChatMessage(object? sender, PicartoChatMessage chatMessage) { ProcessChatMessage(chatMessage); } - internal static void ProcessChatMessage(PicartoChatMessage chatMessage) - { + internal static void ProcessChatMessage(PicartoChatMessage chatMessage) { if (string.IsNullOrWhiteSpace(chatMessage.Message) || !chatMessage.Message.StartsWith('!')) return; CommandService.ChatCommandRequest(SubathonEventSource.Picarto, chatMessage.Message, chatMessage.User.Username, chatMessage.User.IsBroadcaster, - false, false, + false, false, chatMessage.Timestamp.ToLocalTime(), chatMessage.MsgId); } - - public async Task UpdateChannel() - { + + public async Task UpdateChannel() { // call for connect/reconnect button - if (_eventClient == null && _chatClient == null) - { + if (_eventClient == null && _chatClient == null) { await StartupServiceAsync(); return; } - - var picartoUsername = _config.Get("Picarto", "Username")!; + + string picartoUsername = _config.Get("Picarto", "Username")!; // if (string.Equals(picartoUsername, _picartoUsername)) // return; - + _picartoUsername = picartoUsername; - if (string.IsNullOrWhiteSpace(_picartoUsername)) - { + if (string.IsNullOrWhiteSpace(_picartoUsername)) { if (_eventClient != null) await _eventClient.DisconnectAsync(); if (_chatClient != null) await _chatClient.DisconnectAsync(); return; } - + if (_eventClient != null) await _eventClient.ChangeChannel(_picartoUsername); if (_chatClient != null) await _chatClient.ChangeChannel(_picartoUsername); } - private void OnConnect(object? sender, PicartoWebSocketConnectedEventArgs args) - { - if (sender is PicartoChatClient) - { + private void OnConnect(object? sender, PicartoWebSocketConnectedEventArgs args) { + if (sender is PicartoChatClient) { _chatConnected = true; _chatReconnect.Reset(); _chatReconnect.Cts?.Cancel(); - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Picarto, Service = "Chat", Name = _picartoUsername ?? "", Status = _chatConnected }); - } - else if (sender is PicartoEventsClient) - { + else if (sender is PicartoEventsClient) { _eventsConnected = true; _eventsReconnect.Reset(); _eventsReconnect.Cts?.Cancel(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Picarto, Service = "Alerts", Name = _picartoUsername ?? "", - Status = _eventsConnected + Status = _eventsConnected }); } } @@ -225,37 +225,31 @@ private void OnConnect(object? sender, PicartoWebSocketConnectedEventArgs args) private async Task ReconnectWithBackoffAsync( PicartoWebSocketConnection client, Func isConnected, - Utils.ServiceReconnectState state) - { + Utils.ServiceReconnectState state) { if (!await state.Lock.WaitAsync(0)) return; - - try - { + + try { _logger?.LogInformation("Attempting to reconnect {Client}", client.GetType().Name); state.Cts?.Cancel(); state.Cts = new CancellationTokenSource(); - var token = state.Cts.Token; + CancellationToken token = state.Cts.Token; - while (!isConnected() && !token.IsCancellationRequested && !string.IsNullOrWhiteSpace(_picartoUsername)) - { - var delay = state.Backoff; + while (!isConnected() && !token.IsCancellationRequested && !string.IsNullOrWhiteSpace(_picartoUsername)) { + TimeSpan delay = state.Backoff; _logger?.LogDebug( "{Client} reconnect attempt in {Delay}s", client.GetType().Name, delay); - try - { + try { await Task.Delay(delay, token); await client.ReconnectAsync(); } - catch (OperationCanceledException) - { + catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, $"{client.GetType().Name} reconnection failed"); } @@ -264,10 +258,9 @@ private async Task ReconnectWithBackoffAsync( state.Backoff.TotalMilliseconds * 2, state.MaxBackoff.TotalMilliseconds)); state.Retries += 1; - if (state.Retries >= state.MaxRetries) - { + if (state.Retries >= state.MaxRetries) { _logger?.LogError("Max retries exceeded for Picarto connection. Please reconnect"); - + ErrorMessageEvents.RaiseErrorEvent( "ERROR", nameof(SubathonEventSource.Picarto), @@ -278,31 +271,27 @@ private async Task ReconnectWithBackoffAsync( } } } - finally - { + finally { state.Lock.Release(); } } - private void OnDisconnect(object? sender, PicartoWebSocketDisconnectedEventArgs args) - { + private void OnDisconnect(object? sender, PicartoWebSocketDisconnectedEventArgs args) { _logger?.LogWarning( "Picarto disconnected: {Sender} {Status} - {Description}", sender?.GetType().Name, args.CloseStatus, args.CloseDescription); - - var description = args.CloseDescription ?? string.Empty; + + string description = args.CloseDescription ?? string.Empty; bool shouldReconnect = args.Exception != null || (description != "Disconnect" && description != "Closing"); - if (sender is PicartoChatClient) - { + if (sender is PicartoChatClient) { _chatConnected = false; - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Picarto, Service = "Chat", Name = _picartoUsername ?? "", @@ -310,19 +299,15 @@ private void OnDisconnect(object? sender, PicartoWebSocketDisconnectedEventArgs }); if (shouldReconnect && sender is PicartoWebSocketConnection ws) - { _ = Task.Run(() => ReconnectWithBackoffAsync( ws, () => _chatConnected, _chatReconnect)); - } } - else if (sender is PicartoEventsClient) - { + else if (sender is PicartoEventsClient) { _eventsConnected = false; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Picarto, Service = "Alerts", Name = _picartoUsername ?? "", @@ -330,38 +315,11 @@ private void OnDisconnect(object? sender, PicartoWebSocketDisconnectedEventArgs }); if (shouldReconnect && sender is PicartoWebSocketConnection ws) - { _ = Task.Run(() => ReconnectWithBackoffAsync( ws, () => _eventsConnected, _eventsReconnect)); - } } } - - public void Dispose() - { - if (_disposed) return; - - _chatReconnect.Dispose(); - _eventsReconnect.Dispose(); - if (_eventClient != null) - { - _eventClient.Disconnected -= OnDisconnect; - _eventClient.Connected -= OnConnect; - _eventClient.AlertReceived -= OnAlert; - _ = Task.Run(_eventClient.DisconnectAsync); - } - - if (_chatClient != null) - { - _chatClient.Disconnected -= OnDisconnect; - _chatClient.Connected -= OnConnect; - _chatClient.MessageReceived -= OnChatMessage; - _ = Task.Run(_chatClient.DisconnectAsync); - } - - _disposed = true; - } } \ No newline at end of file diff --git a/SubathonManager.Integration/StreamElementsService.cs b/SubathonManager.Integration/StreamElementsService.cs index 9dc9b7d9..47e4b63a 100644 --- a/SubathonManager.Integration/StreamElementsService.cs +++ b/SubathonManager.Integration/StreamElementsService.cs @@ -1,8 +1,8 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; using StreamElements.WebSocket; -using StreamElements.WebSocket.Models.Tip; using StreamElements.WebSocket.Models.Internal; +using StreamElements.WebSocket.Models.Tip; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -15,28 +15,25 @@ namespace SubathonManager.Integration; public class StreamElementsService(ILogger? logger, ISecureStorage secureStorage) - : IAppService -{ + : IAppService { + private readonly Utils.ServiceReconnectState _reconnectState = + new(TimeSpan.FromSeconds(2), 50, TimeSpan.FromMinutes(5)); - private Func ClientFactory { get; set; } - = () => new StreamElementsClient(); - private StreamElementsClient? _client; - public bool Connected { get; private set; } = false; + private bool _hasAuthError; + + private Func ClientFactory { get; } + = () => new StreamElementsClient(); + + public bool Connected { get; private set; } private string? JwtToken => secureStorage.GetOrDefault(StorageKeys.StreamElementsJwt, string.Empty); - private bool _hasAuthError = false; - private readonly Utils.ServiceReconnectState _reconnectState = - new(TimeSpan.FromSeconds(2), maxRetries: 50, maxBackoff: TimeSpan.FromMinutes(5)); - - public Task StartAsync(CancellationToken cancellationToken = default) - { - try - { + + public Task StartAsync(CancellationToken cancellationToken = default) { + try { InitClient(); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogError(ex, "StreamElements could not start"); Disconnect(); } @@ -44,26 +41,21 @@ public Task StartAsync(CancellationToken cancellationToken = default) return Task.CompletedTask; } - public Task StopAsync(CancellationToken cancellationToken = default) - { + public Task StopAsync(CancellationToken cancellationToken = default) { Disconnect(); return Task.CompletedTask; } - - public bool InitClient() - { + + public bool InitClient() { _client = ClientFactory(); _hasAuthError = false; Connected = false; if (string.IsNullOrWhiteSpace(JwtToken)) return false; - - if (_client != null) - { - Disconnect(); - } + + if (_client != null) Disconnect(); _client = new StreamElementsClient(); - + _reconnectState.Reset(); _client.OnConnected += _OnConnected; _client.OnAuthenticated += _OnAuthenticated; @@ -74,59 +66,51 @@ public bool InitClient() return true; } - public bool IsTokenEmpty() - { + public bool IsTokenEmpty() { return !secureStorage.Exists(StorageKeys.StreamElementsJwt) || string.IsNullOrWhiteSpace(JwtToken); } - public bool SetJwtToken(string token) - { + public bool SetJwtToken(string token) { return secureStorage.Set(StorageKeys.StreamElementsJwt, token); } - - private void _OnConnected(object? sender, EventArgs e) - { + + private void _OnConnected(object? sender, EventArgs e) { logger?.LogInformation("[StreamElementsService] Connected"); _reconnectState.Reset(); _reconnectState.Cts?.Cancel(); } - private void _OnDisconnected(object? sender, EventArgs e) - { + + private void _OnDisconnected(object? sender, EventArgs e) { logger?.LogWarning("[StreamElementsService] Disconnected"); Connected = false; - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.StreamElements, Service = "Socket", Name = "User", Status = Connected }); if (_hasAuthError) return; - + _ = Task.Run(ReconnectWithBackoffAsync); } [ExcludeFromCodeCoverage] - private async Task ReconnectWithBackoffAsync() - { + private async Task ReconnectWithBackoffAsync() { if (_client == null) return; if (!await _reconnectState.Lock.WaitAsync(0)) return; // already reconnecting - try - { + try { _reconnectState.Cts?.Cancel(); _reconnectState.Cts = new CancellationTokenSource(); - var token = _reconnectState.Cts.Token; + CancellationToken token = _reconnectState.Cts.Token; - while (!token.IsCancellationRequested && !Connected && !_hasAuthError) - { - if (_reconnectState.Retries >= _reconnectState.MaxRetries) - { - string message = + while (!token.IsCancellationRequested && !Connected && !_hasAuthError) { + if (_reconnectState.Retries >= _reconnectState.MaxRetries) { + var message = "StreamElements disconnected and max reconnect retries were reached."; ErrorMessageEvents.RaiseErrorEvent( @@ -141,7 +125,7 @@ private async Task ReconnectWithBackoffAsync() _reconnectState.Retries++; - var delay = _reconnectState.Backoff; + TimeSpan delay = _reconnectState.Backoff; logger?.LogWarning( "[StreamElementsService] Reconnect attempt {Attempt}/{Max} in {Delay}s", @@ -149,25 +133,19 @@ private async Task ReconnectWithBackoffAsync() _reconnectState.MaxRetries, delay.TotalSeconds); - try - { + try { await Task.Delay(delay, token); - if (!Connected && !_hasAuthError && _client != null) - { - _client.Connect(JwtToken); - } + if (!Connected && !_hasAuthError && _client != null) _client.Connect(JwtToken); } - catch (OperationCanceledException) - { + catch (OperationCanceledException) { return; } - catch (Exception ex) - { - string message = $"StreamElements Disconnected with an error. Could not auto-reconnect. {ex.Message}"; + catch (Exception ex) { + var message = $"StreamElements Disconnected with an error. Could not auto-reconnect. {ex.Message}"; logger?.LogWarning(ex, message); - ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.StreamElements), - message, DateTime.Now.ToLocalTime()); + ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.StreamElements), + message, DateTime.Now.ToLocalTime()); } // exponential backoff @@ -177,22 +155,19 @@ private async Task ReconnectWithBackoffAsync() _reconnectState.MaxBackoff.TotalMilliseconds)); } } - finally - { + finally { _reconnectState.Lock.Release(); } } - private void _OnAuthenticated(object? sender, Authenticated e) - { - logger?.LogDebug($"[StreamElementsService] Authenticated"); + private void _OnAuthenticated(object? sender, Authenticated e) { + logger?.LogDebug("[StreamElementsService] Authenticated"); Connected = true; _hasAuthError = false; _reconnectState.Cts?.Cancel(); - _reconnectState.Reset(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + _reconnectState.Reset(); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.StreamElements, Service = "Socket", Name = "User", @@ -200,45 +175,39 @@ private void _OnAuthenticated(object? sender, Authenticated e) }); } - private void _OnAuthenticateError(object? sender, EventArgs e) - { + private void _OnAuthenticateError(object? sender, EventArgs e) { logger?.LogError("[StreamElementsService] Authentication Error"); Connected = false; _hasAuthError = true; - _reconnectState.Cts?.Cancel(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + _reconnectState.Cts?.Cancel(); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.StreamElements, Service = "Socket", Name = "User", Status = Connected }); - ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.StreamElements), + ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.StreamElements), "StreamElements Token could not be validated", DateTime.Now.ToLocalTime()); } - private void _OnTip(object? sender, Tip e) - { - SubathonEvent subathonEvent = new() - { + private void _OnTip(object? sender, Tip e) { + SubathonEvent subathonEvent = new() { User = e.Username, Currency = e.Currency, Value = $"{e.Amount}", Source = SubathonEventSource.StreamElements, EventType = SubathonEventType.StreamElementsDonation }; - if (Guid.TryParse(e.TipId, out var tipGuid)) + if (Guid.TryParse(e.TipId, out Guid tipGuid)) subathonEvent.Id = tipGuid; - + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - public void Disconnect() - { + public void Disconnect() { _reconnectState.Cts?.Cancel(); if (_client == null) return; - try - { + try { _client.Disconnect(); _client.OnConnected -= _OnConnected; _client.OnAuthenticated -= _OnAuthenticated; @@ -247,14 +216,11 @@ public void Disconnect() _client.OnAuthenticationFailure -= _OnAuthenticateError; _client = null; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[StreamElementsService] Disconnection Error"); } - finally - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + finally { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.StreamElements, Service = "Socket", Name = "User", @@ -264,13 +230,11 @@ public void Disconnect() } } - public static void SimulateTip(string value = "10.00", string currency = "USD") - { - if (!double.TryParse(value, out var val)) + public static void SimulateTip(string value = "10.00", string currency = "USD") { + if (!double.TryParse(value, out double val)) return; - SubathonEvent subathonEvent = new() - { + SubathonEvent subathonEvent = new() { User = "SYSTEM", Currency = currency, Value = value, diff --git a/SubathonManager.Integration/StreamLabsService.cs b/SubathonManager.Integration/StreamLabsService.cs index c2c82900..13841b9e 100644 --- a/SubathonManager.Integration/StreamLabsService.cs +++ b/SubathonManager.Integration/StreamLabsService.cs @@ -1,8 +1,8 @@ -using Streamlabs.SocketClient; -using Streamlabs.SocketClient.Messages; -using Microsoft.Extensions.Options; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Streamlabs.SocketClient; +using Streamlabs.SocketClient.Messages; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -14,78 +14,68 @@ namespace SubathonManager.Integration; -public class StreamLabsService : IAppService -{ - internal Func ClientFactory; - private IStreamlabsClient? _client; - public bool Connected { get; private set; } = false; - +public class StreamLabsService : IAppService { private readonly ILogger? _logger; private readonly ISecureStorage _secureStorage; + private IStreamlabsClient? _client; internal string BaseUrl = "https://sockets.streamlabs.com"; - private string? SecretToken => _secureStorage.GetOrDefault(StorageKeys.StreamLabsSocketToken, string.Empty); + internal Func ClientFactory; - public StreamLabsService(ILogger? logger, ISecureStorage secureStorage) - { + public StreamLabsService(ILogger? logger, ISecureStorage secureStorage) { _logger = logger; _secureStorage = secureStorage; - ClientFactory = token => - { + ClientFactory = token => { var options = new OptionsWrapper( new StreamlabsOptions { Token = token, Url = BaseUrl }); return new StreamlabsClient( AppServices.Provider.GetRequiredService>(), options); }; } - - public async Task StartAsync(CancellationToken cancellationToken = default) - { + + public bool Connected { get; private set; } + private string? SecretToken => _secureStorage.GetOrDefault(StorageKeys.StreamLabsSocketToken, string.Empty); + + public async Task StartAsync(CancellationToken cancellationToken = default) { await InitClientAsync(); } - public async Task StopAsync(CancellationToken cancellationToken = default) - { + public async Task StopAsync(CancellationToken cancellationToken = default) { await DisconnectAsync(); } - + // todo cleanup all names in test for inits - public async Task InitClientAsync() - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + public async Task InitClientAsync() { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.StreamLabs, Service = "Socket", Name = "User", Status = false, Configured = !IsTokenEmpty() }); - - if (string.IsNullOrWhiteSpace(SecretToken)) - { - Connected = false; + + if (string.IsNullOrWhiteSpace(SecretToken)) { + Connected = false; return false; } if (_client != null) await DisconnectAsync(); - + _client = ClientFactory(SecretToken); _client.OnDonation += OnDonation; - try - { + try { await _client.ConnectAsync(); Connected = true; } - catch (Exception ex) - { - string message = $"StreamLabs Service failed to connect: {ex.Message}"; + catch (Exception ex) { + var message = $"StreamLabs Service failed to connect: {ex.Message}"; _logger?.LogError(message); - ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.Twitch), + ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.Twitch), message, DateTime.Now); } - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.StreamLabs, Service = "Socket", Name = "User", @@ -94,60 +84,52 @@ public async Task InitClientAsync() _logger?.LogInformation("StreamLabs Service Connected"); return Connected; } - - public bool IsTokenEmpty() - { + + public bool IsTokenEmpty() { return !_secureStorage.Exists(StorageKeys.StreamLabsSocketToken) || string.IsNullOrWhiteSpace(SecretToken); } - public bool SetSocketToken(string token) - { - return _secureStorage.Set(StorageKeys.StreamLabsSocketToken, token); + public bool SetSocketToken(string token) { + return _secureStorage.Set(StorageKeys.StreamLabsSocketToken, token); } - - private void OnDonation(object? o, DonationMessage message) - { - SubathonEvent subathonEvent = new() - { + + private void OnDonation(object? o, DonationMessage message) { + SubathonEvent subathonEvent = new() { User = message.Name, Currency = $"{message.Currency}".ToUpper(), Value = $"{message.Amount}", Source = SubathonEventSource.StreamLabs, EventType = SubathonEventType.StreamLabsDonation }; - if (Guid.TryParse(message.MessageId, out var tipGuid)) + if (Guid.TryParse(message.MessageId, out Guid tipGuid)) subathonEvent.Id = tipGuid; - + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - - public static void SimulateTip(string value = "10.00", string currency = "USD") - { - if (!double.TryParse(value, out var val)) + + public static void SimulateTip(string value = "10.00", string currency = "USD") { + if (!double.TryParse(value, out double val)) return; - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { User = "SYSTEM", Currency = currency, Value = value, Source = SubathonEventSource.Simulated, EventType = SubathonEventType.StreamLabsDonation }; - + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - - public async Task DisconnectAsync() - { + + public async Task DisconnectAsync() { if (!Connected) return; Connected = false; if (_client == null) return; - + _client.OnDonation -= OnDonation; await _client.DisconnectAsync(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.StreamLabs, Service = "Socket", Name = "User", diff --git a/SubathonManager.Integration/SubathonManager.Integration.csproj b/SubathonManager.Integration/SubathonManager.Integration.csproj index a29c659a..cefbc10c 100644 --- a/SubathonManager.Integration/SubathonManager.Integration.csproj +++ b/SubathonManager.Integration/SubathonManager.Integration.csproj @@ -10,33 +10,34 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - + + - + + diff --git a/SubathonManager.Integration/TangiaService.cs b/SubathonManager.Integration/TangiaService.cs index 11e178fd..06915ce9 100644 --- a/SubathonManager.Integration/TangiaService.cs +++ b/SubathonManager.Integration/TangiaService.cs @@ -7,32 +7,32 @@ using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; -using SubathonManager.Core.Objects; using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; using SubathonManager.Core.Security; using SubathonManager.Core.Security.Interfaces; namespace SubathonManager.Integration; -public class TangiaService(ILogger? logger, IHttpClientFactory httpClientFactory, ISecureStorage secureStorage) : IAppService -{ +public class TangiaService( + ILogger? logger, + IHttpClientFactory httpClientFactory, + ISecureStorage secureStorage) : IAppService { private const string PollBase = "https://api.tangia.co/ack-hook/poll"; private const string PingBase = "https://api.tangia.co/ack-hook/ping"; private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15); private static readonly TimeSpan HttpTimeout = TimeSpan.FromSeconds(10); - private string? EventKey => secureStorage.GetOrDefault(StorageKeys.TangiaEventKey, string.Empty); - private CancellationTokenSource? _cts; private int _instanceId; - public Task StartAsync(CancellationToken ct = default) - { + private string? EventKey => secureStorage.GetOrDefault(StorageKeys.TangiaEventKey, string.Empty); + + public Task StartAsync(CancellationToken ct = default) { StopPolling(); - var key = EventKey; - if (string.IsNullOrWhiteSpace(key)) - { + string? key = EventKey; + if (string.IsNullOrWhiteSpace(key)) { logger?.LogInformation("[Tangia] Not configured. Integration disabled."); BroadcastStatus(false); return Task.CompletedTask; @@ -42,64 +42,61 @@ public Task StartAsync(CancellationToken ct = default) BroadcastStatus(true); _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - var token = _cts.Token; + CancellationToken token = _cts.Token; _ = Task.Run(() => PollLoopAsync(key, token), token); return Task.CompletedTask; } - public Task StopAsync(CancellationToken ct = default) - { + public Task StopAsync(CancellationToken ct = default) { StopPolling(); BroadcastStatus(false); return Task.CompletedTask; } - private void StopPolling() - { + private void StopPolling() { if (_cts is { IsCancellationRequested: false }) _cts.Cancel(); _cts = null; } [ExcludeFromCodeCoverage] - private async Task PollLoopAsync(string key, CancellationToken ct) - { + private async Task PollLoopAsync(string key, CancellationToken ct) { logger?.LogInformation("[Tangia] Polling started"); - while (!ct.IsCancellationRequested) - { - try - { + while (!ct.IsCancellationRequested) { + try { await PollOnceAsync(key, ct); } - catch (OperationCanceledException) { break; } - catch (Exception ex) - { + catch (OperationCanceledException) { + break; + } + catch (Exception ex) { logger?.LogWarning(ex, "[Tangia] Unexpected error during poll"); } - try { await Task.Delay(PollInterval, ct); } - catch (OperationCanceledException) { break; } + try { + await Task.Delay(PollInterval, ct); + } + catch (OperationCanceledException) { + break; + } } logger?.LogInformation("[Tangia] Polling stopped"); } - internal async Task PollOnceAsync(string key, CancellationToken ct) - { + internal async Task PollOnceAsync(string key, CancellationToken ct) { double cacheBust = Random.Shared.NextDouble(); - string url = $"{PollBase}?key={Uri.EscapeDataString(key)}&cacheBust={cacheBust:F16}&instance={_instanceId}"; + var url = $"{PollBase}?key={Uri.EscapeDataString(key)}&cacheBust={cacheBust:F16}&instance={_instanceId}"; - using var client = httpClientFactory.CreateClient(nameof(TangiaService)); + using HttpClient client = httpClientFactory.CreateClient(nameof(TangiaService)); client.Timeout = HttpTimeout; HttpResponseMessage response; - try - { + try { response = await client.GetAsync(url, ct); } - catch (TaskCanceledException) when (!ct.IsCancellationRequested) - { + catch (TaskCanceledException) when (!ct.IsCancellationRequested) { logger?.LogDebug("[Tangia] Poll request timed out"); return; } @@ -110,23 +107,20 @@ internal async Task PollOnceAsync(string key, CancellationToken ct) if (response.StatusCode == HttpStatusCode.NoContent) return; - if (!response.IsSuccessStatusCode) - { + if (!response.IsSuccessStatusCode) { logger?.LogWarning("[Tangia] Unexpected poll response: {Status}", response.StatusCode); return; } - var json = await response.Content.ReadAsStringAsync(ct); + string json = await response.Content.ReadAsStringAsync(ct); if (string.IsNullOrWhiteSpace(json)) return; TangiaResponse? payload; - try - { + try { payload = JsonSerializer.Deserialize(json); } - catch (JsonException ex) - { + catch (JsonException ex) { logger?.LogWarning(ex, "[Tangia] Failed to deserialize poll response"); return; } @@ -134,23 +128,21 @@ internal async Task PollOnceAsync(string key, CancellationToken ct) if (payload?.Events == null || payload.Events.Count == 0) return; - foreach (var ev in payload.Events) - { + foreach (TangiaEvent ev in payload.Events) { logger?.LogDebug("[Tangia] Event received: {Event}", JsonSerializer.Serialize(ev)); if (string.IsNullOrWhiteSpace(ev.EventId)) continue; if (ev.EventId.Contains("_cp_")) continue; if (ev.Data?.OverlayParams?.TriggerData?.Price <= 0) continue; if (ev.EventId.Contains("_test_")) continue; - + // var time = TimeSpan.FromMilliseconds(ev.UnixMs); - SubathonEvent sev = new SubathonEvent() - { + var sev = new SubathonEvent { User = ev.Data?.OverlayParams?.BuyerInfo?.Name ?? ev.Data?.OverlayParams?.Name ?? "Tangia User", Id = Utils.CreateGuidFromUniqueString(ev.EventId), Value = $"{ev.Data?.OverlayParams?.TriggerData?.Price}", Currency = "tokens", Source = SubathonEventSource.Tangia, - EventType = SubathonEventType.TangiaTokens, + EventType = SubathonEventType.TangiaTokens // EventTimestamp = new DateTime(time.Ticks).ToLocalTime(), // so apparently this isnt real unix, it's like, UTC+2 or something }; SubathonEvents.RaiseSubathonEventCreated(sev); @@ -159,21 +151,19 @@ internal async Task PollOnceAsync(string key, CancellationToken ct) [ExcludeFromCodeCoverage] [Obsolete("Unused and may not be required for functionality")] - private async Task PingAsync(string key, CancellationToken ct) - { - try - { - string url = $"{PingBase}?key={Uri.EscapeDataString(key)}&instance={_instanceId}¤tEvent=undefined"; - using var client = httpClientFactory.CreateClient(nameof(TangiaService)); - await client.PostAsync(url, content: null, ct); + private async Task PingAsync(string key, CancellationToken ct) { + try { + var url = $"{PingBase}?key={Uri.EscapeDataString(key)}&instance={_instanceId}¤tEvent=undefined"; + using HttpClient client = httpClientFactory.CreateClient(nameof(TangiaService)); + await client.PostAsync(url, null, ct); + } + catch { + /**/ } - catch { /**/ } } - public static void SimulateTangiaTokens(long amount) - { - var ev = new SubathonEvent - { + public static void SimulateTangiaTokens(long amount) { + var ev = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TangiaTokens, User = "SYSTEM", @@ -184,32 +174,27 @@ public static void SimulateTangiaTokens(long amount) SubathonEvents.RaiseSubathonEventCreated(ev); } - public static bool TryParseEventKey(string overlayUrl, out string eventKey) - { + public static bool TryParseEventKey(string overlayUrl, out string eventKey) { eventKey = string.Empty; if (string.IsNullOrWhiteSpace(overlayUrl)) return false; - try - { + try { var uri = new Uri(overlayUrl); - var path = uri.AbsolutePath; - var lastSegment = path.Split('/').LastOrDefault(s => !string.IsNullOrWhiteSpace(s)); + string path = uri.AbsolutePath; + string? lastSegment = path.Split('/').LastOrDefault(s => !string.IsNullOrWhiteSpace(s)); if (string.IsNullOrWhiteSpace(lastSegment) || !lastSegment.StartsWith("evt_")) return false; eventKey = lastSegment; return true; } - catch - { + catch { return false; } } - private void BroadcastStatus(bool connected) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private void BroadcastStatus(bool connected) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Tangia, Service = nameof(SubathonEventSource.Tangia), Name = "", @@ -218,61 +203,45 @@ private void BroadcastStatus(bool connected) }); } - private class TangiaResponse - { - [JsonPropertyName("Events")] - public List? Events { get; set; } + private class TangiaResponse { + [JsonPropertyName("Events")] public List? Events { get; set; } } - internal class TangiaEvent - { - [JsonPropertyName("UnixMS")] - public long UnixMs { get; set; } + internal class TangiaEvent { + [JsonPropertyName("UnixMS")] public long UnixMs { get; set; } - [JsonPropertyName("Data")] - public TangiaEventData? Data { get; set; } + [JsonPropertyName("Data")] public TangiaEventData? Data { get; set; } - [JsonPropertyName("EventID")] - public string? EventId { get; set; } + [JsonPropertyName("EventID")] public string? EventId { get; set; } } - internal class TangiaEventData - { - [JsonPropertyName("type")] - public string? Type { get; set; } + internal class TangiaEventData { + [JsonPropertyName("type")] public string? Type { get; set; } - [JsonPropertyName("URL")] - public string? Url { get; set; } + [JsonPropertyName("URL")] public string? Url { get; set; } - [JsonPropertyName("OverlayParams")] - public TangiaOverlayParams? OverlayParams { get; set; } + [JsonPropertyName("OverlayParams")] public TangiaOverlayParams? OverlayParams { get; set; } } - internal class TangiaOverlayParams - { - - [JsonPropertyName("InteractionTitle")] - public string? InteractionTitle { get; set; } - [JsonPropertyName("TriggerData")] - public TangiaTriggerData? TriggerData { get; set; } - [JsonPropertyName("BuyerInfo")] - public TangiaBuyerInfo? BuyerInfo { get; set; } - [JsonPropertyName("duration")] - public long? Duration { get; set; } - [JsonPropertyName("name")] - public string? Name { get; set; } + internal class TangiaOverlayParams { + [JsonPropertyName("InteractionTitle")] public string? InteractionTitle { get; set; } + + [JsonPropertyName("TriggerData")] public TangiaTriggerData? TriggerData { get; set; } + + [JsonPropertyName("BuyerInfo")] public TangiaBuyerInfo? BuyerInfo { get; set; } + + [JsonPropertyName("duration")] public long? Duration { get; set; } + + [JsonPropertyName("name")] public string? Name { get; set; } } - internal class TangiaTriggerData - { - [JsonPropertyName("price")] - public int? Price { get; set; } + internal class TangiaTriggerData { + [JsonPropertyName("price")] public int? Price { get; set; } } - internal class TangiaBuyerInfo - { - [JsonPropertyName("has-plus")] - public bool? HasPlus { get; set; } - [JsonPropertyName("name")] - public string? Name { get; set; } + + internal class TangiaBuyerInfo { + [JsonPropertyName("has-plus")] public bool? HasPlus { get; set; } + + [JsonPropertyName("name")] public string? Name { get; set; } } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/ThroneService.cs b/SubathonManager.Integration/ThroneService.cs index d85e3bf2..716fd507 100644 --- a/SubathonManager.Integration/ThroneService.cs +++ b/SubathonManager.Integration/ThroneService.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -15,30 +16,26 @@ namespace SubathonManager.Integration; public class ThroneService(ILogger? logger, IConfig config, DevTunnelsService devTunnels) - : IWebhookIntegration -{ - private bool Enabled => config.GetBool("Throne", "Enabled", false); - public string WebhookPath => "/api/webhooks/throne"; - + : IWebhookIntegration { private const string ThronePublicKeyPem = """ -----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAPXbUfxh7XL4SYUVcfhmYMIbxvtR9E9LDd8gPJ1PwSD8= -----END PUBLIC KEY----- """; + private static readonly TimeSpan TimestampTolerance = TimeSpan.FromMinutes(10); + private bool Enabled => config.GetBool("Throne", "Enabled"); + public string WebhookPath => "/api/webhooks/throne"; - - public async Task StartAsync(CancellationToken ct = default) - { + + public async Task StartAsync(CancellationToken ct = default) { IntegrationEvents.ConnectionUpdated += OnTunnelUpdated; - if (Enabled) - { + if (Enabled) { logger?.LogInformation("[Throne] Webhook listener ready at {Path}", WebhookPath); _ = devTunnels.StartTunnelAsync(ct); } - else - { + else { logger?.LogInformation("[Throne] Has not been enabled. Shutting down..."); BroadcastStatus(false, null); } @@ -46,55 +43,82 @@ public async Task StartAsync(CancellationToken ct = default) await Task.CompletedTask; } - public Task StopAsync(CancellationToken ct = default) - { + public Task StopAsync(CancellationToken ct = default) { IntegrationEvents.ConnectionUpdated -= OnTunnelUpdated; BroadcastStatus(false, null); - + return Task.CompletedTask; } - - - [ExcludeFromCodeCoverage] - public async Task Initialize(CancellationToken ct = default) - { - var tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); - if (!tunnelConn.Status) { - return; + + public Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, + CancellationToken ct = default) { + if (!Enabled) return Task.CompletedTask; + if (!headers.TryGetValue("X-Signature-Timestamp", out string? timestamp) + || string.IsNullOrWhiteSpace(timestamp) + || !long.TryParse(timestamp, out long timestampUnix)) { + logger?.LogWarning("[Throne] Rejected - missing or non-numeric X-Signature-Timestamp"); + return Task.CompletedTask; + } + + if (!headers.TryGetValue("X-Signature-Ed25519", out string? signatureHex) + || string.IsNullOrWhiteSpace(signatureHex)) { + logger?.LogWarning("[Throne] Rejected - missing X-Signature-Ed25519"); + return Task.CompletedTask; + } + + TimeSpan age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(timestampUnix); + if (age.Duration() > TimestampTolerance) { + logger?.LogWarning("[Throne] Rejected - timestamp age {Age:F0}s exceeds tolerance", age.TotalSeconds); + return Task.CompletedTask; + } + + string stringData = Encoding.UTF8.GetString(rawBody); + if (!SignatureVerification.VerifyEd25519Signature(stringData, timestamp, signatureHex, ThronePublicKeyPem)) { + logger?.LogWarning("[Throne] Rejected - invalid signature"); + return Task.CompletedTask; } - - if (!Enabled) - { + + logger?.LogDebug("[Throne] Signature verified OK"); + + ProcessData(stringData); + + return Task.CompletedTask; + } + + + [ExcludeFromCodeCoverage] + public async Task Initialize(CancellationToken ct = default) { + IntegrationConnection tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); + if (!tunnelConn.Status) return; + + if (!Enabled) { BroadcastStatus(false, null); return; } - + tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); BroadcastStatus(Enabled, tunnelConn.Status ? tunnelConn.Name : null); await Task.CompletedTask; } - + [ExcludeFromCodeCoverage] - private void OnTunnelUpdated(IntegrationConnection connection) - { + private void OnTunnelUpdated(IntegrationConnection connection) { if (connection is not { Source: SubathonEventSource.DevTunnels, Service: "Tunnel" }) return; - if (Enabled && connection.Status) - { + if (Enabled && connection.Status) { Task.Run(() => Initialize()); return; } + BroadcastStatus(Enabled, connection.Status ? connection.Name : null); } - - private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) - { + + private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { string? fullUrl = !string.IsNullOrWhiteSpace(tunnelBaseUrl) && tunnelBaseUrl != "(starting...)" ? tunnelBaseUrl.TrimEnd('/') + WebhookPath : null; - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Name = fullUrl ?? "", Status = enabled && fullUrl != null, Source = SubathonEventSource.Throne, @@ -105,114 +129,77 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) if (fullUrl != null) logger?.LogInformation("[Throne] Public webhook URL: {Url}", fullUrl); } - public Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, CancellationToken ct = default) - { - if (!Enabled) return Task.CompletedTask; - if (!headers.TryGetValue("X-Signature-Timestamp", out var timestamp) - || string.IsNullOrWhiteSpace(timestamp) - || !long.TryParse(timestamp, out var timestampUnix)) - { - logger?.LogWarning("[Throne] Rejected - missing or non-numeric X-Signature-Timestamp"); - return Task.CompletedTask; - } - - if (!headers.TryGetValue("X-Signature-Ed25519", out var signatureHex) - || string.IsNullOrWhiteSpace(signatureHex)) - { - logger?.LogWarning("[Throne] Rejected - missing X-Signature-Ed25519"); - return Task.CompletedTask; - } - - var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(timestampUnix); - if (age.Duration() > TimestampTolerance) - { - logger?.LogWarning("[Throne] Rejected - timestamp age {Age:F0}s exceeds tolerance", age.TotalSeconds); - return Task.CompletedTask; - } - - var stringData = Encoding.UTF8.GetString(rawBody); - if (!SignatureVerification.VerifyEd25519Signature( stringData, timestamp, signatureHex, ThronePublicKeyPem)) - { - logger?.LogWarning("[Throne] Rejected - invalid signature"); - return Task.CompletedTask; - } - - logger?.LogDebug("[Throne] Signature verified OK"); - - ProcessData(stringData); - - return Task.CompletedTask; - } - public void ProcessData(string stringData, bool isSim = false) - { + public void ProcessData(string stringData, bool isSim = false) { var throneEvent = JsonConvert.DeserializeObject>(stringData); if (throneEvent == null) return; - throneEvent.TryGetValue("data", out var value); + throneEvent.TryGetValue("data", out object? value); if (value == null || string.IsNullOrWhiteSpace(value.ToString())) return; var data = JsonConvert.DeserializeObject>(value.ToString() ?? string.Empty)!; - throneEvent.TryGetValue("event_id", out var uuid); - data.TryGetValue("item_name", out var itemName); - data.TryGetValue("gifter_username", out var gifterName); - data.TryGetValue("currency", out var currency); + throneEvent.TryGetValue("event_id", out object? uuid); + data.TryGetValue("item_name", out object? itemName); + data.TryGetValue("gifter_username", out object? gifterName); + data.TryGetValue("currency", out object? currency); // data.TryGetValue("creator_username", out var creatorUsername); // bool.TryParse(isSurpriseRaw?.ToString(), out var isSurprise); // var username = creatorUsername?.ToString(); - - data.TryGetValue("is_surprise_gift", out var isSurpriseRaw); + + data.TryGetValue("is_surprise_gift", out object? isSurpriseRaw); if ((bool)(isSurpriseRaw ?? false)) itemName = "Surprise Gift"; - - SubathonEvent subathonEvent = new SubathonEvent - { + + var subathonEvent = new SubathonEvent { Id = Utils.TryParseGuid(uuid?.ToString()), SecondaryValue = itemName?.ToString() ?? "Item", User = gifterName?.ToString() ?? "Crowdfunding Complete!", Source = isSim ? SubathonEventSource.Simulated : SubathonEventSource.Throne }; - throneEvent.TryGetValue("event_type", out var eventType); + throneEvent.TryGetValue("event_type", out object? eventType); subathonEvent.TertiaryValue = itemName?.ToString() ?? "New Gift"; - switch (eventType?.ToString()) - { + switch (eventType?.ToString()) { case "gift_purchased": - var mode = config.GetOrderTypeMode("Throne", $"{SubathonEventType.ThroneGiftPurchase}", + OrderTypeModes mode = config.GetOrderTypeMode("Throne", $"{SubathonEventType.ThroneGiftPurchase}", OrderTypeModes.Dollar); - - data.TryGetValue("price", out var price); - double.TryParse(price?.ToString() ?? "0.00", out var priceInt); + + data.TryGetValue("price", out object? price); + double.TryParse(price?.ToString() ?? "0.00", out double priceInt); subathonEvent.EventType = SubathonEventType.ThroneGiftPurchase; - subathonEvent.Currency = mode == OrderTypeModes.Dollar && !string.IsNullOrWhiteSpace(currency!.ToString()) ? currency.ToString() : "item"; + subathonEvent.Currency = + mode == OrderTypeModes.Dollar && !string.IsNullOrWhiteSpace(currency!.ToString()) + ? currency.ToString() + : "item"; subathonEvent.Amount = 1; - subathonEvent.Value = mode != OrderTypeModes.Dollar ? itemName?.ToString() ?? "1" : - (priceInt / 100).ToString("F2", System.Globalization.CultureInfo.InvariantCulture); + subathonEvent.Value = mode != OrderTypeModes.Dollar + ? itemName?.ToString() ?? "1" + : (priceInt / 100).ToString("F2", CultureInfo.InvariantCulture); break; case "contribution_purchased": subathonEvent.EventType = SubathonEventType.ThroneGiftContribution; subathonEvent.Currency = currency?.ToString() ?? ""; - data.TryGetValue("amount", out var amount); + data.TryGetValue("amount", out object? amount); subathonEvent.TertiaryValue = itemName?.ToString() ?? "New Contribution"; - double.TryParse(amount?.ToString(), out var amountInt); + double.TryParse(amount?.ToString(), out double amountInt); subathonEvent.Value = - (amountInt / 100).ToString("F2", System.Globalization.CultureInfo.InvariantCulture); + (amountInt / 100).ToString("F2", CultureInfo.InvariantCulture); break; case "gift_crowdfunded": - data.TryGetValue("price", out var price2); - double.TryParse(price2?.ToString() ?? "0.00", out var priceInt2); + data.TryGetValue("price", out object? price2); + double.TryParse(price2?.ToString() ?? "0.00", out double priceInt2); subathonEvent.EventType = SubathonEventType.ThroneCrowdGiftComplete; subathonEvent.User = itemName?.ToString() ?? "Crowdfunding Complete!"; subathonEvent.Currency = currency?.ToString() ?? "item"; subathonEvent.Value = - (priceInt2 / 100).ToString("F2", System.Globalization.CultureInfo.InvariantCulture); + (priceInt2 / 100).ToString("F2", CultureInfo.InvariantCulture); break; default: return; } - + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } } \ No newline at end of file diff --git a/SubathonManager.Integration/TipeeeStreamService.cs b/SubathonManager.Integration/TipeeeStreamService.cs index 6545eff7..38fa84d3 100644 --- a/SubathonManager.Integration/TipeeeStreamService.cs +++ b/SubathonManager.Integration/TipeeeStreamService.cs @@ -1,11 +1,13 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net.Http.Headers; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; -using SocketIOClient; using SocketIO.Core; -using SocketIOType = SocketIOClient.SocketIO; +using SocketIOClient; +using SocketIOClient.Transport; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -14,16 +16,31 @@ using SubathonManager.Core.Objects; using SubathonManager.Core.Security; using SubathonManager.Core.Security.Interfaces; +using SocketIOType = SocketIOClient.SocketIO; namespace SubathonManager.Integration; -public class TipeeeStreamService(ILogger? logger, IHttpClientFactory httpClientFactory, - ISecureStorage secureStorage, ITimerService timerService): IAppService -{ +public class TipeeeStreamService( + ILogger? logger, + IHttpClientFactory httpClientFactory, + ISecureStorage secureStorage, + ITimerService timerService) : IAppService { private const string ApiBase = "https://api.tipeeestream.com"; internal readonly string _oAuthUrl = "https://oauth.subathonmanager.app/auth/tipeeestream/login"; + + private readonly Utils.ServiceReconnectState _reconnectState = + new(TimeSpan.FromSeconds(3), 50, TimeSpan.FromMinutes(5)); + internal readonly string _refreshUrl = "https://oauth.subathonmanager.app/auth/tipeeestream/refresh"; + private bool _apiKeyRetried; + private bool _connected; + private bool _hasAuthError; + private IDisposable? _refreshTimerHandle; + + private SocketIOType? _socket; + private CancellationTokenSource? _socketCts; + private string? _username = string.Empty; internal Action OpenBrowser = url => Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); @@ -31,24 +48,11 @@ public class TipeeeStreamService(ILogger? logger, IHttpClie private string? AccessToken => secureStorage.GetOrDefault(StorageKeys.TipeeeStreamAccessToken, string.Empty); private string? RefreshToken => secureStorage.GetOrDefault(StorageKeys.TipeeeStreamRefreshToken, string.Empty); private string? ApiKey => secureStorage.GetOrDefault(StorageKeys.TipeeeStreamApiKey, string.Empty); - private string? _username = string.Empty; - private SocketIOType? _socket; - private bool _connected; - private bool _hasAuthError; - private bool _apiKeyRetried; - private CancellationTokenSource? _socketCts; - private IDisposable? _refreshTimerHandle; - - private readonly Utils.ServiceReconnectState _reconnectState = - new(TimeSpan.FromSeconds(3), maxRetries: 50, maxBackoff: TimeSpan.FromMinutes(5)); - - public async Task StartAsync(CancellationToken ct = default) - { + public async Task StartAsync(CancellationToken ct = default) { Utils.PendingOAuthCallback = null; - if (!HasTokens()) - { + if (!HasTokens()) { logger?.LogInformation("[TipeeeStream] Not configured. Integration disabled."); BroadcastStatus(false); return; @@ -57,45 +61,46 @@ public async Task StartAsync(CancellationToken ct = default) await InitializeAsync(ct); } + public async Task StopAsync(CancellationToken ct = default) { + _refreshTimerHandle?.Dispose(); + _refreshTimerHandle = null; + await DisconnectAsync(); + BroadcastStatus(false); + } + [ExcludeFromCodeCoverage] - private async Task InitializeAsync(CancellationToken ct = default) - { - if (CheckExpiry()) - { - var refreshed = await RefreshAccessTokenAsync(ct); - if (!refreshed) - { + private async Task InitializeAsync(CancellationToken ct = default) { + if (CheckExpiry()) { + bool refreshed = await RefreshAccessTokenAsync(ct); + if (!refreshed) { RevokeTokens(); await StartOAuthFlowAsync(ct); - if (!HasTokens()) - { + if (!HasTokens()) { BroadcastStatus(false); return; } } } - if (string.IsNullOrWhiteSpace(_username)) - { - var username = await FetchUsernameAsync(ct); - if (string.IsNullOrWhiteSpace(username)) - { + if (string.IsNullOrWhiteSpace(_username)) { + string? username = await FetchUsernameAsync(ct); + if (string.IsNullOrWhiteSpace(username)) { logger?.LogWarning("[TipeeeStream] Could not fetch username"); BroadcastStatus(false); return; } + _username = username; } - if (string.IsNullOrWhiteSpace(ApiKey)) - { - var apiKey = await FetchApiKeyAsync(ct); - if (string.IsNullOrWhiteSpace(apiKey)) - { + if (string.IsNullOrWhiteSpace(ApiKey)) { + string? apiKey = await FetchApiKeyAsync(ct); + if (string.IsNullOrWhiteSpace(apiKey)) { logger?.LogWarning("[TipeeeStream] Could not fetch API key"); BroadcastStatus(false); return; } + secureStorage.Set(StorageKeys.TipeeeStreamApiKey, apiKey); } @@ -106,12 +111,10 @@ private async Task InitializeAsync(CancellationToken ct = default) _refreshTimerHandle = timerService.Register( $"{nameof(TipeeeStreamService)}.TokenRefresh", TimeSpan.FromMinutes(30), - async (token) => - { + async token => { if (!CheckExpiry()) return; - var ok = await RefreshAccessTokenAsync(token); - if (!ok) - { + bool ok = await RefreshAccessTokenAsync(token); + if (!ok) { logger?.LogWarning("[TipeeeStream] Periodic token refresh failed - disconnecting"); BroadcastStatus(false); await DisconnectAsync(); @@ -121,20 +124,10 @@ private async Task InitializeAsync(CancellationToken ct = default) await ConnectSocketAsync(ct); } - public async Task StopAsync(CancellationToken ct = default) - { - _refreshTimerHandle?.Dispose(); - _refreshTimerHandle = null; - await DisconnectAsync(); - BroadcastStatus(false); - } - [ExcludeFromCodeCoverage] - private async Task ConnectSocketAsync(CancellationToken ct = default) - { + private async Task ConnectSocketAsync(CancellationToken ct = default) { string? socketUrl = await GetSocketUrlAsync(ct); - if (string.IsNullOrWhiteSpace(socketUrl)) - { + if (string.IsNullOrWhiteSpace(socketUrl)) { logger?.LogWarning("[TipeeeStream] Could not retrieve socket URL"); BroadcastStatus(false); return; @@ -147,47 +140,40 @@ private async Task ConnectSocketAsync(CancellationToken ct = default) _hasAuthError = false; _connected = false; - _socket = new SocketIOType(socketUrl, new SocketIOOptions - { - Query = new List> - { + _socket = new SocketIOType(socketUrl, new SocketIOOptions { + Query = new List> { new("access_token", ApiKey!) }, EIO = EngineIO.V3, - Transport = SocketIOClient.Transport.TransportProtocol.WebSocket + Transport = TransportProtocol.WebSocket }); _socket.OnConnected += OnSocketConnected; _socket.OnDisconnected += OnSocketDisconnected; _socket.On("new-event", OnNewEvent); - try - { + try { await _socket.ConnectAsync(ct); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Initial socket connection failed"); _ = Task.Run(() => ReconnectWithBackoffAsync(_socketCts.Token), ct); } } [ExcludeFromCodeCoverage] - private async void OnSocketConnected(object? sender, EventArgs e) - { + private async void OnSocketConnected(object? sender, EventArgs e) { logger?.LogInformation("[TipeeeStream] Socket connected"); _connected = true; _hasAuthError = false; _reconnectState.Reset(); _reconnectState.Cts?.Cancel(); - try - { + try { await _socket!.EmitAsync("join-room", ApiKey, _username); logger?.LogDebug("[TipeeeStream] Joined room for {User}", _username); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Failed to emit join-room"); } @@ -195,16 +181,14 @@ private async void OnSocketConnected(object? sender, EventArgs e) } [ExcludeFromCodeCoverage] - private void OnSocketDisconnected(object? sender, string reason) - { + private void OnSocketDisconnected(object? sender, string reason) { logger?.LogWarning("[TipeeeStream] Socket disconnected: {Reason}", reason); _connected = false; BroadcastStatus(false); if (_hasAuthError || _socketCts?.IsCancellationRequested == true) return; - if (string.Equals(reason, "io server disconnect", StringComparison.OrdinalIgnoreCase)) - { + if (string.Equals(reason, "io server disconnect", StringComparison.OrdinalIgnoreCase)) { _ = Task.Run(() => HandleApiKeyFailureAsync(_socketCts?.Token ?? CancellationToken.None)); return; } @@ -213,51 +197,44 @@ private void OnSocketDisconnected(object? sender, string reason) } [ExcludeFromCodeCoverage] - private void OnNewEvent(SocketIOResponse response) - { - try - { - HandleParsedEvent(response.GetValue(0)?.Event); - } - catch (Exception ex) - { + private void OnNewEvent(SocketIOResponse response) { + try { + HandleParsedEvent(response.GetValue()?.Event); + } + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Failed to parse new-event"); } } - internal void ProcessEventJson(string json) - { - try - { + internal void ProcessEventJson(string json) { + try { var wrappers = JsonSerializer.Deserialize>(json); HandleParsedEvent(wrappers?.Count > 0 ? wrappers[0].Event : null); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Failed to parse event JSON"); } } - private void HandleParsedEvent(TipeeeEvent? ev) - { + private void HandleParsedEvent(TipeeeEvent? ev) { if (ev?.Type is not ("donation" or "tipeee")) return; - var p = ev.Parameters; + TipeeeEventParameters? p = ev.Parameters; if (p == null) return; - var refStr = ev.Ref.ValueKind == JsonValueKind.String + string refStr = ev.Ref.ValueKind == JsonValueKind.String ? ev.Ref.GetString() ?? "" : ev.Ref.ValueKind == JsonValueKind.Number - ? ev.Ref.GetDouble().ToString(System.Globalization.CultureInfo.InvariantCulture) + ? ev.Ref.GetDouble().ToString(CultureInfo.InvariantCulture) : ""; if (refStr.StartsWith("TWITCH_", StringComparison.OrdinalIgnoreCase) || refStr.StartsWith("YOUTUBE_", StringComparison.OrdinalIgnoreCase)) return; - + // docs mention this but in practice it doesn't so, :/ - var isSimulation = refStr.StartsWith("simulation", StringComparison.OrdinalIgnoreCase); + bool isSimulation = refStr.StartsWith("simulation", StringComparison.OrdinalIgnoreCase); - var username = isSimulation + string? username = isSimulation ? "SYSTEM" : !string.IsNullOrWhiteSpace(p.Username) ? p.Username @@ -266,23 +243,24 @@ private void HandleParsedEvent(TipeeeEvent? ev) ?.Username ?? "TipeeeStream"; - var amount = p.Amount.ValueKind == JsonValueKind.String - ? double.TryParse(p.Amount.GetString(), System.Globalization.NumberStyles.Any, - System.Globalization.CultureInfo.InvariantCulture, out var parsed) ? parsed : 0.0 + double amount = p.Amount.ValueKind == JsonValueKind.String + ? double.TryParse(p.Amount.GetString(), NumberStyles.Any, + CultureInfo.InvariantCulture, out double parsed) + ? parsed + : 0.0 : p.Amount.ValueKind == JsonValueKind.Number ? p.Amount.GetDouble() : 0.0; - var subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = Utils.CreateGuidFromUniqueString(refStr), Source = SubathonEventSource.TipeeeStream, EventType = SubathonEventType.TipeeeStreamDonation, User = username, - Value = amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), + Value = amount.ToString("F2", CultureInfo.InvariantCulture), Currency = !string.IsNullOrWhiteSpace(p.Currency) ? p.Currency : "EUR", - EventTimestamp = DateTimeOffset.TryParse(ev.CreatedAt, System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.None, out var ts) + EventTimestamp = DateTimeOffset.TryParse(ev.CreatedAt, CultureInfo.InvariantCulture, + DateTimeStyles.None, out DateTimeOffset ts) ? ts.LocalDateTime : DateTime.Now.ToLocalTime() }; @@ -291,21 +269,17 @@ private void HandleParsedEvent(TipeeeEvent? ev) } [ExcludeFromCodeCoverage] - private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) - { + private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) { if (!await _reconnectState.Lock.WaitAsync(0, ct)) return; - try - { + try { _reconnectState.Cts?.Cancel(); _reconnectState.Cts = new CancellationTokenSource(); using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _reconnectState.Cts.Token); - var token = linked.Token; + CancellationToken token = linked.Token; - while (!token.IsCancellationRequested && !_connected && !_hasAuthError) - { - if (_reconnectState.Retries >= _reconnectState.MaxRetries) - { + while (!token.IsCancellationRequested && !_connected && !_hasAuthError) { + if (_reconnectState.Retries >= _reconnectState.MaxRetries) { var msg = "TipeeeStream disconnected and max reconnect retries were reached."; ErrorMessageEvents.RaiseErrorEvent( "ERROR", nameof(SubathonEventSource.TipeeeStream), msg, DateTime.Now.ToLocalTime()); @@ -314,20 +288,20 @@ private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) } _reconnectState.Retries++; - var delay = _reconnectState.Backoff; + TimeSpan delay = _reconnectState.Backoff; logger?.LogWarning("[TipeeeStream] Reconnect attempt {Attempt}/{Max} in {Delay}s", _reconnectState.Retries, _reconnectState.MaxRetries, delay.TotalSeconds); - try - { + try { await Task.Delay(delay, token); if (!_connected && _socket != null) await _socket.ConnectAsync(token); } - catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (OperationCanceledException) { + return; + } + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Reconnect attempt failed"); } @@ -337,63 +311,55 @@ private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) _reconnectState.MaxBackoff.TotalMilliseconds)); } } - finally - { + finally { _reconnectState.Lock.Release(); } } [ExcludeFromCodeCoverage] - private async Task DisconnectAsync() - { + private async Task DisconnectAsync() { _socketCts?.Cancel(); _connected = false; _reconnectState.Cts?.Cancel(); - var s = _socket; + SocketIOType? s = _socket; _socket = null; if (s == null) return; - try - { + try { s.OnConnected -= OnSocketConnected; s.OnDisconnected -= OnSocketDisconnected; s.Off("new-event"); await s.DisconnectAsync(); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Disconnect error"); } - finally - { + finally { s.Dispose(); } } [ExcludeFromCodeCoverage] - public async Task ConnectAsync(CancellationToken ct = default) - { + public async Task ConnectAsync(CancellationToken ct = default) { await StopAsync(ct); await StartOAuthFlowAsync(ct); - if (!HasTokens()) - { + if (!HasTokens()) { BroadcastStatus(false); return; } + await InitializeAsync(ct); } - public static void SimulateDonation(string amount, string currency) - { - if (!double.TryParse(amount, System.Globalization.NumberStyles.Any, - System.Globalization.CultureInfo.InvariantCulture, out var amt)) return; - var ev = new SubathonEvent - { + public static void SimulateDonation(string amount, string currency) { + if (!double.TryParse(amount, NumberStyles.Any, + CultureInfo.InvariantCulture, out double amt)) return; + var ev = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TipeeeStreamDonation, User = "SYSTEM", - Value = amt.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), + Value = amt.ToString("F2", CultureInfo.InvariantCulture), Currency = !string.IsNullOrWhiteSpace(currency) ? currency : "EUR", EventTimestamp = DateTime.Now.ToLocalTime() }; @@ -401,39 +367,35 @@ public static void SimulateDonation(string amount, string currency) } [ExcludeFromCodeCoverage] - private bool CheckExpiry() - { + private bool CheckExpiry() { if (string.IsNullOrWhiteSpace(AccessToken)) return false; - var expires = Utils.GetAccessTokenExpiry(AccessToken); + DateTime? expires = Utils.GetAccessTokenExpiry(AccessToken); if (expires == null) return false; return DateTime.UtcNow >= expires.Value.AddSeconds(-60); } [ExcludeFromCodeCoverage] - private async Task RefreshAccessTokenAsync(CancellationToken ct = default) - { + private async Task RefreshAccessTokenAsync(CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(RefreshToken)) return false; logger?.LogDebug("[TipeeeStream] Refreshing access token..."); - try - { - using var client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); + try { + using HttpClient client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); using var body = new FormUrlEncodedContent( [ new KeyValuePair("refresh_token", RefreshToken) ]); - var response = await client.PostAsync(_refreshUrl, body, ct); - if (!response.IsSuccessStatusCode) - { + HttpResponseMessage response = await client.PostAsync(_refreshUrl, body, ct); + if (!response.IsSuccessStatusCode) { logger?.LogWarning("[TipeeeStream] Token refresh failed ({Status})", response.StatusCode); return false; } - var json = await response.Content.ReadAsStringAsync(ct); + string json = await response.Content.ReadAsStringAsync(ct); var tokens = JsonSerializer.Deserialize>(json); - var newAccess = tokens?.GetValueOrDefault("access_token").GetString(); - var newRefresh = tokens?.GetValueOrDefault("refresh_token").GetString() ?? RefreshToken; + string? newAccess = tokens?.GetValueOrDefault("access_token").GetString(); + string? newRefresh = tokens?.GetValueOrDefault("refresh_token").GetString() ?? RefreshToken; if (string.IsNullOrWhiteSpace(newAccess)) return false; @@ -442,176 +404,159 @@ private async Task RefreshAccessTokenAsync(CancellationToken ct = default) logger?.LogDebug("[TipeeeStream] Tokens refreshed successfully"); return true; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Token refresh error"); return false; } } [ExcludeFromCodeCoverage] - private async Task StartOAuthFlowAsync(CancellationToken ct = default) - { + private async Task StartOAuthFlowAsync(CancellationToken ct = default) { RevokeTokens(); Utils.PendingOAuthCallback = null; logger?.LogDebug("[TipeeeStream] Opening OAuth..."); OpenBrowser(_oAuthUrl); - var (newAccess, newRefresh) = await WaitForProtocolCallbackAsync(ct); - if (!string.IsNullOrEmpty(newAccess) && !string.IsNullOrEmpty(newRefresh)) - { + (string? newAccess, string? newRefresh) = await WaitForProtocolCallbackAsync(ct); + if (!string.IsNullOrEmpty(newAccess) && !string.IsNullOrEmpty(newRefresh)) { secureStorage.Set(StorageKeys.TipeeeStreamAccessToken, newAccess); secureStorage.Set(StorageKeys.TipeeeStreamRefreshToken, newRefresh); logger?.LogInformation("[TipeeeStream] OAuth tokens stored"); } - else - { + else { logger?.LogWarning("[TipeeeStream] OAuth flow did not produce tokens"); } } - private async Task<(string?, string?)> WaitForProtocolCallbackAsync(CancellationToken ct = default) - { - var timeout = DateTime.Now.AddMinutes(15); - while (DateTime.Now < timeout && !ct.IsCancellationRequested) - { - var cb = Utils.PendingOAuthCallback; - if (cb?.Provider == "tipeeestream") - { + private async Task<(string?, string?)> WaitForProtocolCallbackAsync(CancellationToken ct = default) { + DateTime timeout = DateTime.Now.AddMinutes(15); + while (DateTime.Now < timeout && !ct.IsCancellationRequested) { + OAuthCallback? cb = Utils.PendingOAuthCallback; + if (cb?.Provider == "tipeeestream") { Utils.PendingOAuthCallback = null; - if (!string.IsNullOrEmpty(cb.Error)) - { + if (!string.IsNullOrEmpty(cb.Error)) { logger?.LogWarning("[TipeeeStream] OAuth error from callback: {Error}", cb.Error); return (null, null); } - if (!string.IsNullOrEmpty(cb.AccessToken) && !string.IsNullOrEmpty(cb.RefreshToken)) - { + + if (!string.IsNullOrEmpty(cb.AccessToken) && !string.IsNullOrEmpty(cb.RefreshToken)) { logger?.LogInformation("[TipeeeStream] OAuth callback received"); return (cb.AccessToken, cb.RefreshToken); } + logger?.LogWarning("[TipeeeStream] OAuth callback had no tokens and no error"); return (null, null); } + await Task.Delay(250, ct); } + logger?.LogWarning("[TipeeeStream] OAuth callback timed out"); return (null, null); } - private bool HasTokens() => - secureStorage.Exists(StorageKeys.TipeeeStreamAccessToken) - && secureStorage.Exists(StorageKeys.TipeeeStreamRefreshToken) - && !string.IsNullOrWhiteSpace(AccessToken) - && !string.IsNullOrWhiteSpace(RefreshToken); + private bool HasTokens() { + return secureStorage.Exists(StorageKeys.TipeeeStreamAccessToken) + && secureStorage.Exists(StorageKeys.TipeeeStreamRefreshToken) + && !string.IsNullOrWhiteSpace(AccessToken) + && !string.IsNullOrWhiteSpace(RefreshToken); + } - public void RevokeTokens() - { + public void RevokeTokens() { secureStorage.Delete(StorageKeys.TipeeeStreamAccessToken); secureStorage.Delete(StorageKeys.TipeeeStreamRefreshToken); secureStorage.Delete(StorageKeys.TipeeeStreamApiKey); } [ExcludeFromCodeCoverage] - private async Task FetchUsernameAsync(CancellationToken ct = default) - { - try - { - using var client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); + private async Task FetchUsernameAsync(CancellationToken ct = default) { + try { + using HttpClient client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", AccessToken); - var resp = await client.GetAsync($"{ApiBase}/v1.0/me", ct); - if (!resp.IsSuccessStatusCode) - { + new AuthenticationHeaderValue("Bearer", AccessToken); + HttpResponseMessage resp = await client.GetAsync($"{ApiBase}/v1.0/me", ct); + if (!resp.IsSuccessStatusCode) { logger?.LogWarning("[TipeeeStream] FetchUsername HTTP {Status}", resp.StatusCode); return null; } - var json = await resp.Content.ReadAsStringAsync(ct); + + string json = await resp.Content.ReadAsStringAsync(ct); logger?.LogDebug("[TipeeeStream] FetchUsername response: {Body}", json); - using var doc = JsonDocument.Parse(json); - if (doc.RootElement.TryGetProperty("datas", out var datas) - && datas.TryGetProperty("username", out var un)) + using JsonDocument doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("datas", out JsonElement datas) + && datas.TryGetProperty("username", out JsonElement un)) return un.GetString(); - if (doc.RootElement.TryGetProperty("username", out var rootUn)) + if (doc.RootElement.TryGetProperty("username", out JsonElement rootUn)) return rootUn.GetString(); return null; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Failed to fetch username"); return null; } } [ExcludeFromCodeCoverage] - private async Task FetchApiKeyAsync(CancellationToken ct = default) - { - try - { - using var client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); + private async Task FetchApiKeyAsync(CancellationToken ct = default) { + try { + using HttpClient client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", AccessToken); - var resp = await client.GetAsync($"{ApiBase}/v1.0/me/api", ct); - if (!resp.IsSuccessStatusCode) - { + new AuthenticationHeaderValue("Bearer", AccessToken); + HttpResponseMessage resp = await client.GetAsync($"{ApiBase}/v1.0/me/api", ct); + if (!resp.IsSuccessStatusCode) { logger?.LogWarning("[TipeeeStream] FetchApiKey HTTP {Status}", resp.StatusCode); return null; } - var json = await resp.Content.ReadAsStringAsync(ct); + + string json = await resp.Content.ReadAsStringAsync(ct); logger?.LogDebug("[TipeeeStream] FetchApiKey response: {Body}", json); - using var doc = JsonDocument.Parse(json); - if (doc.RootElement.TryGetProperty("datas", out var datas) - && datas.TryGetProperty("apiKey", out var key)) + using JsonDocument doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("datas", out JsonElement datas) + && datas.TryGetProperty("apiKey", out JsonElement key)) return key.GetString(); - if (doc.RootElement.TryGetProperty("apiKey", out var rootKey)) + if (doc.RootElement.TryGetProperty("apiKey", out JsonElement rootKey)) return rootKey.GetString(); return null; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Failed to fetch API key"); return null; } } [ExcludeFromCodeCoverage] - private async Task GetSocketUrlAsync(CancellationToken ct = default) - { - try - { - using var client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); - var resp = await client.GetAsync($"{ApiBase}/v2.0/site/socket", ct); - if (!resp.IsSuccessStatusCode) - { + private async Task GetSocketUrlAsync(CancellationToken ct = default) { + try { + using HttpClient client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); + HttpResponseMessage resp = await client.GetAsync($"{ApiBase}/v2.0/site/socket", ct); + if (!resp.IsSuccessStatusCode) { logger?.LogWarning("[TipeeeStream] GetSocketUrl HTTP {Status}", resp.StatusCode); return null; } - var json = await resp.Content.ReadAsStringAsync(ct); + + string json = await resp.Content.ReadAsStringAsync(ct); logger?.LogDebug("[TipeeeStream] GetSocketUrl response: {Body}", json); - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - var dataEl = root.TryGetProperty("datas", out var d) ? d : root; + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; + JsonElement dataEl = root.TryGetProperty("datas", out JsonElement d) ? d : root; { - var host = dataEl.TryGetProperty("host", out var h) ? h.GetString() : null; - var port = dataEl.TryGetProperty("port", out var p) ? p.GetString() : null; - if (!string.IsNullOrWhiteSpace(host)) - { + string? host = dataEl.TryGetProperty("host", out JsonElement h) ? h.GetString() : null; + string? port = dataEl.TryGetProperty("port", out JsonElement p) ? p.GetString() : null; + if (!string.IsNullOrWhiteSpace(host)) { host = host.TrimEnd('/', ':'); return string.IsNullOrWhiteSpace(port) ? host : $"{host}:{port}"; } } return null; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Failed to get socket URL"); return null; } } [ExcludeFromCodeCoverage] - private async Task HandleApiKeyFailureAsync(CancellationToken ct = default) - { - if (_apiKeyRetried) - { + private async Task HandleApiKeyFailureAsync(CancellationToken ct = default) { + if (_apiKeyRetried) { logger?.LogError("[TipeeeStream] API key retry already attempted - giving up"); _hasAuthError = true; BroadcastStatus(false); @@ -625,9 +570,8 @@ private async Task HandleApiKeyFailureAsync(CancellationToken ct = default) logger?.LogWarning("[TipeeeStream] API key may be stale - re-fetching..."); secureStorage.Delete(StorageKeys.TipeeeStreamApiKey); - var newKey = await FetchApiKeyAsync(ct); - if (string.IsNullOrWhiteSpace(newKey)) - { + string? newKey = await FetchApiKeyAsync(ct); + if (string.IsNullOrWhiteSpace(newKey)) { logger?.LogWarning("[TipeeeStream] Re-fetch of API key failed"); _hasAuthError = true; BroadcastStatus(false); @@ -639,10 +583,8 @@ private async Task HandleApiKeyFailureAsync(CancellationToken ct = default) await ConnectSocketAsync(ct); } - private void BroadcastStatus(bool connected) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private void BroadcastStatus(bool connected) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.TipeeeStream, Service = nameof(SubathonEventSource.TipeeeStream), Name = connected ? _username ?? "User" : "", @@ -652,61 +594,42 @@ private void BroadcastStatus(bool connected) } - private class TipeeeWrapper - { - [JsonPropertyName("event")] - public TipeeeEvent? Event { get; set; } + private class TipeeeWrapper { + [JsonPropertyName("event")] public TipeeeEvent? Event { get; set; } } - private class TipeeeEvent - { - [JsonPropertyName("type")] - public string? Type { get; set; } + private class TipeeeEvent { + [JsonPropertyName("type")] public string? Type { get; set; } // "ref" can be a string or a float depending on the event source - [JsonPropertyName("ref")] - public JsonElement Ref { get; set; } + [JsonPropertyName("ref")] public JsonElement Ref { get; set; } // why is it diff between v1/v2 events... - [JsonPropertyName("created_at")] - public string? CreatedAt { get; set; } + [JsonPropertyName("created_at")] public string? CreatedAt { get; set; } - [JsonPropertyName("parameters")] - public TipeeeEventParameters? Parameters { get; set; } + [JsonPropertyName("parameters")] public TipeeeEventParameters? Parameters { get; set; } - [JsonPropertyName("user")] - public TipeeeEventUser? User { get; set; } + [JsonPropertyName("user")] public TipeeeEventUser? User { get; set; } } - private class TipeeeEventParameters - { - [JsonPropertyName("username")] - public string? Username { get; set; } + private class TipeeeEventParameters { + [JsonPropertyName("username")] public string? Username { get; set; } // "amount" can be a string or number depending on the event source - [JsonPropertyName("amount")] - public JsonElement Amount { get; set; } - - [JsonPropertyName("currency")] - public string? Currency { get; set; } + [JsonPropertyName("amount")] public JsonElement Amount { get; set; } + [JsonPropertyName("currency")] public string? Currency { get; set; } } - private class TipeeeEventUser - { - [JsonPropertyName("username")] - public string? Username { get; set; } + private class TipeeeEventUser { + [JsonPropertyName("username")] public string? Username { get; set; } - [JsonPropertyName("providers")] - public List? Providers { get; set; } + [JsonPropertyName("providers")] public List? Providers { get; set; } } - private class TipeeeEventProvider - { - [JsonPropertyName("code")] - public string? Code { get; set; } + private class TipeeeEventProvider { + [JsonPropertyName("code")] public string? Code { get; set; } - [JsonPropertyName("username")] - public string? Username { get; set; } + [JsonPropertyName("username")] public string? Username { get; set; } } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/TreatStreamService.cs b/SubathonManager.Integration/TreatStreamService.cs index e8800cb9..addd85b0 100644 --- a/SubathonManager.Integration/TreatStreamService.cs +++ b/SubathonManager.Integration/TreatStreamService.cs @@ -3,9 +3,9 @@ using System.Globalization; using System.Text.Json; using Microsoft.Extensions.Logging; -using SocketIOClient; using SocketIO.Core; -using SocketIOType = SocketIOClient.SocketIO; +using SocketIOClient; +using SocketIOClient.Transport; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -14,40 +14,53 @@ using SubathonManager.Core.Objects; using SubathonManager.Core.Security; using SubathonManager.Core.Security.Interfaces; +using SocketIOType = SocketIOClient.SocketIO; namespace SubathonManager.Integration; -public class TreatStreamService(ILogger? logger, IHttpClientFactory httpClientFactory, - ISecureStorage secureStorage, ITimerService timerService) : IAppService -{ - internal string SocketUrl = "https://nodeapi.treatstream.com/"; +public class TreatStreamService( + ILogger? logger, + IHttpClientFactory httpClientFactory, + ISecureStorage secureStorage, + ITimerService timerService) : IAppService { internal readonly string _oAuthUrl = "https://oauth.subathonmanager.app/auth/treatstream/login"; + + private readonly Utils.ServiceReconnectState _reconnectState = + new(TimeSpan.FromSeconds(3), 50, TimeSpan.FromMinutes(5)); + internal readonly string _refreshUrl = "https://oauth.subathonmanager.app/auth/treatstream/refresh"; internal readonly string _socketTokenUrl = "https://treatstream.com/Oauth2/Authorize/socketToken"; + private bool _connected; + private bool _hasAuthError; + private IDisposable? _refreshTimerHandle; + + private SocketIOType? _socket; + private CancellationTokenSource? _socketCts; internal Action OpenBrowser = url => Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); + internal string SocketUrl = "https://nodeapi.treatstream.com/"; + private string? AccessToken => secureStorage.GetOrDefault(StorageKeys.TreatStreamAccessToken, string.Empty); private string? RefreshToken => secureStorage.GetOrDefault(StorageKeys.TreatStreamRefreshToken, string.Empty); private string? ClientId => secureStorage.GetOrDefault(StorageKeys.TreatStreamClientId, string.Empty); - private SocketIOType? _socket; - private bool _connected; - private bool _hasAuthError; - private CancellationTokenSource? _socketCts; - private IDisposable? _refreshTimerHandle; - - private readonly Utils.ServiceReconnectState _reconnectState = - new(TimeSpan.FromSeconds(3), maxRetries: 50, maxBackoff: TimeSpan.FromMinutes(5)); + private DateTime? TokenExpiry { + get { + string? raw = secureStorage.GetOrDefault(StorageKeys.TreatStreamTokenExpiry, string.Empty); + return DateTime.TryParse(raw, CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime dt) + ? dt + : null; + } + } - public async Task StartAsync(CancellationToken ct = default) - { + public async Task StartAsync(CancellationToken ct = default) { Utils.PendingOAuthCallback = null; - if (!HasTokens()) - { + if (!HasTokens()) { logger?.LogInformation("[TreatStream] Not configured. Integration disabled."); BroadcastStatus(false); return; @@ -56,8 +69,7 @@ public async Task StartAsync(CancellationToken ct = default) await InitializeAsync(ct); } - public async Task StopAsync(CancellationToken ct = default) - { + public async Task StopAsync(CancellationToken ct = default) { _refreshTimerHandle?.Dispose(); _refreshTimerHandle = null; await DisconnectAsync(); @@ -65,17 +77,13 @@ public async Task StopAsync(CancellationToken ct = default) } [ExcludeFromCodeCoverage] - private async Task InitializeAsync(CancellationToken ct = default) - { - if (CheckExpiry()) - { - var refreshed = await RefreshAccessTokenAsync(ct); - if (!refreshed) - { + private async Task InitializeAsync(CancellationToken ct = default) { + if (CheckExpiry()) { + bool refreshed = await RefreshAccessTokenAsync(ct); + if (!refreshed) { RevokeTokens(); await StartOAuthFlowAsync(ct); - if (!HasTokens()) - { + if (!HasTokens()) { BroadcastStatus(false); return; } @@ -87,66 +95,50 @@ private async Task InitializeAsync(CancellationToken ct = default) } [ExcludeFromCodeCoverage] - public async Task ConnectAsync(CancellationToken ct = default) - { + public async Task ConnectAsync(CancellationToken ct = default) { await StopAsync(ct); await StartOAuthFlowAsync(ct); - if (!HasTokens()) - { + if (!HasTokens()) { BroadcastStatus(false); return; } - await InitializeAsync(ct); - } - private DateTime? TokenExpiry - { - get - { - var raw = secureStorage.GetOrDefault(StorageKeys.TreatStreamTokenExpiry, string.Empty); - return DateTime.TryParse(raw, CultureInfo.InvariantCulture, - DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var dt) ? dt : null; - } + await InitializeAsync(ct); } - internal void StoreExpiry(string? expiresIn) - { - double seconds = double.TryParse(expiresIn, NumberStyles.Any, CultureInfo.InvariantCulture, out var s) && s > 0 + internal void StoreExpiry(string? expiresIn) { + double seconds = double.TryParse(expiresIn, NumberStyles.Any, CultureInfo.InvariantCulture, out double s) && + s > 0 ? s : TimeSpan.FromDays(15).TotalSeconds; // docs say 30, but if it is unknown, be safe with 15 secureStorage.Set(StorageKeys.TreatStreamTokenExpiry, DateTime.UtcNow.AddSeconds(seconds).ToString("O", CultureInfo.InvariantCulture)); } - private bool CheckExpiry() - { + private bool CheckExpiry() { if (string.IsNullOrWhiteSpace(AccessToken)) return false; - var expires = TokenExpiry; + DateTime? expires = TokenExpiry; if (expires == null) return false; return DateTime.UtcNow >= expires.Value.AddHours(-1); } [ExcludeFromCodeCoverage] - private void RegisterRefreshTimer() - { + private void RegisterRefreshTimer() { _refreshTimerHandle?.Dispose(); - var untilRefresh = (TokenExpiry ?? DateTime.UtcNow.AddDays(30)) - DateTime.UtcNow - TimeSpan.FromHours(1); - var interval = TimeSpan.FromTicks(Math.Clamp(untilRefresh.Ticks, + TimeSpan untilRefresh = (TokenExpiry ?? DateTime.UtcNow.AddDays(30)) - DateTime.UtcNow - TimeSpan.FromHours(1); + TimeSpan interval = TimeSpan.FromTicks(Math.Clamp(untilRefresh.Ticks, TimeSpan.FromMinutes(5).Ticks, TimeSpan.FromHours(24).Ticks)); _refreshTimerHandle = timerService.Register( $"{nameof(TreatStreamService)}.TokenRefresh", interval, - async (token) => - { + async token => { if (!CheckExpiry()) return; - var ok = await RefreshAccessTokenAsync(token); - if (ok) - { + bool ok = await RefreshAccessTokenAsync(token); + if (ok) { RegisterRefreshTimer(); } - else - { + else { logger?.LogWarning("[TreatStream] Scheduled token refresh failed - disconnecting"); BroadcastStatus(false); await DisconnectAsync(); @@ -155,35 +147,35 @@ private void RegisterRefreshTimer() } [ExcludeFromCodeCoverage] - internal async Task RefreshAccessTokenAsync(CancellationToken ct = default) - { + internal async Task RefreshAccessTokenAsync(CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(RefreshToken)) return false; logger?.LogDebug("[TreatStream] Refreshing access token..."); - try - { - using var client = httpClientFactory.CreateClient(nameof(TreatStreamService)); + try { + using HttpClient client = httpClientFactory.CreateClient(nameof(TreatStreamService)); using var body = new FormUrlEncodedContent( [ new KeyValuePair("refresh_token", RefreshToken) ]); - var response = await client.PostAsync(_refreshUrl, body, ct); - if (!response.IsSuccessStatusCode) - { + HttpResponseMessage response = await client.PostAsync(_refreshUrl, body, ct); + if (!response.IsSuccessStatusCode) { logger?.LogWarning("[TreatStream] Token refresh failed ({Status})", response.StatusCode); return false; } - var json = await response.Content.ReadAsStringAsync(ct); + string json = await response.Content.ReadAsStringAsync(ct); var tokens = JsonSerializer.Deserialize>(json); var newAccess = tokens?.GetValueOrDefault("access_token").ToString(); - var newRefresh = tokens != null && tokens.TryGetValue("refresh_token", out var r) - ? r.ToString() : RefreshToken; - var expiresIn = tokens != null && tokens.TryGetValue("expires_in", out var e) - ? e.ToString() : null; - var clientId = tokens != null && tokens.TryGetValue("client_id", out var c) - ? c.ToString() : null; + string? newRefresh = tokens != null && tokens.TryGetValue("refresh_token", out JsonElement r) + ? r.ToString() + : RefreshToken; + string? expiresIn = tokens != null && tokens.TryGetValue("expires_in", out JsonElement e) + ? e.ToString() + : null; + string? clientId = tokens != null && tokens.TryGetValue("client_id", out JsonElement c) + ? c.ToString() + : null; if (string.IsNullOrWhiteSpace(newAccess)) return false; @@ -195,23 +187,20 @@ internal async Task RefreshAccessTokenAsync(CancellationToken ct = default logger?.LogDebug("[TreatStream] Tokens refreshed successfully"); return true; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TreatStream] Token refresh error"); return false; } } [ExcludeFromCodeCoverage] - private async Task StartOAuthFlowAsync(CancellationToken ct = default) - { + private async Task StartOAuthFlowAsync(CancellationToken ct = default) { RevokeTokens(); Utils.PendingOAuthCallback = null; logger?.LogDebug("[TreatStream] Opening OAuth..."); OpenBrowser(_oAuthUrl); - var cb = await WaitForProtocolCallbackAsync(ct); - if (cb != null && !string.IsNullOrEmpty(cb.AccessToken) && !string.IsNullOrEmpty(cb.RefreshToken)) - { + OAuthCallback? cb = await WaitForProtocolCallbackAsync(ct); + if (cb != null && !string.IsNullOrEmpty(cb.AccessToken) && !string.IsNullOrEmpty(cb.RefreshToken)) { secureStorage.Set(StorageKeys.TreatStreamAccessToken, cb.AccessToken); secureStorage.Set(StorageKeys.TreatStreamRefreshToken, cb.RefreshToken); if (!string.IsNullOrWhiteSpace(cb.ClientId)) @@ -219,44 +208,42 @@ private async Task StartOAuthFlowAsync(CancellationToken ct = default) StoreExpiry(cb.ExpiresIn); logger?.LogInformation("[TreatStream] OAuth tokens stored"); } - else - { + else { logger?.LogWarning("[TreatStream] OAuth flow did not produce tokens"); } } [ExcludeFromCodeCoverage] - private async Task WaitForProtocolCallbackAsync(CancellationToken ct = default) - { - var timeout = DateTime.Now.AddMinutes(15); - while (DateTime.Now < timeout && !ct.IsCancellationRequested) - { - var cb = Utils.PendingOAuthCallback; - if (cb?.Provider == "treatstream") - { + private async Task WaitForProtocolCallbackAsync(CancellationToken ct = default) { + DateTime timeout = DateTime.Now.AddMinutes(15); + while (DateTime.Now < timeout && !ct.IsCancellationRequested) { + OAuthCallback? cb = Utils.PendingOAuthCallback; + if (cb?.Provider == "treatstream") { Utils.PendingOAuthCallback = null; - if (!string.IsNullOrEmpty(cb.Error)) - { + if (!string.IsNullOrEmpty(cb.Error)) { logger?.LogWarning("[TreatStream] OAuth error from callback: {Error}", cb.Error); return null; } + return cb; } + await Task.Delay(250, ct); } + logger?.LogWarning("[TreatStream] OAuth callback timed out"); return null; } - public bool HasTokens() => - secureStorage.Exists(StorageKeys.TreatStreamAccessToken) - && secureStorage.Exists(StorageKeys.TreatStreamRefreshToken) - && !string.IsNullOrWhiteSpace(AccessToken) - && !string.IsNullOrWhiteSpace(RefreshToken); + public bool HasTokens() { + return secureStorage.Exists(StorageKeys.TreatStreamAccessToken) + && secureStorage.Exists(StorageKeys.TreatStreamRefreshToken) + && !string.IsNullOrWhiteSpace(AccessToken) + && !string.IsNullOrWhiteSpace(RefreshToken); + } [ExcludeFromCodeCoverage] - public void RevokeTokens() - { + public void RevokeTokens() { secureStorage.Delete(StorageKeys.TreatStreamAccessToken); secureStorage.Delete(StorageKeys.TreatStreamRefreshToken); secureStorage.Delete(StorageKeys.TreatStreamTokenExpiry); @@ -264,47 +251,41 @@ public void RevokeTokens() } [ExcludeFromCodeCoverage] - internal async Task FetchSocketTokenAsync(CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(ClientId)) - { + internal async Task FetchSocketTokenAsync(CancellationToken ct = default) { + if (string.IsNullOrWhiteSpace(ClientId)) { logger?.LogWarning("[TreatStream] No stored client_id - reconnect via OAuth to fetch it"); return null; } - try - { - using var client = httpClientFactory.CreateClient(nameof(TreatStreamService)); + try { + using HttpClient client = httpClientFactory.CreateClient(nameof(TreatStreamService)); using var body = new FormUrlEncodedContent( [ new KeyValuePair("client_id", ClientId!), new KeyValuePair("access_token", AccessToken ?? "") ]); - var resp = await client.PostAsync(_socketTokenUrl, body, ct); - if (!resp.IsSuccessStatusCode) - { + HttpResponseMessage resp = await client.PostAsync(_socketTokenUrl, body, ct); + if (!resp.IsSuccessStatusCode) { logger?.LogWarning("[TreatStream] FetchSocketToken HTTP {Status}", resp.StatusCode); return null; } - var json = await resp.Content.ReadAsStringAsync(ct); - using var doc = JsonDocument.Parse(json); - return doc.RootElement.TryGetProperty("socket_token", out var tok) + + string json = await resp.Content.ReadAsStringAsync(ct); + using JsonDocument doc = JsonDocument.Parse(json); + return doc.RootElement.TryGetProperty("socket_token", out JsonElement tok) ? tok.ToString() : null; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TreatStream] Failed to fetch socket token"); return null; } } [ExcludeFromCodeCoverage] - private async Task ConnectSocketAsync(CancellationToken ct = default) - { - var socketToken = await FetchSocketTokenAsync(ct); - if (string.IsNullOrWhiteSpace(socketToken)) - { + private async Task ConnectSocketAsync(CancellationToken ct = default) { + string? socketToken = await FetchSocketTokenAsync(ct); + if (string.IsNullOrWhiteSpace(socketToken)) { logger?.LogWarning("[TreatStream] Could not retrieve socket token"); BroadcastStatus(false); _ = Task.Run(() => ReconnectWithBackoffAsync(_socketCts?.Token ?? ct), ct); @@ -317,34 +298,29 @@ private async Task ConnectSocketAsync(CancellationToken ct = default) _hasAuthError = false; _connected = false; - _socket = new SocketIOType(SocketUrl, new SocketIOOptions - { - Query = new List> - { + _socket = new SocketIOType(SocketUrl, new SocketIOOptions { + Query = new List> { new("token", socketToken) }, EIO = EngineIO.V3, - Transport = SocketIOClient.Transport.TransportProtocol.WebSocket + Transport = TransportProtocol.WebSocket }); _socket.OnConnected += OnSocketConnected; _socket.OnDisconnected += OnSocketDisconnected; _socket.On("realTimeTreat", OnRealTimeTreat); - try - { + try { await _socket.ConnectAsync(ct); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TreatStream] Initial socket connection failed"); _ = Task.Run(() => ReconnectWithBackoffAsync(_socketCts.Token), ct); } } [ExcludeFromCodeCoverage] - private void OnSocketConnected(object? sender, EventArgs e) - { + private void OnSocketConnected(object? sender, EventArgs e) { logger?.LogInformation("[TreatStream] Socket connected"); _connected = true; _hasAuthError = false; @@ -354,8 +330,7 @@ private void OnSocketConnected(object? sender, EventArgs e) } [ExcludeFromCodeCoverage] - private void OnSocketDisconnected(object? sender, string reason) - { + private void OnSocketDisconnected(object? sender, string reason) { logger?.LogWarning("[TreatStream] Socket disconnected: {Reason}", reason); _connected = false; BroadcastStatus(false); @@ -365,21 +340,17 @@ private void OnSocketDisconnected(object? sender, string reason) } [ExcludeFromCodeCoverage] - private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) - { + private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) { if (!await _reconnectState.Lock.WaitAsync(0, ct)) return; - try - { + try { _reconnectState.Cts?.Cancel(); _reconnectState.Cts = new CancellationTokenSource(); using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _reconnectState.Cts.Token); - var token = linked.Token; + CancellationToken token = linked.Token; - while (!token.IsCancellationRequested && !_connected && !_hasAuthError) - { - if (_reconnectState.Retries >= _reconnectState.MaxRetries) - { + while (!token.IsCancellationRequested && !_connected && !_hasAuthError) { + if (_reconnectState.Retries >= _reconnectState.MaxRetries) { var msg = "TreatStream disconnected and max reconnect retries were reached."; ErrorMessageEvents.RaiseErrorEvent( "ERROR", nameof(SubathonEventSource.TreatStream), msg, DateTime.Now.ToLocalTime()); @@ -388,24 +359,21 @@ private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) } _reconnectState.Retries++; - var delay = _reconnectState.Backoff; + TimeSpan delay = _reconnectState.Backoff; logger?.LogWarning("[TreatStream] Reconnect attempt {Attempt}/{Max} in {Delay}s", _reconnectState.Retries, _reconnectState.MaxRetries, delay.TotalSeconds); - try - { + try { await Task.Delay(delay, token); if (CheckExpiry()) await RefreshAccessTokenAsync(token); - var socketToken = await FetchSocketTokenAsync(token); - if (!string.IsNullOrWhiteSpace(socketToken) && !_connected && _socket != null) - { + string? socketToken = await FetchSocketTokenAsync(token); + if (!string.IsNullOrWhiteSpace(socketToken) && !_connected && _socket != null) { await DisconnectSocketOnlyAsync(); - _socket = new SocketIOType(SocketUrl, new SocketIOOptions - { + _socket = new SocketIOType(SocketUrl, new SocketIOOptions { Query = new List> { new("token", socketToken) }, EIO = EngineIO.V3, - Transport = SocketIOClient.Transport.TransportProtocol.WebSocket + Transport = TransportProtocol.WebSocket }); _socket.OnConnected += OnSocketConnected; _socket.OnDisconnected += OnSocketDisconnected; @@ -413,9 +381,10 @@ private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) await _socket.ConnectAsync(token); } } - catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (OperationCanceledException) { + return; + } + catch (Exception ex) { logger?.LogWarning(ex, "[TreatStream] Reconnect attempt failed"); } @@ -425,37 +394,31 @@ private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) _reconnectState.MaxBackoff.TotalMilliseconds)); } } - finally - { + finally { _reconnectState.Lock.Release(); } } [ExcludeFromCodeCoverage] - private async Task DisconnectSocketOnlyAsync() - { - var s = _socket; + private async Task DisconnectSocketOnlyAsync() { + SocketIOType? s = _socket; _socket = null; if (s == null) return; - try - { + try { s.OnConnected -= OnSocketConnected; s.OnDisconnected -= OnSocketDisconnected; s.Off("realTimeTreat"); await s.DisconnectAsync(); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TreatStream] Disconnect error"); } - finally - { + finally { s.Dispose(); } } - private async Task DisconnectAsync() - { + private async Task DisconnectAsync() { _socketCts?.Cancel(); _connected = false; _reconnectState.Cts?.Cancel(); @@ -463,41 +426,36 @@ private async Task DisconnectAsync() } [ExcludeFromCodeCoverage] - private void OnRealTimeTreat(SocketIOResponse response) - { - try - { - ProcessTreatJson(response.GetValue(0).GetRawText()); + private void OnRealTimeTreat(SocketIOResponse response) { + try { + ProcessTreatJson(response.GetValue().GetRawText()); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TreatStream] Failed to parse realTimeTreat"); } } - internal bool ProcessTreatJson(string json, bool simulated = false) - { - try - { - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; + internal bool ProcessTreatJson(string json, bool simulated = false) { + try { + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; if (root.ValueKind != JsonValueKind.Object) return false; - string? title = root.TryGetProperty("title", out var titleEl) ? titleEl.GetString() : null; + string? title = root.TryGetProperty("title", out JsonElement titleEl) ? titleEl.GetString() : null; if (string.IsNullOrWhiteSpace(title)) return false; - string sender = root.TryGetProperty("sender", out var senderEl) + string sender = root.TryGetProperty("sender", out JsonElement senderEl) && !string.IsNullOrWhiteSpace(senderEl.GetString()) ? senderEl.GetString()! : "TreatStream"; - string dateCreated = root.TryGetProperty("date_created", out var dateEl) - ? dateEl.GetString() ?? "" : ""; + string dateCreated = root.TryGetProperty("date_created", out JsonElement dateEl) + ? dateEl.GetString() ?? "" + : ""; bool isSystem = simulated || sender == "SYSTEM"; - var subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = Utils.CreateGuidFromUniqueString($"treatstream|{title}|{dateCreated}"), Source = isSystem ? SubathonEventSource.Simulated : SubathonEventSource.TreatStream, EventType = SubathonEventType.TreatStreamOrder, @@ -507,7 +465,7 @@ internal bool ProcessTreatJson(string json, bool simulated = false) Value = title, TertiaryValue = title, EventTimestamp = DateTimeOffset.TryParse(dateCreated, CultureInfo.InvariantCulture, - DateTimeStyles.None, out var ts) + DateTimeStyles.None, out DateTimeOffset ts) ? ts.LocalDateTime : DateTime.Now.ToLocalTime() }; @@ -515,18 +473,15 @@ internal bool ProcessTreatJson(string json, bool simulated = false) SubathonEvents.RaiseSubathonEventCreated(subathonEvent); return true; } - catch (JsonException ex) - { + catch (JsonException ex) { logger?.LogWarning(ex, "[TreatStream] Invalid treat JSON"); return false; } } - public static void SimulateTreat(string title = "Fancy Treat") - { + public static void SimulateTreat(string title = "Fancy Treat") { if (string.IsNullOrWhiteSpace(title)) title = "Fancy Treat"; - SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent - { + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TreatStreamOrder, User = "SYSTEM", @@ -538,10 +493,8 @@ public static void SimulateTreat(string title = "Fancy Treat") }); } - private void BroadcastStatus(bool connected) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private void BroadcastStatus(bool connected) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.TreatStream, Service = "Socket", Name = connected ? "TreatStream" : "", @@ -549,4 +502,4 @@ private void BroadcastStatus(bool connected) Configured = HasTokens() }); } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/TwitchService.cs b/SubathonManager.Integration/TwitchService.cs index 082cbc16..3bef2bde 100644 --- a/SubathonManager.Integration/TwitchService.cs +++ b/SubathonManager.Integration/TwitchService.cs @@ -1,77 +1,132 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; -using TwitchLib.Api; -using TwitchLib.Api.Core.Enums; -using TwitchLib.Client; -using TwitchLib.Client.Models; -using TwitchLib.EventSub.Core.EventArgs.Channel; -using TwitchLib.EventSub.Websockets.Core.Models; -using TwitchLib.EventSub.Websockets; using SubathonManager.Core; -using SubathonManager.Core.Models; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; using SubathonManager.Core.Objects; using SubathonManager.Core.Security; using SubathonManager.Core.Security.Interfaces; using SubathonManager.Services; +using TwitchLib.Api; +using TwitchLib.Api.Auth; +using TwitchLib.Api.Core.Enums; +using TwitchLib.Api.Helix.Models.EventSub; +using TwitchLib.Api.Helix.Models.Users.GetUsers; +using TwitchLib.Client; using TwitchLib.Client.Events; +using TwitchLib.Client.Models; +using TwitchLib.EventSub.Core.EventArgs.Channel; using TwitchLib.EventSub.Core.EventArgs.Stream; +using TwitchLib.EventSub.Websockets; using TwitchLib.EventSub.Websockets.Core.EventArgs; +using TwitchLib.EventSub.Websockets.Core.Models; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; -public class TwitchService(ILogger? logger, IConfig config, ISecureStorage secureStorage) - : IDisposable, IAppService -{ +public class TwitchService( + ILogger? logger, + IConfig config, + ISecureStorage secureStorage, + ITimerService? timerService = null) + : IDisposable, IAppService { + private static int _hypeTrainLevel; + + private readonly Utils.ServiceReconnectState _chatReconnect = + new(TimeSpan.FromSeconds(5), 200, TimeSpan.FromMinutes(2)); + + private readonly Utils.ServiceReconnectState _eventSubReconnect = + new(TimeSpan.FromSeconds(2.5), 200, TimeSpan.FromMinutes(5)); + + private readonly TimeSpan _hypeTrainLevelDuration = TimeSpan.FromSeconds(5 * 60 + 15); // 5m + buffer time + internal readonly string _oAuthURl = "https://oauth.subathonmanager.app/auth/twitch/login"; private TwitchAPI? _api; private TwitchClient? _chat; + private bool _disposed; private EventSubWebsocketClient? _eventSub; - private static int _hypeTrainLevel = 0; - private bool _disposed = false; - internal readonly string _oAuthURl = "https://oauth.subathonmanager.app/auth/twitch/login"; - - internal Uri? EventSubUrl = null; - + private volatile bool _isConnected; + private DateTime _lastChatDisconnectLog = DateTime.MinValue; - private volatile bool _isConnected = false; - - private readonly Utils.ServiceReconnectState _chatReconnect = - new(TimeSpan.FromSeconds(5), maxRetries: 200, maxBackoff: TimeSpan.FromMinutes(2)); - private readonly Utils.ServiceReconnectState _eventSubReconnect = - new(TimeSpan.FromSeconds(2.5), maxRetries: 200, maxBackoff: TimeSpan.FromMinutes(5)); + internal Uri? EventSubUrl = null; + internal string? Login = string.Empty; + + internal Action OpenBrowser = + url => Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); public string? UserName { get; private set; } = string.Empty; - internal string? Login = string.Empty; private string? UserId { get; set; } private string? AccessToken => secureStorage.GetOrDefault(StorageKeys.TwitchAccessToken, string.Empty); - internal Action OpenBrowser = url => Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); + [ExcludeFromCodeCoverage] + public async Task StartAsync(CancellationToken ct = default) { + if (HasTokenFile()) { + bool tokenValid = await ValidateTokenAsync(); + if (!tokenValid) { + RevokeTokenFile(); + logger?.LogWarning("Twitch token expired - deleting token file"); + } + else { + logger?.LogInformation("Twitch Service starting up..."); + await InitializeAsync(ct); + } + } + } - public bool HasTokenFile() - { - return secureStorage.Exists(StorageKeys.TwitchAccessToken) && + public async Task StopAsync(CancellationToken ct = default) { + // api has no disconnect? + OnTeardown(); + if (_chat != null) await _chat.DisconnectAsync(); + if (_eventSub != null) await _eventSub.DisconnectAsync(); + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { + Source = SubathonEventSource.Twitch, + Service = "API", + Name = UserName ?? "", + Status = false, + Configured = HasTokenFile() + }); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { + Source = SubathonEventSource.Twitch, + Service = "EventSub", + Name = UserName ?? "", + Status = false, + Configured = HasTokenFile() + }); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { + Source = SubathonEventSource.Twitch, + Service = "Chat", + Name = UserName ?? "", + Status = false, + Configured = HasTokenFile() + }); + } + + public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + public bool HasTokenFile() { + return secureStorage.Exists(StorageKeys.TwitchAccessToken) && !string.IsNullOrWhiteSpace(AccessToken); } - public void RevokeTokenFile() - { + public void RevokeTokenFile() { secureStorage.Delete(StorageKeys.TwitchAccessToken); } - public async Task ValidateTokenAsync() - { + public async Task ValidateTokenAsync() { if (!HasTokenFile()) return false; var api = new TwitchAPI(); - try - { - var validation = await api.Auth.ValidateAccessTokenAsync(AccessToken); + try { + ValidateAccessTokenResponse? validation = await api.Auth.ValidateAccessTokenAsync(AccessToken); if (validation.ClientId != Config.TwitchClientId) return false; @@ -79,50 +134,23 @@ public async Task ValidateTokenAsync() logger?.LogInformation($"Twitch Token Valid for Scopes: {string.Join(',', validation.Scopes)}"); return true; } - catch (HttpRequestException ex) - { + catch (HttpRequestException ex) { logger?.LogError(ex, "Could not validate token. Internet connection may be down."); - // return true so we don't delete file return true; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogError(ex, "Twitch Token Validation Error"); ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.Twitch), "Twitch Token could not be validated", DateTime.Now.ToLocalTime()); return false; } } - - [ExcludeFromCodeCoverage] - public async Task StartAsync(CancellationToken ct = default) - { - if (HasTokenFile()) - { - var tokenValid = await ValidateTokenAsync(); - if (!tokenValid) - { - RevokeTokenFile(); - logger?.LogWarning("Twitch token expired - deleting token file"); - } - else - { - logger?.LogInformation("Twitch Service starting up..."); - await InitializeAsync(ct); - } - } - } - + [ExcludeFromCodeCoverage] - public async Task InitializeAsync(CancellationToken ct = default) - { - if (string.IsNullOrEmpty(AccessToken)) - { - await StartOAuthFlowAsync(); - } + public async Task InitializeAsync(CancellationToken ct = default) { + if (string.IsNullOrEmpty(AccessToken)) await StartOAuthFlowAsync(); - try - { + try { await InitializeApiAsync(); logger?.LogDebug("Twitch Initialized API"); await InitializeChatAsync(); @@ -130,78 +158,65 @@ public async Task InitializeAsync(CancellationToken ct = default) await InitializeEventSubAsync(); logger?.LogDebug("Twitch Initialized EventSub"); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogError(ex, "TwitchService Initialization Error"); ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.Twitch), $"Error initializing Twitch Service: {ex.Message}. " + $"Please try reconnecting twitch or restarting the application", DateTime.Now); } } - - private async Task StartOAuthFlowAsync() - { + + private async Task StartOAuthFlowAsync() { Utils.PendingOAuthCallback = null; logger?.LogDebug("Opening Twitch OAuth..."); OpenBrowser(_oAuthURl); - var token = await WaitForProtocolCallbackAsync(); - if (!string.IsNullOrWhiteSpace(token)) - { - secureStorage.Set(StorageKeys.TwitchAccessToken, token); - } + string? token = await WaitForProtocolCallbackAsync(); + if (!string.IsNullOrWhiteSpace(token)) secureStorage.Set(StorageKeys.TwitchAccessToken, token); } - - private async Task WaitForProtocolCallbackAsync(CancellationToken ct = default) - { - var timeout = DateTime.Now.AddMinutes(15); - while (DateTime.Now < timeout && !ct.IsCancellationRequested) - { - var cb = Utils.PendingOAuthCallback; - if (cb?.Provider == "twitch" && !string.IsNullOrEmpty(cb.AccessToken)) - { + + private async Task WaitForProtocolCallbackAsync(CancellationToken ct = default) { + DateTime timeout = DateTime.Now.AddMinutes(15); + while (DateTime.Now < timeout && !ct.IsCancellationRequested) { + OAuthCallback? cb = Utils.PendingOAuthCallback; + if (cb?.Provider == "twitch" && !string.IsNullOrEmpty(cb.AccessToken)) { logger?.LogInformation("Twitch OAuth Callback received"); - var token = cb.AccessToken; + string? token = cb.AccessToken; Utils.PendingOAuthCallback = null; return token; } + await Task.Delay(250, ct); } + return null; } - + [ExcludeFromCodeCoverage] - private async Task InitializeApiAsync() - { - _api = new TwitchAPI - { - Settings = - { + private async Task InitializeApiAsync() { + _api = new TwitchAPI { + Settings = { ClientId = Config.TwitchClientId, AccessToken = AccessToken } }; - var user = (await _api.Helix.Users.GetUsersAsync()).Users.FirstOrDefault(); - if (user != null) - { + User? user = (await _api.Helix.Users.GetUsersAsync()).Users.FirstOrDefault(); + if (user != null) { UserName = user.DisplayName; Login = user.Login; UserId = user.Id; logger?.LogDebug($"Authenticated as {UserName}"); - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "API", Name = UserName!, Status = true }); } - else - { - Login = string.Empty; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + else { + Login = string.Empty; + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "API", Name = "", @@ -211,29 +226,25 @@ private async Task InitializeApiAsync() } } - + [ExcludeFromCodeCoverage] - private async Task InitializeChatAsync() - { + private async Task InitializeChatAsync() { _chatReconnect.Reset(); - var credentials = new ConnectionCredentials(UserName!, $"oauth:{AccessToken}"); + var credentials = new ConnectionCredentials(UserName!, $"oauth:{AccessToken}", disableUsernameCheck: true); _chat = new TwitchClient(); - + _chat.OnMessageReceived += HandleMessageCmdReceived; _chat.OnDisconnected += HandleChatDisconnect; _chat.OnReconnected += HandleChatReconnect; _chat.OnConnected += HandleChatConnect; - - try - { - _chat.Initialize(credentials, channel: UserName); + + try { + _chat.Initialize(credentials, UserName); logger?.LogDebug("[Twitch] Authenticated Chat as {UserName}", UserName); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogError(ex, ex.Message); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "Chat", Name = UserName!, @@ -241,14 +252,12 @@ private async Task InitializeChatAsync() Configured = HasTokenFile() }); } - + await _chat.ConnectAsync(); } - private Task HandleChatConnect(object? sender, OnConnectedEventArgs e) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private Task HandleChatConnect(object? sender, OnConnectedEventArgs e) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "Chat", Name = UserName!, @@ -258,14 +267,11 @@ private Task HandleChatConnect(object? sender, OnConnectedEventArgs e) } [ExcludeFromCodeCoverage] - private Task HandleChatDisconnect(object? sender, OnDisconnectedArgs onDisconnectedArgs) - { - if ((DateTime.Now - _lastChatDisconnectLog).TotalSeconds > 60) - { + private Task HandleChatDisconnect(object? sender, OnDisconnectedArgs onDisconnectedArgs) { + if ((DateTime.Now - _lastChatDisconnectLog).TotalSeconds > 60) { logger?.LogWarning("Twitch Chat Disconnected. Attempting Reconnect..."); - _lastChatDisconnectLog = DateTime.Now; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + _lastChatDisconnectLog = DateTime.Now; + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "Chat", Name = UserName!, @@ -273,46 +279,41 @@ private Task HandleChatDisconnect(object? sender, OnDisconnectedArgs onDisconnec Configured = HasTokenFile() }); } + _ = Task.Run(TryReconnectChatAsync); return Task.CompletedTask; } - - + + [ExcludeFromCodeCoverage] - private async Task TryReconnectChatAsync() - { + private async Task TryReconnectChatAsync() { if (_chat == null) return; if (!await _chatReconnect.Lock.WaitAsync(0)) return; - - try - { + + try { _chatReconnect.Cts?.Cancel(); _chatReconnect.Cts = new CancellationTokenSource(); - var token = _chatReconnect.Cts.Token; - + CancellationToken token = _chatReconnect.Cts.Token; + - while (!token.IsCancellationRequested && _chat.IsConnected == false) - { + while (!token.IsCancellationRequested && !_chat.IsConnected) { _chatReconnect.Retries++; - var delay = _chatReconnect.Backoff; + TimeSpan delay = _chatReconnect.Backoff; logger?.LogDebug( "[Twitch Chat] Reconnect attempt {Attempt} in {Delay}s", _chatReconnect.Retries, delay.TotalSeconds); - - try - { + + try { await Task.Delay(delay, token); - if (_chat.IsConnected) - { + if (_chat.IsConnected) { logger?.LogDebug("Twitch Chat reconnect successful."); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "Chat", Name = UserName!, @@ -323,12 +324,10 @@ private async Task TryReconnectChatAsync() await _chat.ReconnectAsync(); } - catch (OperationCanceledException) - { + catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "Twitch Chat reconnect failed"); } @@ -338,21 +337,18 @@ private async Task TryReconnectChatAsync() _chatReconnect.MaxBackoff.TotalMilliseconds)); } } - finally - { + finally { _chatReconnect.Lock.Release(); } } [ExcludeFromCodeCoverage] - private Task HandleChatReconnect(object? _, OnConnectedEventArgs onConnectedEventArgs) - { + private Task HandleChatReconnect(object? _, OnConnectedEventArgs onConnectedEventArgs) { logger?.LogInformation("Twitch Chat Reconnected"); _chatReconnect.Cts?.Cancel(); _chatReconnect.Reset(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "Chat", Name = UserName!, @@ -360,36 +356,30 @@ private Task HandleChatReconnect(object? _, OnConnectedEventArgs onConnectedEven }); return Task.CompletedTask; } - - - private Task HandleMessageCmdReceived(object? s, OnMessageReceivedArgs e) - { - if (!e.ChatMessage.Channel.Equals(Login, StringComparison.InvariantCultureIgnoreCase) && + + + private Task HandleMessageCmdReceived(object? s, OnMessageReceivedArgs e) { + if (!e.ChatMessage.Channel.Equals(Login, StringComparison.InvariantCultureIgnoreCase) && !e.ChatMessage.Channel.Equals(UserName, StringComparison.InvariantCultureIgnoreCase)) return Task.CompletedTask; - + string message = e.ChatMessage.Message; bool isMod = e.ChatMessage.UserDetail.IsModerator; bool isBroadcaster = e.ChatMessage.IsBroadcaster; bool isVip = e.ChatMessage.UserDetail.IsVip; if (!string.IsNullOrWhiteSpace(message) && message.StartsWith('!')) - { CommandService.ChatCommandRequest(SubathonEventSource.Twitch, message, e.ChatMessage.Username, // DisplayName isBroadcaster, isMod, isVip, DateTime.Now); - } else if (e.ChatMessage.DisplayName.Equals("blerp", StringComparison.InvariantCultureIgnoreCase) && config.GetBool("Extensions", "Blerp.Enabled", true)) - { BlerpChatService.ParseMessage(e.ChatMessage.Message, SubathonEventSource.Twitch); - } return Task.CompletedTask; } - - private async Task InitializeEventSubAsync() - { + + private async Task InitializeEventSubAsync() { _eventSubReconnect.Reset(); _eventSub = new EventSubWebsocketClient(); @@ -412,23 +402,19 @@ private async Task InitializeEventSubAsync() await _eventSub.ConnectAsync(EventSubUrl); } - - private bool IsEventSubConnected() - { + + private bool IsEventSubConnected() { return _eventSub != null && !string.IsNullOrEmpty(_eventSub.SessionId) && _isConnected; } - - private async Task HandleEventSubConnect(object? s, WebsocketConnectedArgs e) - { - bool hasError = false; + + private async Task HandleEventSubConnect(object? s, WebsocketConnectedArgs e) { + var hasError = false; logger?.LogInformation("Connected to EventSub WebSocket, session ID: " - + _eventSub?.SessionId + ", isReconnect: " + e.IsRequestedReconnect); - if (!e.IsRequestedReconnect) - { + + _eventSub?.SessionId + ", isReconnect: " + e.IsRequestedReconnect); + if (!e.IsRequestedReconnect) { // todo allow override from local in case of deprecation, thanks twitch - var eventTypes = new[] - { + var eventTypes = new[] { "stream.offline", "stream.online", "channel.follow", @@ -444,26 +430,21 @@ private async Task HandleEventSubConnect(object? s, WebsocketConnectedArgs e) "channel.charity_campaign.donate" }; - foreach (var type in eventTypes) - { - try - { - - var condition = new Dictionary - { + foreach (string type in eventTypes) + try { + var condition = new Dictionary { { "broadcaster_user_id", UserId! }, { "to_broadcaster_user_id", UserId! }, { "moderator_user_id", UserId! }, { "user_id", UserId! } }; if (_api == null) continue; - var x = await _api.Helix.EventSub.CreateEventSubSubscriptionAsync(type, + CreateEventSubSubscriptionResponse? x = await _api.Helix.EventSub.CreateEventSubSubscriptionAsync( + type, type.Contains("follow") || type.Contains("hype_train") ? "2" : "1", condition, EventSubTransportMethod.Websocket, _eventSub?.SessionId, clientId: Config.TwitchClientId, accessToken: AccessToken); - } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogError(ex, $"Failed to subscribe to {type}: {ex.Message}"); ErrorMessageEvents.RaiseErrorEvent( "ERROR", @@ -473,18 +454,16 @@ private async Task HandleEventSubConnect(object? s, WebsocketConnectedArgs e) RevokeTokenFile(); hasError = true; } - } } + _isConnected = !hasError; - if (_isConnected) - { + if (_isConnected) { _eventSubReconnect.Cts?.Cancel(); _eventSubReconnect.Reset(); } - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "EventSub", Name = UserName!, @@ -495,8 +474,7 @@ private async Task HandleEventSubConnect(object? s, WebsocketConnectedArgs e) [ExcludeFromCodeCoverage] - private Task HandleEventSubReconnect(object? s, WebsocketReconnectedArgs e) - { + private Task HandleEventSubReconnect(object? s, WebsocketReconnectedArgs e) { logger?.LogInformation("Reconnected EventSub WebSocket."); if (_eventSubReconnect.Retries >= 1) ErrorMessageEvents.RaiseErrorEvent("INFO", nameof(SubathonEventSource.Twitch), @@ -505,30 +483,25 @@ private Task HandleEventSubReconnect(object? s, WebsocketReconnectedArgs e) _eventSubReconnect.Reset(); _isConnected = true; if (_chat is { IsConnected: true }) - { // eventsub disconnect can false-disconnect chat sometimes. - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "Chat", Name = UserName!, Status = true }); - } return Task.CompletedTask; } - + [ExcludeFromCodeCoverage] - private Task HandleEventSubDisconnect(object? s, WebsocketDisconnectedArgs e) - { + private Task HandleEventSubDisconnect(object? s, WebsocketDisconnectedArgs e) { logger?.LogWarning("Disconnected EventSub WebSocket."); - + ErrorMessageEvents.RaiseErrorEvent("WARN", nameof(SubathonEventSource.Twitch), "Twitch EventSub has disconnected", DateTime.Now.ToLocalTime()); - + _isConnected = false; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "EventSub", Name = UserName!, @@ -538,27 +511,23 @@ private Task HandleEventSubDisconnect(object? s, WebsocketDisconnectedArgs e) _ = Task.Run(TryReconnectEventSubAsync); return Task.CompletedTask; } - + [ExcludeFromCodeCoverage] - private async Task TryReconnectEventSubAsync() - { + private async Task TryReconnectEventSubAsync() { if (_eventSub == null) return; if (!await _eventSubReconnect.Lock.WaitAsync(0)) return; - - try - { + + try { _eventSubReconnect.Cts?.Cancel(); _eventSubReconnect.Cts = new CancellationTokenSource(); - var token = _eventSubReconnect.Cts.Token; + CancellationToken token = _eventSubReconnect.Cts.Token; - while (!token.IsCancellationRequested && !IsEventSubConnected()) - { + while (!token.IsCancellationRequested && !IsEventSubConnected()) { if (_eventSubReconnect.MaxRetries > 0 && - _eventSubReconnect.Retries >= _eventSubReconnect.MaxRetries) - { + _eventSubReconnect.Retries >= _eventSubReconnect.MaxRetries) { ErrorMessageEvents.RaiseErrorEvent( "ERROR", nameof(SubathonEventSource.Twitch), @@ -569,26 +538,25 @@ private async Task TryReconnectEventSubAsync() return; } - if (!await ValidateTokenAsync()) - { + if (!await ValidateTokenAsync()) { logger?.LogError("EventSub reconnect aborted: Twitch token invalid."); ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.Twitch), - "Twitch EventSub could not be reconnected - Twitch Token is invalid", DateTime.Now.ToLocalTime()); + "Twitch EventSub could not be reconnected - Twitch Token is invalid", + DateTime.Now.ToLocalTime()); RevokeTokenFile(); return; } _eventSubReconnect.Retries++; - var delay = _eventSubReconnect.Backoff; + TimeSpan delay = _eventSubReconnect.Backoff; logger?.LogWarning( "[Twitch EventSub] Reconnect attempt {Attempt} in {Delay}s", _eventSubReconnect.Retries, delay.TotalSeconds); - try - { + try { await Task.Delay(delay, token); if (IsEventSubConnected()) @@ -596,12 +564,10 @@ private async Task TryReconnectEventSubAsync() await _eventSub.ReconnectAsync(); } - catch (OperationCanceledException) - { + catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "EventSub reconnect failed"); } @@ -611,19 +577,15 @@ private async Task TryReconnectEventSubAsync() _eventSubReconnect.MaxBackoff.TotalMilliseconds)); } } - finally - { + finally { _eventSubReconnect.Lock.Release(); } } - private Task HandleChannelOnline(object? s, StreamOnlineArgs e) - { - if (config.GetBool("Twitch", "ResumeOnStart", false)) - { - SubathonEvent subathonEvent = new SubathonEvent - { + private Task HandleChannelOnline(object? s, StreamOnlineArgs e) { + if (config.GetBool("Twitch", "ResumeOnStart")) { + var subathonEvent = new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), Command = SubathonCommandType.Resume, Value = $"{SubathonCommandType.Resume}", @@ -636,10 +598,8 @@ private Task HandleChannelOnline(object? s, StreamOnlineArgs e) SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - if (config.GetBool("Twitch", "UnlockOnStart", false)) - { - SubathonEvent subathonEvent = new SubathonEvent - { + if (config.GetBool("Twitch", "UnlockOnStart")) { + var subathonEvent = new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), Command = SubathonCommandType.Unlock, Value = $"{SubathonCommandType.Unlock}", @@ -655,12 +615,9 @@ private Task HandleChannelOnline(object? s, StreamOnlineArgs e) return Task.CompletedTask; } - private Task HandleChannelOffline(object? s, StreamOfflineArgs e) - { - if (config.GetBool("Twitch", "PauseOnEnd", false)) - { - SubathonEvent subathonEvent = new SubathonEvent - { + private Task HandleChannelOffline(object? s, StreamOfflineArgs e) { + if (config.GetBool("Twitch", "PauseOnEnd")) { + var subathonEvent = new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), Command = SubathonCommandType.Pause, Value = $"{SubathonCommandType.Pause}", @@ -673,10 +630,8 @@ private Task HandleChannelOffline(object? s, StreamOfflineArgs e) SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - if (config.GetBool("Twitch", "LockOnEnd", false)) - { - SubathonEvent subathonEvent = new SubathonEvent - { + if (config.GetBool("Twitch", "LockOnEnd")) { + var subathonEvent = new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), Command = SubathonCommandType.Lock, Value = $"{SubathonCommandType.Lock}", @@ -691,14 +646,12 @@ private Task HandleChannelOffline(object? s, StreamOfflineArgs e) return Task.CompletedTask; } - - private Task HandleChannelFollow(object? s, ChannelFollowArgs e) - { + + private Task HandleChannelFollow(object? s, ChannelFollowArgs e) { var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, EventType = SubathonEventType.TwitchFollow, @@ -710,17 +663,15 @@ private Task HandleChannelFollow(object? s, ChannelFollowArgs e) return Task.CompletedTask; } - - private Task HandleSubGift(object? s, ChannelSubscriptionGiftArgs e) - { + + private Task HandleSubGift(object? s, ChannelSubscriptionGiftArgs e) { var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - var user = e.Payload.Event.UserName; + string? user = e.Payload.Event.UserName; if (e.Payload.Event.IsAnonymous || string.IsNullOrWhiteSpace(user)) user = "Anonymous"; - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, Currency = "sub", @@ -734,16 +685,14 @@ private Task HandleSubGift(object? s, ChannelSubscriptionGiftArgs e) return Task.CompletedTask; } - private Task HandleChannelSubscribe(object? s, ChannelSubscribeArgs e) - { + private Task HandleChannelSubscribe(object? s, ChannelSubscribeArgs e) { if (e.Payload.Event.IsGift) return Task.CompletedTask; var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, Currency = "sub", @@ -756,16 +705,14 @@ private Task HandleChannelSubscribe(object? s, ChannelSubscribeArgs e) return Task.CompletedTask; } - - private Task HandleSubscriptionMsg(object? s, ChannelSubscriptionMessageArgs e) - { + + private Task HandleSubscriptionMsg(object? s, ChannelSubscriptionMessageArgs e) { // int duration = e.Payload.Event.DurationMonths; // Do we want to take this into account and multiply? - no, people can reshare and it is read in var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, Currency = "sub", @@ -777,16 +724,14 @@ private Task HandleSubscriptionMsg(object? s, ChannelSubscriptionMessageArgs e) SubathonEvents.RaiseSubathonEventCreated(subathonEvent); return Task.CompletedTask; } - - private Task HandleBitsUse(object? s, ChannelBitsUseArgs e) - { + + private Task HandleBitsUse(object? s, ChannelBitsUseArgs e) { var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); - var user = e.Payload.Event.UserName; + Guid.TryParse(eventMeta!.MessageId, out Guid mId); + string user = e.Payload.Event.UserName; if (string.IsNullOrWhiteSpace(user)) user = "Anonymous"; if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, EventType = SubathonEventType.TwitchCheer, @@ -797,21 +742,17 @@ private Task HandleBitsUse(object? s, ChannelBitsUseArgs e) }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); if (e.Payload.Event.Type.ToLower() != "cheer") - { logger?.LogInformation($"TwitchCheer Event {subathonEvent.Id} " + - $"source was: {e.Payload.Event.Type} {e.Payload.Event.PowerUp?.Type}"); - } + $"source was: {e.Payload.Event.Type} {e.Payload.Event.PowerUp?.Type}"); return Task.CompletedTask; } - private Task HandleChannelRaid(object? s, ChannelRaidArgs e) - { + private Task HandleChannelRaid(object? s, ChannelRaidArgs e) { var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, EventType = SubathonEventType.TwitchRaid, @@ -823,13 +764,15 @@ private Task HandleChannelRaid(object? s, ChannelRaidArgs e) return Task.CompletedTask; } - private Task HandleHypeTrainBeginV2(object? s, ChannelHypeTrainBeginV2Args e) - { + private void ClearHypeTrainTimeout() { + SimulateHypeTrainEnd(_hypeTrainLevel); + } + + private Task HandleHypeTrainBeginV2(object? s, ChannelHypeTrainBeginV2Args e) { var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, EventType = SubathonEventType.TwitchHypeTrain, @@ -840,18 +783,17 @@ private Task HandleHypeTrainBeginV2(object? s, ChannelHypeTrainBeginV2Args e) }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); _hypeTrainLevel = 1; + timerService?.Register("twitch-hype-train", _hypeTrainLevelDuration, ClearHypeTrainTimeout); return Task.CompletedTask; } - - private Task HandleHypeTrainProgressV2(object? s, ChannelHypeTrainProgressV2Args e) - { + + private Task HandleHypeTrainProgressV2(object? s, ChannelHypeTrainProgressV2Args e) { if (e.Payload.Event.Level <= _hypeTrainLevel) return Task.CompletedTask; var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, EventType = SubathonEventType.TwitchHypeTrain, @@ -862,16 +804,15 @@ private Task HandleHypeTrainProgressV2(object? s, ChannelHypeTrainProgressV2Args }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); _hypeTrainLevel = subathonEvent.Amount; + timerService?.Register("twitch-hype-train", _hypeTrainLevelDuration, ClearHypeTrainTimeout); return Task.CompletedTask; } - - private Task HandleHypeTrainEndV2(object? s, ChannelHypeTrainEndV2Args e) - { + + private Task HandleHypeTrainEndV2(object? s, ChannelHypeTrainEndV2Args e) { var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, EventType = SubathonEventType.TwitchHypeTrain, @@ -882,22 +823,22 @@ private Task HandleHypeTrainEndV2(object? s, ChannelHypeTrainEndV2Args e) }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); _hypeTrainLevel = 0; + + timerService?.Unregister("twitch-hype-train"); return Task.CompletedTask; } - - private Task HandleCharityEvent(object? s, ChannelCharityCampaignDonateArgs e ) - { + + private Task HandleCharityEvent(object? s, ChannelCharityCampaignDonateArgs e) { var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, EventType = SubathonEventType.TwitchCharityDonation, User = e.Payload.Event.UserName, Value = Math.Round( - e.Payload.Event.Amount.Value + e.Payload.Event.Amount.Value / (decimal)Math.Pow(10, e.Payload.Event.Amount.DecimalPlaces), 2 ).ToString("0.00"), @@ -908,43 +849,8 @@ private Task HandleCharityEvent(object? s, ChannelCharityCampaignDonateArgs e ) return Task.CompletedTask; } - public async Task StopAsync(CancellationToken ct = default) - { - // api has no disconnect? - OnTeardown(); - if (_chat != null) await _chat.DisconnectAsync(); - if (_eventSub != null) await _eventSub.DisconnectAsync(); - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { - Source = SubathonEventSource.Twitch, - Service = "API", - Name = UserName ?? "", - Status = false, - Configured = HasTokenFile() - }); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { - Source = SubathonEventSource.Twitch, - Service = "EventSub", - Name = UserName ?? "", - Status = false, - Configured = HasTokenFile() - }); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { - Source = SubathonEventSource.Twitch, - Service = "Chat", - Name = UserName ?? "", - Status = false, - Configured = HasTokenFile() - }); - } - - public static void SimulateRaid(int viewers=50) - { - SubathonEvent subathonEvent = new SubathonEvent - { + public static void SimulateRaid(int viewers = 50) { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TwitchRaid, User = "SYSTEM", @@ -952,11 +858,9 @@ public static void SimulateRaid(int viewers=50) }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - - public static void SimulateCheer(int bitsCount=100) - { - SubathonEvent subathonEvent = new SubathonEvent - { + + public static void SimulateCheer(int bitsCount = 100) { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TwitchCheer, User = "SYSTEM", @@ -966,12 +870,10 @@ public static void SimulateCheer(int bitsCount=100) SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - public static void SimulateSubscription(string tier) - { + public static void SimulateSubscription(string tier) { if (tier != "1000" && tier != "2000" && tier != "3000") return; - - SubathonEvent subathonEvent = new SubathonEvent - { + + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, Currency = "sub", EventType = SubathonEventType.TwitchSub, @@ -982,12 +884,10 @@ public static void SimulateSubscription(string tier) SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - public static void SimulateGiftSubscriptions(string tier, int amount) - { + public static void SimulateGiftSubscriptions(string tier, int amount) { if (tier != "1000" && tier != "2000" && tier != "3000") return; - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, Currency = "sub", EventType = SubathonEventType.TwitchGiftSub, @@ -997,105 +897,86 @@ public static void SimulateGiftSubscriptions(string tier, int amount) Amount = amount }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); - } - public static void SimulateFollow() - { - SubathonEvent subathonEvent = new SubathonEvent - { + } + + public static void SimulateFollow() { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TwitchFollow, - User = "SYSTEM", + User = "SYSTEM" }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - public static void SimulateCharityDonation(string value = "10.00", string currency = "USD") - { - SubathonEvent subathonEvent = new SubathonEvent - { + + public static void SimulateCharityDonation(string value = "10.00", string currency = "USD") { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TwitchCharityDonation, Value = value, Currency = currency, - User = "SYSTEM", + User = "SYSTEM" }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - - public static void SimulateHypeTrainStart() - { - SubathonEvent subathonEvent = new SubathonEvent - { + + public static void SimulateHypeTrainStart() { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TwitchHypeTrain, Value = "start", Amount = 1, - User = "SYSTEM", + User = "SYSTEM" }; _hypeTrainLevel = 1; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - - public static void SimulateHypeTrainProgress(int level = 7) - { + + public static void SimulateHypeTrainProgress(int level = 7) { if (level <= _hypeTrainLevel) return; - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TwitchHypeTrain, Value = "progress", Amount = level, - User = "SYSTEM", + User = "SYSTEM" }; _hypeTrainLevel = level; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - - public static void SimulateHypeTrainEnd(int level = 10) - { - SubathonEvent subathonEvent = new SubathonEvent - { + + public static void SimulateHypeTrainEnd(int level = 10) { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TwitchHypeTrain, Value = "end", Amount = level, - User = "SYSTEM", + User = "SYSTEM" }; _hypeTrainLevel = 0; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { + protected virtual void Dispose(bool disposing) { + if (!_disposed) { + if (disposing) { _chatReconnect.Dispose(); _eventSubReconnect.Dispose(); OnTeardown(); } + _disposed = true; } } - - private void OnTeardown() - { - if (_chat != null) - { + + private void OnTeardown() { + if (_chat != null) { _chat.OnMessageReceived -= HandleMessageCmdReceived; _chat.OnDisconnected -= HandleChatDisconnect; _chat.OnReconnected -= HandleChatReconnect; _chat.OnConnected -= HandleChatConnect; } - if (_eventSub != null) - { + if (_eventSub != null) { _eventSub.WebsocketConnected -= HandleEventSubConnect; _eventSub.WebsocketReconnected -= HandleEventSubReconnect; _eventSub.WebsocketDisconnected -= HandleEventSubDisconnect; @@ -1114,5 +995,4 @@ private void OnTeardown() _eventSub.ChannelCharityCampaignDonate -= HandleCharityEvent; } } -} - +} \ No newline at end of file diff --git a/SubathonManager.Integration/VTSService.cs b/SubathonManager.Integration/VTSService.cs new file mode 100644 index 00000000..c62a707e --- /dev/null +++ b/SubathonManager.Integration/VTSService.cs @@ -0,0 +1,1239 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Net.Sockets; +using System.Net.WebSockets; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; +using SubathonManager.Core.Security; +using SubathonManager.Core.Security.Interfaces; +using SubathonManager.Data; +using VTubeStudio.Client; +using VTubeStudio.Client.Errors; +using VTubeStudio.Client.Events; +using VTubeStudio.Client.Messages; + +namespace SubathonManager.Integration; + +[ExcludeFromCodeCoverage] +public sealed record VtsHotkey(string Id, string Name, string Type, string File, string Description) { + public bool IsExpressionToggle => string.Equals(Type, "ToggleExpression", StringComparison.OrdinalIgnoreCase); +} + +[ExcludeFromCodeCoverage] +public sealed record VtsExpression(string File, string Name, bool Active); + +[ExcludeFromCodeCoverage] +public sealed record VtsParameter( + string Name, + string AddedBy, // may be way to determine? + double Value, + double Min, + double Max, + double DefaultValue, + bool IsCustom); + +public class VTSService( + ILogger? logger, + IConfig config, + ISecureStorage secureStorage, + ITimerService? timerService = null, + IDbContextFactory? dbFactory = null) + : IAppService, IDisposable { + public const string ConfigSection = "VTubeStudio"; + private const string PluginName = "Subathon Manager"; + private const string PluginDeveloper = "WolfwithSword"; + private const string ServiceName = "VTubeStudio"; + + ///////////////// + private const string ParamCurrentPoints = "SMCurrentPoints"; + private const string ParamCurrentMoney = "SMCurrentMoney"; + private const string ParamCurrentGoalValue = "SMCurrentGoalValue"; + private const string ParamUntilNextGoal = "SMUntilNextGoal"; + private const string ParamGoalProgress = "SMGoalProgress"; + private const string ParamMultiplierActive = "SMMultiplierActive"; + private const string ParamWheelSpinActive = "SMWheelSpinActive"; + private const string ParamPromptActive = "SMPromptActive"; + private const string ParamTimerPaused = "SMTimerPaused"; + private const string ParamTimerLocked = "SMTimerLocked"; + + private const string PluginIconResource = "SubathonManager.Integration.assets.icon_128.png"; + private const double ParamMaxValue = 1000000; + + private static readonly Lazy LazyPluginIcon = new(LoadPluginIcon, LazyThreadSafetyMode.PublicationOnly); + + private readonly ConcurrentDictionary _customParameters = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary _heldParameters = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _lastPublished = new(StringComparer.Ordinal); + private readonly ILogger? _logger = logger; + + private readonly Utils.ServiceReconnectState _reconnectState = + new(TimeSpan.FromSeconds(3), 1000, TimeSpan.FromSeconds(30), true); + + private readonly List _subscriptions = []; + + private VTubeStudioClient? _client; + private CancellationTokenSource? _holdCts; + private CancellationTokenSource? _publishCts; + private int _spinGeneration; + private volatile bool _stopRequested; + + [ExcludeFromCodeCoverage] + private List CustomParameterRequests => [ + new() { + ParameterName = ParamCurrentPoints, + Explanation = "Subathon Manager: current points", + Min = 0, + Max = ParamMaxValue, + DefaultValue = 0 + }, + new() { + ParameterName = ParamCurrentMoney, + Explanation = "Subathon Manager: current money total", + Min = 0, + Max = ParamMaxValue, + DefaultValue = 0 + }, + new() { + ParameterName = ParamCurrentGoalValue, + Explanation = "Subathon Manager: value of the goal in progress", + Min = 0, + Max = ParamMaxValue, + DefaultValue = 0 + }, + new() { + ParameterName = ParamUntilNextGoal, + Explanation = "Subathon Manager: amount still needed for the next goal", + Min = 0, + Max = ParamMaxValue, + DefaultValue = 0 + }, + new() { + ParameterName = ParamGoalProgress, + Explanation = "Subathon Manager: percent of the way to the next goal (0-100)", + Min = 0, + Max = 100, + DefaultValue = 0 + }, + Flag(ParamMultiplierActive, "a multiplier is active"), + Flag(ParamWheelSpinActive, "a wheel spin is in progress"), + Flag(ParamPromptActive, "a prompt is running"), + Flag(ParamTimerPaused, "the timer is paused"), + Flag(ParamTimerLocked, "the timer is locked") + ]; + + public bool Connected { get; private set; } + + public string? CurrentModelId { get; private set; } + public string? CurrentModelName { get; private set; } + + public IReadOnlyList CachedHotkeys { get; private set; } = []; + public IReadOnlyList CachedExpressions { get; private set; } = []; + public IReadOnlyList CachedParameters { get; private set; } = []; + + public bool Enabled => config.GetBool(ConfigSection, "Enabled"); + private string Host => (config.Get(ConfigSection, "Host", "localhost") ?? "localhost").Trim(); + private string Port => (config.Get(ConfigSection, "Port", "8001") ?? "8001").Trim(); + + private int PublishIntervalMs => + int.TryParse(config.Get(ConfigSection, "PublishIntervalMs", "500"), out int ms) && ms is >= 100 and <= 900 + ? ms + : 500; + + private int InjectIntervalMs => + int.TryParse(config.Get(ConfigSection, "InjectIntervalMs", "100"), out int ms) && ms >= 20 ? ms : 100; + + public IReadOnlyCollection HeldParameters => _heldParameters.Keys.ToList(); + + internal static string? PluginIconBase64 => LazyPluginIcon.Value; + + public Task StartAsync(CancellationToken ct = default) { + _stopRequested = false; + BroadcastStatus(false); + + if (!Enabled) { + _logger?.LogInformation("[VTSService] Disabled. Integration inactive"); + return Task.CompletedTask; + } + + _ = Task.Run(() => ConnectAsync(ct), ct); + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken ct = default) { + _stopRequested = true; + _reconnectState.Cts?.Cancel(); + await TeardownClientAsync(); + BroadcastStatus(false); + } + + public void Dispose() { + _stopRequested = true; + UnhookParameters(); + StopHoldLoop(); + StopPublishLoop(); + _reconnectState.Dispose(); + GC.SuppressFinalize(this); + } + + [ExcludeFromCodeCoverage] + private static ParameterCreationRequest Flag(string name, string what) { + return new ParameterCreationRequest { + ParameterName = name, + Explanation = $"Subathon Manager: 1 if {what}, otherwise 0", + Min = 0, + Max = 1, + DefaultValue = 0 + }; + } + + private static string? LoadPluginIcon() { + try { + using Stream? stream = typeof(VTSService).Assembly.GetManifestResourceStream(PluginIconResource); + if (stream == null) return null; + + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + return buffer.Length > 0 ? Convert.ToBase64String(buffer.ToArray()) : null; + } + catch { + return null; + } + } + + public event Action? ModelDataChanged; + + [ExcludeFromCodeCoverage] + public async Task RestartAsync(CancellationToken ct = default) { + await StopAsync(ct); + await StartAsync(ct); + } + + public bool SaveConfig(string host, string port, bool enabled, bool forceSave = false) { + var hasUpdated = false; + hasUpdated |= config.Set(ConfigSection, "Host", host); + hasUpdated |= config.Set(ConfigSection, "Port", port); + hasUpdated |= config.SetBool(ConfigSection, "Enabled", enabled); + if (hasUpdated && forceSave) + config.Save(); + return hasUpdated; + } + + public (string host, string port, bool enabled) GetConfig() { + return (Host, Port, Enabled); + } + + public void ClearAuthToken() { + secureStorage.Delete(StorageKeys.VTubeStudioAuthToken); + } + + [ExcludeFromCodeCoverage] + private async Task ConnectAsync(CancellationToken ct) { + if (_stopRequested || !Enabled) return; + + await TeardownClientAsync(); + + VTubeStudioClient? client = null; + try { + var options = new VTubeStudioClientOptions { + Endpoint = new Uri($"ws://{Host}:{Port}"), + PluginName = PluginName, + PluginDeveloper = PluginDeveloper, + PluginIcon = PluginIconBase64 + }; + + client = new VTubeStudioClient(options); + client.Disconnected += OnDisconnected; + if (_logger != null && _logger.IsEnabled(LogLevel.Debug)) + client.EventReceived += OnRawEventReceived; + await client.ConnectAsync(ct); + + string? existingToken = secureStorage.GetOrDefault(StorageKeys.VTubeStudioAuthToken, string.Empty); + if (string.IsNullOrWhiteSpace(existingToken)) { + existingToken = null; + _logger?.LogInformation( + "[VTSService] No stored auth token. Approve the plugin in the VTubeStudio window"); + } + + string token = await client.RequestAndAuthenticateAsync(existingToken, ct); + if (!string.IsNullOrWhiteSpace(token) && !string.Equals(token, existingToken, StringComparison.Ordinal)) + secureStorage.Set(StorageKeys.VTubeStudioAuthToken, token); + + _client = client; + client = null; + Connected = true; + _reconnectState.Reset(); + _reconnectState.Cts?.Cancel(); + + _logger?.LogInformation("[VTSService] Connected and authenticated"); + BroadcastStatus(true); + + await SubscribeToModelEventsAsync(CancellationToken.None); + StartHoldLoop(); + StartPublishLoop(); + await RefreshAsync(CancellationToken.None); + + await SetupParameters(CancellationToken.None); + HookParameters(); + await RefreshPublishedValuesAsync(CancellationToken.None); + } + catch (OperationCanceledException) { + await DiscardAsync(client); + } + catch (VTubeStudioApiException ex) { + await DiscardAsync(client); + + if (ex.ErrorId is VTubeStudioErrorId.TokenRequestDenied + or VTubeStudioErrorId.AuthenticationTokenMissing) { + ClearAuthToken(); + _logger?.LogWarning("[VTSService] Authentication rejected ({ErrorId}). Stored token cleared", + ex.ErrorId); + } + else if (_reconnectState.Retries < 2) { + _logger?.LogWarning("[VTSService] API error during connect: {ErrorId} - {Message}", + ex.ErrorId, ex.ApiMessage); + } + + _ = Task.Run(ReconnectWithBackoffAsync, CancellationToken.None); + } + catch (Exception ex) { + await DiscardAsync(client); + LogConnectFailure(ex); + _ = Task.Run(ReconnectWithBackoffAsync, CancellationToken.None); + } + } + + [ExcludeFromCodeCoverage] + private async Task SetupParameters(CancellationToken ct = default) { + if (_client == null) return; + + foreach (ParameterCreationRequest customParameter in CustomParameterRequests) + try { + ParameterCreationResponse res = await _client.CreateParameterAsync(customParameter, ct); + if (string.IsNullOrWhiteSpace(res.ParameterName)) { + _logger?.LogWarning("[VTSService] VTube Studio refused to create {Parameter}", + customParameter.ParameterName); + continue; + } + + SeedCustomParameter(res.ParameterName); + _logger?.LogDebug("[VTSService] Custom parameter ready: {Parameter}", res.ParameterName); + } + catch (Exception ex) { + _logger?.LogWarning("[VTSService] Could not create custom parameter {Parameter}: {Reason}", + customParameter.ParameterName, ParseException(ex)); + } + + _logger?.LogInformation("[VTSService] {Count} custom parameter(s) available", _customParameters.Count); + } + + [ExcludeFromCodeCoverage] + private void HookParameters() { + SubathonEvents.SubathonDataUpdate += OnSubathonDataUpdate; + SubathonEvents.SubathonGoalListUpdated += OnGoalListUpdated; + SubathonEvents.SubathonGoalCompleted += OnGoalCompleted; + SubathonEvents.PromptRunStarted += OnPromptRunChanged; + SubathonEvents.PromptRunUpdate += OnPromptRunChanged; + WheelEvents.WheelSpinStarted += OnWheelSpinStarted; + WheelEvents.WheelSpinResult += OnWheelSpinResult; + } + + private void UnhookParameters() { + SubathonEvents.SubathonDataUpdate -= OnSubathonDataUpdate; + SubathonEvents.SubathonGoalListUpdated -= OnGoalListUpdated; + SubathonEvents.SubathonGoalCompleted -= OnGoalCompleted; + SubathonEvents.PromptRunStarted -= OnPromptRunChanged; + SubathonEvents.PromptRunUpdate -= OnPromptRunChanged; + WheelEvents.WheelSpinStarted -= OnWheelSpinStarted; + WheelEvents.WheelSpinResult -= OnWheelSpinResult; + } + + [ExcludeFromCodeCoverage] + private void OnRawEventReceived(object? sender, VTubeStudioEventArgs e) { + _logger?.LogDebug("[VTSService] Event received from VTube Studio: {EventName}", e.EventName); + } + + [ExcludeFromCodeCoverage] + private async Task DiscardAsync(VTubeStudioClient? client) { + if (client == null) return; + UnhookParameters(); + client.Disconnected -= OnDisconnected; + if (_logger != null && _logger.IsEnabled(LogLevel.Debug)) + client.EventReceived -= OnRawEventReceived; + await SafeDisposeAsync(client); + } + + [ExcludeFromCodeCoverage] + private void LogConnectFailure(Exception ex) { + int retries = _reconnectState.Retries; + + if (IsExpectedConnectFailure(ex)) { + if (retries < 2) + _logger?.LogWarning( + "[VTSService] Cannot reach VTube Studio at {Host}:{Port} ({Reason}). Is it running with the " + + "Plugin API started? Retrying in the background", Host, Port, ParseException(ex)); + else if (retries % 10 == 0) + _logger?.LogTrace("[VTSService] Still cannot reach VTube Studio at {Host}:{Port} (attempt {N})", + Host, Port, retries); + return; + } + + if (retries < 2) + _logger?.LogWarning(ex, "[VTSService] Connection attempt failed"); + else if (retries % 10 == 0) + _logger?.LogDebug("[VTSService] Connection attempt failed (attempt {N}): {Reason}", + retries, ParseException(ex)); + } + + [ExcludeFromCodeCoverage] + private static bool IsExpectedConnectFailure(Exception ex) { + for (Exception? e = ex; e != null; e = e.InnerException) + if (e is WebSocketException or SocketException or IOException or HttpRequestException + or TimeoutException or OperationCanceledException) + return true; + + return false; + } + + [ExcludeFromCodeCoverage] + private static string ParseException(Exception ex) { + Exception root = ex; + while (root.InnerException != null) root = root.InnerException; + return root is SocketException socket ? socket.SocketErrorCode.ToString() : root.Message; + } + + [ExcludeFromCodeCoverage] + private void OnDisconnected(object? sender, EventArgs e) { + if (!Connected) return; + Connected = false; + StopHoldLoop(); + StopPublishLoop(); + BroadcastStatus(false); + + if (_stopRequested) return; + if (_reconnectState.Retries < 2) + _logger?.LogWarning("[VTSService] Disconnected from VTube Studio. Retrying in the background"); + _ = Task.Run(ReconnectWithBackoffAsync, CancellationToken.None); + } + + [ExcludeFromCodeCoverage] + private async Task ReconnectWithBackoffAsync() { + if (!await _reconnectState.Lock.WaitAsync(0)) return; + + try { + _reconnectState.Cts?.Cancel(); + _reconnectState.Cts = new CancellationTokenSource(); + CancellationToken token = _reconnectState.Cts.Token; + + while (!token.IsCancellationRequested && !Connected && !_stopRequested && Enabled) { + if (!_reconnectState.InfiniteRetries && _reconnectState.Retries >= _reconnectState.MaxRetries) { + _logger?.LogError("[VTSService] Max reconnect retries reached"); + return; + } + + _reconnectState.Retries++; + TimeSpan delay = _reconnectState.Backoff; + + if (_reconnectState.Retries < 3 || _reconnectState.Retries % 60 == 0) + _logger?.LogDebug("[VTSService] Reconnect attempt {N} in {Delay}s", + _reconnectState.Retries, delay.TotalSeconds); + + try { + await Task.Delay(delay, token); + if (!Connected && !_stopRequested) + await ConnectAsync(token); + } + catch (OperationCanceledException) { + return; + } + catch (Exception ex) { + if (_reconnectState.Retries < 2) + _logger?.LogWarning(ex, "[VTSService] Reconnect error"); + else + _logger?.LogDebug("[VTSService] Reconnect error: {Reason}", ParseException(ex)); + } + + _reconnectState.Backoff = TimeSpan.FromMilliseconds( + Math.Min( + _reconnectState.Backoff.TotalMilliseconds * 2, + _reconnectState.MaxBackoff.TotalMilliseconds)); + } + } + finally { + _reconnectState.Lock.Release(); + } + } + + private async Task TeardownClientAsync() { + StopHoldLoop(); + StopPublishLoop(); + + foreach (IDisposable sub in _subscriptions) + try { + sub.Dispose(); + } + catch { + /**/ + } + + _subscriptions.Clear(); + _heldParameters.Clear(); + + VTubeStudioClient? client = Interlocked.Exchange(ref _client, null); + Connected = false; + CurrentModelId = null; + CurrentModelName = null; + CachedHotkeys = []; + CachedExpressions = []; + CachedParameters = []; + + if (client == null) return; + UnhookParameters(); + client.Disconnected -= OnDisconnected; + if (_logger != null && _logger.IsEnabled(LogLevel.Debug)) + client.EventReceived -= OnRawEventReceived; + await SafeDisposeAsync(client); + } + + [ExcludeFromCodeCoverage] + private async Task SafeDisposeAsync(VTubeStudioClient client) { + try { + await client.DisposeAsync(); + } + catch (Exception ex) { + _logger?.LogDebug("[VTSService] Error disposing client: {Reason}", ParseException(ex)); + } + } + + [ExcludeFromCodeCoverage] + private async Task SubscribeToModelEventsAsync(CancellationToken ct) { + VTubeStudioClient? client = _client; + if (client == null) return; + + try { + _subscriptions.Add(client.Events.On(OnModelLoaded)); + _subscriptions.Add(client.Events.On(OnModelConfigChanged)); + + await client.SubscribeAsync(ct: ct); + + EventSubscriptionResponse confirmed = + await client.SubscribeAsync(ct: ct); + + _logger?.LogInformation("[VTSService] Subscribed to model events. VTubeStudio confirms {Count}: {Events}", + confirmed.SubscribedEventCount, string.Join(", ", confirmed.SubscribedEvents)); + } + catch (Exception ex) when (IsExpectedConnectFailure(ex)) { + _logger?.LogDebug("[VTSService] Could not subscribe to model events: {Reason}", ParseException(ex)); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to subscribe to model events"); + } + } + + [ExcludeFromCodeCoverage] + private void OnModelLoaded(ModelLoadedEventPayload payload) { + _logger?.LogInformation("[VTSService] Model loaded event: {Model} (loaded: {Loaded})", + payload.ModelName ?? "none", payload.ModelLoaded); + + _ = Task.Run(() => ApplyModelChangeAsync( + payload.ModelLoaded ? payload.ModelId : null, + payload.ModelLoaded ? payload.ModelName : null, + CancellationToken.None)); + } + + [ExcludeFromCodeCoverage] + private void OnModelConfigChanged(ModelConfigChangedEventPayload payload) { + _logger?.LogInformation("[VTSService] Model config changed event: {Model}", payload.ModelName ?? "none"); + _ = Task.Run(() => RefreshAsync()); + } + + private void BroadcastStatus(bool status) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { + Source = SubathonEventSource.VTubeStudio, + Service = ServiceName, + Name = "WebSocket", + Status = status, + Configured = Enabled + }); + } + + [ExcludeFromCodeCoverage] + public async Task RefreshAsync(CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected) return false; + + try { + CurrentModelResponse model = await client.GetCurrentModelAsync(ct); + CurrentModelId = model.ModelLoaded ? model.ModelId : null; + CurrentModelName = model.ModelLoaded ? model.ModelName : null; + + CachedHotkeys = await GetHotkeysAsync(ct); + CachedExpressions = await GetExpressionsAsync(ct); + CachedParameters = await GetParametersAsync(ct); + + _logger?.LogDebug( + "[VTSService] Refreshed {Model}: {Hotkeys} hotkeys, {Expressions} expressions, {Parameters} parameters", + CurrentModelName ?? "no model", CachedHotkeys.Count, CachedExpressions.Count, CachedParameters.Count); + + ModelDataChanged?.Invoke(); + return true; + } + catch (Exception ex) when (IsExpectedConnectFailure(ex)) { + _logger?.LogDebug("[VTSService] Refresh skipped: {Reason}", ParseException(ex)); + return false; + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Refresh failed"); + return false; + } + } + + [ExcludeFromCodeCoverage] + public async Task> GetHotkeysAsync(CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected) return []; + + try { + HotkeysInCurrentModelResponse response = + await client.GetHotkeysAsync(new HotkeysInCurrentModelRequest(), ct); + if (!response.ModelLoaded) return []; + + return response.AvailableHotkeys + .Select(h => + new VtsHotkey(h.HotkeyId, h.Name, h.Type, h.File ?? string.Empty, h.Description ?? string.Empty)) + .ToList(); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to list hotkeys"); + return []; + } + } + + [ExcludeFromCodeCoverage] + public async Task> GetExpressionsAsync(CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected) return []; + + try { + ExpressionStateResponse response = + await client.GetExpressionStateAsync(new ExpressionStateRequest { Details = false }, ct); + if (!response.ModelLoaded) return []; + + return response.Expressions + .Select(e => new VtsExpression(e.File, e.Name, e.Active)) + .ToList(); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to list expressions"); + return []; + } + } + + [ExcludeFromCodeCoverage] + public async Task> GetParametersAsync(CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected) return []; + + try { + InputParameterListResponse response = await client.GetInputParametersAsync(ct); + if (!response.ModelLoaded) return []; + + List parameters = response.DefaultParameters + .Select(p => Map(p, false)) + .ToList(); + + parameters.AddRange(response.CustomParameters.Select(p => Map(p, true))); + return parameters; + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to list input parameters"); + return []; + } + + static VtsParameter Map(ParameterInfo p, bool isCustom) { + return new VtsParameter(p.Name, p.AddedBy ?? "", p.Value, p.Min, p.Max, p.DefaultValue, isCustom); + } + } + + [ExcludeFromCodeCoverage] + public async Task> GetLive2DParametersAsync(CancellationToken ct = default) { + // unused, but future scope? + VTubeStudioClient? client = _client; + if (client == null || !Connected) return []; + + try { + Live2DParameterListResponse response = await client.GetLive2DParametersAsync(ct); + if (!response.ModelLoaded) return []; + + return response.Parameters + .Select(p => new VtsParameter(p.Name, p.AddedBy ?? "", p.Value, p.Min, p.Max, p.DefaultValue, false)) + .ToList(); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to list Live2D parameters"); + return []; + } + } + + [ExcludeFromCodeCoverage] + public async Task GetParameterValueAsync(string parameterName, CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected || string.IsNullOrWhiteSpace(parameterName)) return null; + + try { + ParameterInfo info = await client.GetParameterValueAsync( + new ParameterValueRequest { Name = parameterName }, ct); + return info.Value; + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to read parameter {Parameter}", parameterName); + return null; + } + } + + [ExcludeFromCodeCoverage] + public async Task GetExpressionStateAsync(string expressionFile, CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected || string.IsNullOrWhiteSpace(expressionFile)) return null; + + try { + ExpressionStateResponse response = await client.GetExpressionStateAsync( + new ExpressionStateRequest { Details = false, ExpressionFile = expressionFile }, ct); + + ExpressionInfo? match = response.Expressions + .FirstOrDefault(e => string.Equals(e.File, expressionFile, StringComparison.OrdinalIgnoreCase)); + return match?.Active; + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to read expression state {Expression}", expressionFile); + return null; + } + } + + [ExcludeFromCodeCoverage] + public async Task TriggerHotkeyAsync(string hotkeyId, string? itemInstanceId = null, + CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected || string.IsNullOrWhiteSpace(hotkeyId)) return false; + + try { + await client.TriggerHotkeyAsync(new HotkeyTriggerRequest { + HotkeyId = hotkeyId, + ItemInstanceId = itemInstanceId + }, ct); + return true; + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to trigger hotkey {Hotkey}", hotkeyId); + return false; + } + } + + [ExcludeFromCodeCoverage] + public async Task SetExpressionStateAsync(string expressionFile, bool active, + CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected || string.IsNullOrWhiteSpace(expressionFile)) return false; + + try { + await client.SetExpressionAsync(new ExpressionActivationRequest { + ExpressionFile = expressionFile, + Active = active + }, ct); + return true; + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to set expression {Expression} to {Active}", + expressionFile, active); + return false; + } + } + + [ExcludeFromCodeCoverage] + public async Task ApplyExpressionActionAsync(string expressionFile, VtsToggleAction action, + CancellationToken ct = default) { + if (action == VtsToggleAction.DoNothing) return true; + if (!Connected || string.IsNullOrWhiteSpace(expressionFile)) return false; + + bool active; + if (action == VtsToggleAction.Toggle) { + bool? current = await GetExpressionStateAsync(expressionFile, ct); + if (current == null) return false; + active = !current.Value; + } + else { + active = action == VtsToggleAction.On; + } + + return await SetExpressionStateAsync(expressionFile, active, ct); + } + + [ExcludeFromCodeCoverage] + public async Task SetParameterValueAsync(string parameterName, double value, double? weight = null, + bool hold = true, CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected || string.IsNullOrWhiteSpace(parameterName)) return false; + + var injection = new ParameterValue { Id = parameterName, Value = value, Weight = weight }; + if (hold) _heldParameters[parameterName] = injection; + + try { + await InjectAsync(client, [injection], ct); + return true; + } + catch (Exception ex) { + if (hold) _heldParameters.TryRemove(parameterName, out _); + _logger?.LogWarning(ex, "[VTSService] Failed to set parameter {Parameter}", parameterName); + return false; + } + } + + public bool ReleaseParameter(string parameterName) { + return _heldParameters.TryRemove(parameterName, out _); + } + + public void ReleaseAllParameters() { + _heldParameters.Clear(); + } + + public bool IsParameterHeld(string parameterName) { + return _heldParameters.ContainsKey(parameterName); + } + + [ExcludeFromCodeCoverage] + private static Task InjectAsync(VTubeStudioClient? client, ICollection values, + CancellationToken ct = default) { + if (client is not { IsConnected: true }) return Task.CompletedTask; + + IReadOnlyList list = values.ToList(); + return client.InjectParameterDataAsync(new InjectParameterDataRequest { + Mode = "set", + FaceFound = false, + ParameterValues = list + }, ct); + } + + [ExcludeFromCodeCoverage] + private void StartPublishLoop() { + StopPublishLoop(); + var cts = new CancellationTokenSource(); + _publishCts = cts; + _ = Task.Run(() => RunPublishLoopAsync(cts.Token), cts.Token); + } + + private void StopPublishLoop() { + CancellationTokenSource? cts = Interlocked.Exchange(ref _publishCts, null); + + try { + cts?.Cancel(); + } + catch { + /**/ + } + } + + [ExcludeFromCodeCoverage] + private async Task RunPublishLoopAsync(CancellationToken ct) { + var reportedFailure = false; + + while (!ct.IsCancellationRequested) { + try { + await Task.Delay(PublishIntervalMs, ct); + } + catch (OperationCanceledException) { + return; + } + + if (!Connected || _client == null || _customParameters.IsEmpty) continue; + + try { + await InjectCustomParametersAsync(ct); + reportedFailure = false; + } + catch (OperationCanceledException) { + return; + } + catch (Exception ex) { + if (reportedFailure) continue; + reportedFailure = true; + _logger?.LogDebug("[VTSService] Custom parameter keepalive failed: {Reason}", ParseException(ex)); + } + } + } + + [ExcludeFromCodeCoverage] + private void StartHoldLoop() { + StopHoldLoop(); + var cts = new CancellationTokenSource(); + _holdCts = cts; + _ = Task.Run(() => RunHoldLoopAsync(cts.Token), cts.Token); + } + + private void StopHoldLoop() { + CancellationTokenSource? cts = Interlocked.Exchange(ref _holdCts, null); + + try { + cts?.Cancel(); + } + catch { + /**/ + } + } + + [ExcludeFromCodeCoverage] + private async Task RunHoldLoopAsync(CancellationToken ct) { + var reportedFailure = false; + + while (!ct.IsCancellationRequested) { + try { + await Task.Delay(InjectIntervalMs, ct); + } + catch (OperationCanceledException) { + return; + } + + VTubeStudioClient? client = _client; + if (client == null || !Connected || _heldParameters.IsEmpty) continue; + + try { + await InjectAsync(client, _heldParameters.Values.ToList(), ct); + reportedFailure = false; + } + catch (OperationCanceledException) { + return; + } + catch (Exception ex) { + if (reportedFailure) continue; + reportedFailure = true; + _logger?.LogDebug("[VTSService] Parameter hold injection failed: {Reason}", ParseException(ex)); + } + } + } + + [ExcludeFromCodeCoverage] + private async Task ApplyModelChangeAsync(string? modelId, string? modelName, CancellationToken ct) { + if (string.Equals(modelId, CurrentModelId, StringComparison.Ordinal)) return; + + _logger?.LogInformation("[VTSService] Model changed: {Previous} -> {Current}", + CurrentModelName ?? "none", modelName ?? "none"); + + CurrentModelId = modelId; + CurrentModelName = modelName; + _heldParameters.Clear(); + await RefreshAsync(ct); + } + + public async Task ExecuteWheelActionAsync(VTSWheelAction action, CancellationToken ct = default) { + if (!Connected) { + _logger?.LogInformation( + "[VTSService] Wheel action skipped: not connected to VTube Studio. Leaving the spin pending"); + return false; + } + + if (!action.IsValid(out string invalid)) { + _logger?.LogWarning("[VTSService] Wheel action is not valid: {Error}", invalid); + return false; + } + + switch (action.Kind) { + case VtsTargetKind.Expression: + return await RunExpressionWheelActionAsync(action, ct); + case VtsTargetKind.Parameter: + return await RunParameterWheelActionAsync(action, ct); + case VtsTargetKind.Hotkey: + return await RunHotkeyWheelActionAsync(action, ct); + default: + return false; + } + } + + [ExcludeFromCodeCoverage] + private async Task RunExpressionWheelActionAsync(VTSWheelAction action, CancellationToken ct) { + if (!await TargetExistsAsync(action, ct)) return false; + if (!await ApplyExpressionActionAsync(action.Target, action.ToggleAction, ct)) return false; + ScheduleRevert(action, null); + return true; + } + + [ExcludeFromCodeCoverage] + private async Task RunParameterWheelActionAsync(VTSWheelAction action, CancellationToken ct) { + double? original = await GetParameterValueAsync(action.Target, ct); + if (original == null) { + _logger?.LogWarning( + "[VTSService] Wheel action target parameter \"{Parameter}\" not found on the current model", + action.Target); + return false; + } + + if (!await SetParameterValueAsync(action.Target, action.Value, ct: ct)) return false; + ScheduleRevert(action, original); + return true; + } + + [ExcludeFromCodeCoverage] + private async Task RunHotkeyWheelActionAsync(VTSWheelAction action, CancellationToken ct) { + if (!await TargetExistsAsync(action, ct)) return false; + if (!await TriggerHotkeyAsync(action.Target, ct: ct)) return false; + ScheduleRevert(action, null); + return true; + } + + [ExcludeFromCodeCoverage] + private async Task TargetExistsAsync(VTSWheelAction action, CancellationToken ct) { + if (Matches()) return true; + + await RefreshAsync(ct); + if (Matches()) return true; + + _logger?.LogWarning( + "[VTSService] Wheel action target {Kind} \"{Target}\" is not on the current model ({Model})", + action.Kind, action.Target, CurrentModelName ?? "none"); + return false; + + bool Matches() { + return action.Kind switch { + VtsTargetKind.Expression => CachedExpressions.Any(e => + string.Equals(e.File, action.Target, StringComparison.OrdinalIgnoreCase)), + VtsTargetKind.Hotkey => CachedHotkeys.Any(h => + string.Equals(h.Id, action.Target, StringComparison.OrdinalIgnoreCase)), + VtsTargetKind.Parameter => CachedParameters.Any(p => + string.Equals(p.Name, action.Target, StringComparison.Ordinal)), + _ => false + }; + } + } + + [ExcludeFromCodeCoverage] + private void ScheduleRevert(VTSWheelAction action, double? originalValue) { + if (!action.HasRevert) return; + + if (timerService == null) { + _logger?.LogWarning("[VTSService] No timer service available; skipping the wheel action revert."); + return; + } + + string key = action.TimerKey; + timerService.Register(key, action.Duration, async revertCt => { + timerService.Unregister(key); + await RevertWheelActionAsync(action, originalValue, revertCt); + }); + + _logger?.LogInformation("[VTSService] Wheel action revert for {Target} scheduled in {Duration}", + action.Target, action.Duration); + } + + [ExcludeFromCodeCoverage] + private async Task RevertWheelActionAsync(VTSWheelAction action, double? originalValue, CancellationToken ct) { + if (!Connected) { + _logger?.LogInformation("[VTSService] Skipping wheel action revert for {Target}: not connected.", + action.Target); + return; + } + + switch (action.Kind) { + case VtsTargetKind.Expression: + await ApplyExpressionActionAsync(action.Target, action.AfterToggle, ct); + break; + + case VtsTargetKind.Parameter: + switch (action.AfterParameter) { + case VtsParameterAfterAction.DoNothing: + ReleaseParameter(action.Target); + break; + case VtsParameterAfterAction.ResetToOriginal: + ReleaseParameter(action.Target); + if (originalValue.HasValue) + await SetParameterValueAsync(action.Target, originalValue.Value, hold: false, ct: ct); + break; + case VtsParameterAfterAction.SetNewValue: + ReleaseParameter(action.Target); + await SetParameterValueAsync(action.Target, action.AfterValue, hold: false, ct: ct); + break; + } + + break; + + case VtsTargetKind.Hotkey: + if (action.AfterHotkey == VtsHotkeyAfterAction.TriggerAgain) + await TriggerHotkeyAsync(action.Target, ct: ct); + break; + } + } + + [ExcludeFromCodeCoverage] + private void OnSubathonDataUpdate(SubathonData data, DateTime timestamp) { + _ = Task.Run(() => RefreshPublishedValuesAsync(CancellationToken.None)); + } + + [ExcludeFromCodeCoverage] + private void OnGoalListUpdated(List goals, long currentValue, GoalsType type) { + UpdateGoalValues(goals, currentValue); + _ = Task.Run(() => PublishCustomParametersAsync(CancellationToken.None)); + } + + [ExcludeFromCodeCoverage] + private void OnGoalCompleted(SubathonGoal goal, long currentValue) { + _ = Task.Run(() => RefreshPublishedValuesAsync(CancellationToken.None)); + } + + [ExcludeFromCodeCoverage] + private async Task RefreshPublishedValuesAsync(CancellationToken ct) { + if (!Connected) return; + if (dbFactory == null) { + _logger?.LogDebug("[VTSService] No database factory; cannot read subathon values to publish"); + return; + } + + try { + await using AppDbContext db = await dbFactory.CreateDbContextAsync(ct); + + SubathonData? subathon = await db.SubathonDatas + .Include(x => x.Multiplier) + .AsNoTracking() + .FirstOrDefaultAsync(x => x.IsActive, ct); + if (subathon == null) return; + + SetCustomParameter(ParamCurrentPoints, Math.Max(0, subathon.Points)); + SetCustomParameter(ParamCurrentMoney, Math.Max(0, subathon.GetRoundedMoneySumWithCents())); + SetCustomFlag(ParamMultiplierActive, subathon.Multiplier?.IsRunning() ?? false); + SetCustomFlag(ParamTimerPaused, subathon.IsPaused); + SetCustomFlag(ParamTimerLocked, subathon.IsLocked); + + bool promptRunning = await db.SubathonPromptRuns + .AsNoTracking() + .AnyAsync(r => r.Status == SubathonPromptRunStatus.Active, ct); + SetCustomFlag(ParamPromptActive, promptRunning); + + SubathonGoalSet? goalSet = await db.SubathonGoalSets + .AsNoTracking() + .Include(g => g.Goals) + .FirstOrDefaultAsync(g => g.IsActive, ct); + + long currentValue = goalSet?.Type == GoalsType.Money ? subathon.GetRoundedMoneySum() : subathon.Points; + UpdateGoalValues(goalSet?.Goals, currentValue); + } + catch (Exception ex) { + _logger?.LogDebug("[VTSService] Could not refresh published values: {Reason}", ParseException(ex)); + } + + await PublishCustomParametersAsync(ct); + } + + [ExcludeFromCodeCoverage] + private async Task PublishCustomParametersAsync(CancellationToken ct) { + if (!Connected || _client == null || _customParameters.IsEmpty) return; + if (!HasCustomParameterChanged(_customParameters.Values.ToList())) return; + + try { + await InjectCustomParametersAsync(ct); + } + catch (Exception ex) { + _logger?.LogDebug("[VTSService] Could not publish custom parameters: {Reason}", ParseException(ex)); + } + } + + [ExcludeFromCodeCoverage] + private async Task InjectCustomParametersAsync(CancellationToken ct) { + List snapshot = _customParameters.Values.ToList(); + if (snapshot.Count == 0) return; + + await InjectAsync(_client, snapshot, ct); + foreach (ParameterValue value in snapshot) _lastPublished[value.Id] = value.Value; + } + + [ExcludeFromCodeCoverage] + private bool HasCustomParameterChanged(List snapshot) { + foreach (ParameterValue value in snapshot) { + if (!_lastPublished.TryGetValue(value.Id, out double previous)) return true; + if (Math.Abs(previous - value.Value) > 0.0001) return true; + } + + return false; + } + + [ExcludeFromCodeCoverage] + private void UpdateGoalValues(IEnumerable? goals, long currentValue) { + if (!Connected || _client is not { IsConnected: true }) return; + + List ordered = (goals ?? []).OrderBy(g => g.Points).ToList(); + + if (ordered.Count == 0) { + SetCustomParameter(ParamCurrentGoalValue, 0); + SetCustomParameter(ParamUntilNextGoal, 0); + SetCustomParameter(ParamGoalProgress, 0); + return; + } + + SubathonGoal? next = ordered.FirstOrDefault(g => g.Points > currentValue); + if (next == null) { + SetCustomParameter(ParamGoalProgress, 100); + return; + } + + SetCustomParameter(ParamCurrentGoalValue, Math.Max(0, next.Points)); + SetCustomParameter(ParamUntilNextGoal, Math.Max(0, next.Points - currentValue)); + + long previous = ordered.Where(g => g.Points <= currentValue) + .Select(g => g.Points) + .DefaultIfEmpty(0) + .Max(); + + long span = next.Points - previous; + double progress = span <= 0 ? 100 : (currentValue - previous) / (double)span * 100; + SetCustomParameter(ParamGoalProgress, Math.Clamp(progress, 0, 100)); + } + + [ExcludeFromCodeCoverage] + private void SetCustomFlag(string parameterName, bool active) { + SetCustomParameter(parameterName, active ? 1 : 0); + } + + [ExcludeFromCodeCoverage] + private void OnPromptRunChanged(SubathonPromptRun run, SubathonPrompt? prompt) { + SetCustomFlag(ParamPromptActive, run.IsActive); + _ = Task.Run(() => PublishCustomParametersAsync(CancellationToken.None)); + } + + [ExcludeFromCodeCoverage] + private void OnWheelSpinStarted(WheelSet wheel, int delaySeconds) { + int generation = Interlocked.Increment(ref _spinGeneration); + SetCustomFlag(ParamWheelSpinActive, true); + _ = Task.Run(() => PublishCustomParametersAsync(CancellationToken.None)); + + _ = Task.Run(async () => { + await Task.Delay(TimeSpan.FromSeconds(Math.Max(0, delaySeconds) + 5)); + if (Volatile.Read(ref _spinGeneration) != generation) return; + ClearWheelSpinFlag(); + }); + } + + [ExcludeFromCodeCoverage] + private void OnWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int spinsOwed) { + Interlocked.Increment(ref _spinGeneration); + ClearWheelSpinFlag(); + } + + [ExcludeFromCodeCoverage] + private void ClearWheelSpinFlag() { + SetCustomFlag(ParamWheelSpinActive, false); + _ = Task.Run(() => PublishCustomParametersAsync(CancellationToken.None)); + } + + [ExcludeFromCodeCoverage] + private void SetCustomParameter(string parameterName, double value) { + if (!_customParameters.ContainsKey(parameterName)) return; + + _customParameters[parameterName] = new ParameterValue { Id = parameterName, Value = value }; + } + + [ExcludeFromCodeCoverage] + private void SeedCustomParameter(string parameterName) { + _customParameters.TryAdd(parameterName, new ParameterValue { Id = parameterName, Value = 0 }); + } +} \ No newline at end of file diff --git a/SubathonManager.Integration/YouTubeService.cs b/SubathonManager.Integration/YouTubeService.cs index 169e2f11..4caf0569 100644 --- a/SubathonManager.Integration/YouTubeService.cs +++ b/SubathonManager.Integration/YouTubeService.cs @@ -1,9 +1,5 @@ -using YTLiveChat.Contracts; -using YTLiveChat.Contracts.Models; -using YTLiveChat.Contracts.Services; -using YTLiveChat.Services; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; -using System.Diagnostics.CodeAnalysis; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -11,81 +7,86 @@ using SubathonManager.Core.Models; using SubathonManager.Core.Objects; using SubathonManager.Services; +using YTLiveChat.Contracts; +using YTLiveChat.Contracts.Models; +using YTLiveChat.Contracts.Services; +using YTLiveChat.Services; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; -public class YouTubeService : IDisposable, IAppService -{ - private int _pollTime = 1500; +public class YouTubeService : IDisposable, IAppService { + private readonly IConfig _config; private readonly HttpClient _httpClient; + + private readonly ILogger? _logger; + + private readonly Utils.ServiceReconnectState _reconnectState = + new(TimeSpan.FromSeconds(5), 100, TimeSpan.FromMinutes(2)); + private readonly IYTLiveChat _ytLiveChat; - private bool _disposed = false; + + private int _canonicalLinkErrorCount; + private bool _disposed; + private readonly int _pollTime = 1500; private string? _ytHandle; public bool Running; - private readonly Utils.ServiceReconnectState _reconnectState = - new(TimeSpan.FromSeconds(5), maxRetries: 100, maxBackoff: TimeSpan.FromMinutes(2)); - - private readonly ILogger? _logger; - private readonly IConfig _config; - - private int _canonicalLinkErrorCount = 0; - - public YouTubeService(ILogger? logger, IConfig config, ILogger? httpClientLogger, ILogger? chatLogger) - { + public YouTubeService(ILogger? logger, IConfig config, ILogger? httpClientLogger, + ILogger? chatLogger) { #pragma warning disable CS0618 - + _logger = logger; _config = config; - var options = new YTLiveChatOptions - { + var options = new YTLiveChatOptions { RequestFrequency = _pollTime, EnableContinuousLivestreamMonitor = true, // having upcoming streams will report as true LiveCheckFrequency = 10 * 1000 }; - - _httpClient ??= new HttpClient() - { + + _httpClient ??= new HttpClient { BaseAddress = new Uri(options.YoutubeBaseUrl) }; - + var ytHttpClient = new YTHttpClient(_httpClient, httpClientLogger); _ytLiveChat = new YTLiveChat.Services.YTLiveChat(options, ytHttpClient, chatLogger); - + _ytLiveChat.InitialPageLoaded += OnInitialPageLoaded; _ytLiveChat.ChatReceived += OnChatReceived; _ytLiveChat.ChatStopped += OnChatStopped; _ytLiveChat.ErrorOccurred += OnErrorOccurred; //_ytLiveChat.LivestreamStarted += OnStreamStart; _ytLiveChat.BannerAdded += OnBanner; - _ytLiveChat.LivestreamEnded += OnStreamEnd; - + _ytLiveChat.LivestreamEnded += OnStreamEnd; + #pragma warning restore CS0618 } - public Task StartAsync(CancellationToken cancellationToken = default) - { + public Task StartAsync(CancellationToken cancellationToken = default) { Start(null); return Task.CompletedTask; } - - public Task StopAsync(CancellationToken cancellationToken = default) - { + + public Task StopAsync(CancellationToken cancellationToken = default) { Running = false; Dispose(); return Task.CompletedTask; } - - public bool Start(string? handle) - { + + [ExcludeFromCodeCoverage] + public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + public bool Start(string? handle) { Running = false; _reconnectState.Reset(); _ytHandle = handle ?? _config.Get("YouTube", "Handle")!; bool configured = !string.IsNullOrWhiteSpace(_ytHandle) && _ytHandle.Trim() != "@"; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.YouTube, Service = "Chat", Name = "None", @@ -93,50 +94,44 @@ public bool Start(string? handle) Configured = configured }); - if (!configured) - { + if (!configured) { _logger?.LogInformation("YouTube Service not connected to any channel. Not running."); return Running; } - if (!_ytHandle.StartsWith("@")) _ytHandle = "@" + _ytHandle; + if (!_ytHandle.StartsWith("@")) _ytHandle = "@" + _ytHandle; _logger?.LogInformation("Youtube Service Starting for " + _ytHandle); - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.YouTube, Service = "Chat", Name = _ytHandle!, Status = Running }); - _ytLiveChat.Start(handle: _ytHandle, overwrite: true); + _ytLiveChat.Start(_ytHandle, overwrite: true); return true; } - + // private void OnStreamStart(object? sender, LivestreamStartedEventArgs e) // { // Console.WriteLine("----------------------YT Live Stream Started"); // } - + [ExcludeFromCodeCoverage] - private async void OnStreamEnd(object? sender, LivestreamEndedEventArgs e) - { + private async void OnStreamEnd(object? sender, LivestreamEndedEventArgs e) { _logger?.LogDebug("YT Stream has ended"); await Task.Delay(2000); - if (await _reconnectState.IsReconnecting() && _reconnectState.Retries < _reconnectState.MaxRetries) - { + if (await _reconnectState.IsReconnecting() && _reconnectState.Retries < _reconnectState.MaxRetries) { _logger?.LogInformation("YT Cancelling reconnects. Stream verified as ended. Attempting last try."); _reconnectState.Retries = _reconnectState.MaxRetries - 1; } } - private void OnInitialPageLoaded(object? sender, InitialPageLoadedEventArgs e) - { + private void OnInitialPageLoaded(object? sender, InitialPageLoadedEventArgs e) { Running = true; _reconnectState.Reset(); _reconnectState.Cts?.Cancel(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.YouTube, Service = "Chat", Name = _ytHandle!, @@ -144,12 +139,11 @@ private void OnInitialPageLoaded(object? sender, InitialPageLoadedEventArgs e) }); _logger?.LogInformation($"Successfully loaded YouTube Live ID: {e.LiveId}"); } - private void OnChatStopped(object? sender, ChatStoppedEventArgs e) - { + + private void OnChatStopped(object? sender, ChatStoppedEventArgs e) { Running = false; _logger?.LogWarning("YT Chat stopped"); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.YouTube, Service = "Chat", Name = _ytHandle!, @@ -157,16 +151,15 @@ private void OnChatStopped(object? sender, ChatStoppedEventArgs e) }); TryReconnectLoop(); } - - private void OnBanner(object? sender, BannerAddedEventArgs e) - { - if (e.Banner.BannerType != BannerType.CrossChannelRedirect || e.Banner is not CrossChannelRedirectBannerItem banner) return; + + private void OnBanner(object? sender, BannerAddedEventArgs e) { + if (e.Banner.BannerType != BannerType.CrossChannelRedirect || + e.Banner is not CrossChannelRedirectBannerItem banner) return; if (banner.RedirectType != CrossChannelRedirectType.Raid) return; - var channel = banner.RedirectChannelHandle.Replace("@", ""); + string channel = banner.RedirectChannelHandle.Replace("@", ""); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.YouTube, EventType = SubathonEventType.YouTubeRedirect, Value = "", @@ -176,52 +169,43 @@ private void OnBanner(object? sender, BannerAddedEventArgs e) SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - private void OnErrorOccurred(object? sender, ErrorOccurredEventArgs e) - { - var ex = e.GetException(); + private void OnErrorOccurred(object? sender, ErrorOccurredEventArgs e) { + Exception? ex = e.GetException(); Running = false; - - if (ex?.Message.Contains("canonical link not found", StringComparison.OrdinalIgnoreCase) == true) - { + + if (ex?.Message.Contains("canonical link not found", StringComparison.OrdinalIgnoreCase) == true) { if (_canonicalLinkErrorCount == 0) - { _logger?.LogWarning( "YouTube stream is offline? (canonical link not found)." ); - } _canonicalLinkErrorCount++; } - else - { + else { var msg = "YT Error Occured"; - if (ex != null && ex.Message.Contains("Initial Continuation token not found")) - { + if (ex != null && ex.Message.Contains("Initial Continuation token not found")) { msg += " - Initial Continuation token not found"; _logger?.LogWarning(msg); } - else + else { _logger?.LogWarning(ex, "YT Error Occurred"); + } } - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.YouTube, Service = "Chat", Name = _ytHandle!, Status = Running }); } - - private void OnChatReceived(object? sender, ChatReceivedEventArgs e) - { + + private void OnChatReceived(object? sender, ChatReceivedEventArgs e) { if (string.IsNullOrWhiteSpace(_ytHandle) || _ytHandle == "@") return; - - if (!Running) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + if (!Running) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.YouTube, Service = "Chat", Name = _ytHandle!, @@ -233,38 +217,33 @@ private void OnChatReceived(object? sender, ChatReceivedEventArgs e) Running = true; _canonicalLinkErrorCount = 0; - + ChatItem item = e.ChatItem; if (item.Timestamp.DateTime.ToLocalTime() < DateTime.Now - TimeSpan.FromMinutes(3)) - { // only if it's not older than 3 min, // to avoid reparsing old events that had new id's return; - } string user = item.Author.Name.Replace("@", ""); - if (item.Superchat != null) - { + if (item.Superchat != null) { if (item.IsTicker) return; string currency = item.Superchat.Currency.ToUpper().Trim(); string raw = item.Superchat.AmountString.Trim(); - + if (currency == "USD" && !raw.StartsWith('$')) // Parsed incorrectly currency = Utils.TryParseCurrency(raw); - if (currency is "" or "???") - { - string message = $"Unknown currency detected: From: {user}, {item.Superchat.AmountString}"; - ErrorMessageEvents.RaiseErrorEvent("WARN", nameof(SubathonEventType.YouTubeSuperChat), + if (currency is "" or "???") { + var message = $"Unknown currency detected: From: {user}, {item.Superchat.AmountString}"; + ErrorMessageEvents.RaiseErrorEvent("WARN", nameof(SubathonEventType.YouTubeSuperChat), message, item.Timestamp.DateTime.ToLocalTime()); _logger?.LogWarning(message); } - - SubathonEvent subathonEvent = new() - { + + SubathonEvent subathonEvent = new() { User = user, Currency = $"{currency}".Trim().ToUpper(), Value = $"{item.Superchat.AmountValue}", @@ -277,29 +256,27 @@ private void OnChatReceived(object? sender, ChatReceivedEventArgs e) return; } - if (item.MembershipDetails != null) - { + if (item.MembershipDetails != null) { if (item.IsTicker) return; - SubathonEvent subathonEvent = new() - { + SubathonEvent subathonEvent = new() { Source = SubathonEventSource.YouTube }; subathonEvent.Id = Guid.TryParse(item.Id, out Guid gid) ? gid : Utils.CreateGuidFromUniqueString(item.Id); subathonEvent.EventTimestamp = item.Timestamp.DateTime.ToLocalTime(); - - var details = item.MembershipDetails; + + MembershipDetails? details = item.MembershipDetails; string tier = details.HeaderSubtext ?? details.LevelName; if (string.IsNullOrWhiteSpace(tier)) tier = details.LevelName; subathonEvent.Value = tier; subathonEvent.Currency = "member"; - - if (tier.Contains("new", StringComparison.CurrentCultureIgnoreCase) && details.EventType == MembershipEventType.Unknown) + + if (tier.Contains("new", StringComparison.CurrentCultureIgnoreCase) && + details.EventType == MembershipEventType.Unknown) details.EventType = MembershipEventType.New; - - switch (details.EventType) - { + + switch (details.EventType) { case MembershipEventType.Milestone: // essentially a resub subathonEvent.EventType = SubathonEventType.YouTubeMembership; tier = details.LevelName; @@ -324,20 +301,21 @@ private void OnChatReceived(object? sender, ChatReceivedEventArgs e) subathonEvent.EventType = SubathonEventType.YouTubeMembership; break; } + subathonEvent.User = user; - + if (tier.Equals("Member", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(tier)) tier = "DEFAULT"; if (tier.StartsWith("Upgraded membership to")) // upgrade type { // levelname might be correct here, but incredibly hard to test tier = tier.Replace("Upgraded membership to", ""); - tier = tier[..^1]; // remove end ! - + tier = tier[..^1]; // remove end ! } + subathonEvent.Value = tier.Trim(); subathonEvent.EventTypeMeta = tier.Trim(); - + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); return; } @@ -349,42 +327,36 @@ private void OnChatReceived(object? sender, ChatReceivedEventArgs e) item.Timestamp.DateTime.ToLocalTime(), Utils.CreateGuidFromUniqueString(item.Id)); else if (user.Equals("blerp", StringComparison.OrdinalIgnoreCase) && _config.GetBool("Extensions", "Blerp.Enabled", true)) - { BlerpChatService.ParseMessage(messagePreview, SubathonEventSource.YouTube); - } } - - private void TryReconnectLoop() - { + + private void TryReconnectLoop() { if (string.IsNullOrWhiteSpace(_ytHandle) || _ytHandle.Trim() == "@") return; - + _ = Task.Run(ReconnectWithBackoffAsync); } - + [ExcludeFromCodeCoverage] - private async Task ReconnectWithBackoffAsync() - { + private async Task ReconnectWithBackoffAsync() { if (await _reconnectState.IsReconnecting()) return; // already reconnecting - + if (string.IsNullOrWhiteSpace(_ytHandle) || _ytHandle.Trim() == "@") return; - - try - { + + try { _reconnectState.Cts?.Cancel(); _reconnectState.Cts = new CancellationTokenSource(); - var token = _reconnectState.Cts.Token; + CancellationToken token = _reconnectState.Cts.Token; - while (!token.IsCancellationRequested && !Running && _ytHandle?.Trim() != "@" && !string.IsNullOrWhiteSpace(_ytHandle)) - { - if (_reconnectState.Retries >= _reconnectState.MaxRetries) - { + while (!token.IsCancellationRequested && !Running && _ytHandle?.Trim() != "@" && + !string.IsNullOrWhiteSpace(_ytHandle)) { + if (_reconnectState.Retries >= _reconnectState.MaxRetries) { _logger?.LogError( "[YT] Max reconnect retries ({Retries}) reached. Giving up.", _reconnectState.MaxRetries); - + ErrorMessageEvents.RaiseErrorEvent( "ERROR", nameof(SubathonEventSource.YouTube), @@ -395,7 +367,7 @@ private async Task ReconnectWithBackoffAsync() _reconnectState.Retries++; - var delay = _reconnectState.Backoff; + TimeSpan delay = _reconnectState.Backoff; _logger?.LogWarning( "[YT] Reconnect attempt {Attempt}/{Max} in {Delay}s", @@ -403,21 +375,16 @@ private async Task ReconnectWithBackoffAsync() _reconnectState.MaxRetries, delay.TotalSeconds); - try - { + try { await Task.Delay(delay, token); if (!Running && !string.IsNullOrWhiteSpace(_ytHandle)) - { - _ytLiveChat.Start(handle: _ytHandle, overwrite: true); - } + _ytLiveChat.Start(_ytHandle, overwrite: true); } - catch (OperationCanceledException) - { + catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[YT] Reconnect attempt failed"); } @@ -428,25 +395,15 @@ private async Task ReconnectWithBackoffAsync() _reconnectState.MaxBackoff.TotalMilliseconds)); } } - finally - { + finally { _reconnectState.Lock.Release(); } } - - [ExcludeFromCodeCoverage] - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - + [ExcludeFromCodeCoverage] - protected virtual void Dispose(bool disposing) - { + protected virtual void Dispose(bool disposing) { if (_disposed) return; - if (disposing) - { + if (disposing) { _reconnectState.Dispose(); _ytLiveChat.Stop(); _ytLiveChat.InitialPageLoaded -= OnInitialPageLoaded; @@ -457,16 +414,15 @@ protected virtual void Dispose(bool disposing) _ytLiveChat.Dispose(); _httpClient.Dispose(); } + _disposed = true; } - - public static void SimulateSuperChat(string value = "10.00", string currency = "USD") - { - if (!double.TryParse(value, out var val)) + + public static void SimulateSuperChat(string value = "10.00", string currency = "USD") { + if (!double.TryParse(value, out double val)) return; - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { User = "SYSTEM", Currency = currency, Value = value, @@ -475,11 +431,9 @@ public static void SimulateSuperChat(string value = "10.00", string currency = " }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - - public static void SimulateMembership(string tier = "DEFAULT") - { - SubathonEvent subathonEvent = new SubathonEvent - { + + public static void SimulateMembership(string tier = "DEFAULT") { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, Currency = "member", EventType = SubathonEventType.YouTubeMembership, @@ -489,11 +443,9 @@ public static void SimulateMembership(string tier = "DEFAULT") SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - public static void SimulateGiftMemberships(int amount) - { - string tier = "DEFAULT"; - SubathonEvent subathonEvent = new SubathonEvent - { + public static void SimulateGiftMemberships(int amount) { + var tier = "DEFAULT"; + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, Currency = "member", EventType = SubathonEventType.YouTubeGiftMembership, @@ -503,12 +455,10 @@ public static void SimulateGiftMemberships(int amount) Amount = amount }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); - } - - public static void SimulateRaid() - { - SubathonEvent subathonEvent = new SubathonEvent - { + } + + public static void SimulateRaid() { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, Currency = "", EventType = SubathonEventType.YouTubeRedirect, @@ -517,5 +467,5 @@ public static void SimulateRaid() Amount = 1 }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); - } + } } \ No newline at end of file diff --git a/SubathonManager.Server/AspNetContextAdapter.cs b/SubathonManager.Server/AspNetContextAdapter.cs index 5dffe02a..e48f2af3 100644 --- a/SubathonManager.Server/AspNetContextAdapter.cs +++ b/SubathonManager.Server/AspNetContextAdapter.cs @@ -3,13 +3,13 @@ using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Primitives; using SubathonManager.Server.Interfaces; namespace SubathonManager.Server; [ExcludeFromCodeCoverage] -public sealed class AspNetContextAdapter(HttpContext ctx) : IHttpContext -{ +public sealed class AspNetContextAdapter(HttpContext ctx) : IHttpContext { public string Method => ctx.Request.Method; public string Path => ctx.Request.Path.Value ?? "/"; public string QueryString => ctx.Request.QueryString.Value?.TrimStart('?') ?? string.Empty; @@ -17,24 +17,22 @@ public sealed class AspNetContextAdapter(HttpContext ctx) : IHttpContext public Encoding Encoding => Encoding.UTF8; public bool IsWebSocket => ctx.WebSockets.IsWebSocketRequest; - public IReadOnlyDictionary Headers - { - get - { + public IReadOnlyDictionary Headers { + get { var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var (key, value) in ctx.Request.Headers) + foreach ((string key, StringValues value) in ctx.Request.Headers) dict[key] = value.ToString(); return dict; } } - public Task? AcceptWebSocketAsync(string? subProtocol = null) - => ctx.WebSockets.IsWebSocketRequest + public Task? AcceptWebSocketAsync(string? subProtocol = null) { + return ctx.WebSockets.IsWebSocketRequest ? ctx.WebSockets.AcceptWebSocketAsync(subProtocol) : null; + } - public async Task WriteResponse(int code, string body, bool addCors = false, string? contentType = null) - { + public async Task WriteResponse(int code, string body, bool addCors = false, string? contentType = null) { ctx.Response.StatusCode = code; if (contentType != null) ctx.Response.ContentType = contentType; @@ -43,31 +41,33 @@ public async Task WriteResponse(int code, string body, bool addCors = false, str await ctx.Response.WriteAsync(body, Encoding.UTF8); } - public async Task ServeFile(string fullPath, string contentType) - { + public async Task ServeFile(string fullPath, string contentType) { ctx.Response.ContentType = contentType; AddCorsHeaders(ctx.Response); await ctx.Response.SendFileAsync(fullPath); } - public async Task ServeBytes(byte[] data, string contentType) - { + public async Task ServeBytes(byte[] data, string contentType) { ctx.Response.ContentType = contentType; ctx.Response.ContentLength = data.Length; AddCorsHeaders(ctx.Response); await ctx.Response.Body.WriteAsync(data); } - private static void AddCorsHeaders(HttpResponse response) - { + private static void AddCorsHeaders(HttpResponse response) { response.Headers["Access-Control-Allow-Origin"] = "*"; response.Headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"; response.Headers["Access-Control-Allow-Headers"] = "Content-Type"; } } -internal sealed class NoopHostLifetime : IHostLifetime -{ - public Task WaitForStartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; -} +[ExcludeFromCodeCoverage] +internal sealed class NoopHostLifetime : IHostLifetime { + public Task WaitForStartAsync(CancellationToken cancellationToken) { + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/SubathonManager.Server/Interfaces/IHttpContext.cs b/SubathonManager.Server/Interfaces/IHttpContext.cs index 9f375fe6..59bee7dc 100644 --- a/SubathonManager.Server/Interfaces/IHttpContext.cs +++ b/SubathonManager.Server/Interfaces/IHttpContext.cs @@ -1,12 +1,12 @@ -using System.Text; -using System.Net.WebSockets; +using System.Net.WebSockets; +using System.Text; + namespace SubathonManager.Server.Interfaces; -public interface IHttpContext -{ +public interface IHttpContext { string Method { get; } string Path { get; } - + string QueryString { get; } IReadOnlyDictionary Headers { get; } @@ -14,9 +14,9 @@ public interface IHttpContext Stream Body { get; } Encoding Encoding { get; } bool IsWebSocket { get; } - + Task? AcceptWebSocketAsync(string? subProtocol = null); - Task WriteResponse(int statusCode, string body, bool addCors = false, string? contentType = null); + Task WriteResponse(int statusCode, string body, bool addCors = false, string? contentType = null); Task ServeFile(string fullPath, string contentType); Task ServeBytes(byte[] data, string contentType); } \ No newline at end of file diff --git a/SubathonManager.Server/Interfaces/IWebSocketClient.cs b/SubathonManager.Server/Interfaces/IWebSocketClient.cs index 950cead3..feb2b8f6 100644 --- a/SubathonManager.Server/Interfaces/IWebSocketClient.cs +++ b/SubathonManager.Server/Interfaces/IWebSocketClient.cs @@ -1,25 +1,24 @@ -using SubathonManager.Core.Enums; -using System.Net.WebSockets; +using System.Net.WebSockets; +using SubathonManager.Core.Enums; -namespace SubathonManager.Server; +namespace SubathonManager.Server.Interfaces; -public interface IWebSocketClient -{ +public interface IWebSocketClient { List ClientTypes { get; set; } - + WebSocketState State { get; } - + Guid ClientId { get; } List IntegrationSources { get; } - - Task SendAsync(ArraySegment buffer, - WebSocketMessageType messageType, - bool endOfMessage, + + Task SendAsync(ArraySegment buffer, + WebSocketMessageType messageType, + bool endOfMessage, CancellationToken cancellationToken); Task ReceiveAsync( - ArraySegment buffer, + ArraySegment buffer, CancellationToken cancellationToke); Task CloseAsync( diff --git a/SubathonManager.Server/WebServer.Api.Leaderboard.cs b/SubathonManager.Server/WebServer.Api.Leaderboard.cs new file mode 100644 index 00000000..8fbca5fb --- /dev/null +++ b/SubathonManager.Server/WebServer.Api.Leaderboard.cs @@ -0,0 +1,539 @@ +using System.Collections.Specialized; +using System.Globalization; +using System.Text.Json; +using System.Web; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; +using SubathonManager.Data; +using SubathonManager.Server.Interfaces; +using SubathonManager.Services; + +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Server; + +internal enum LeaderboardMethod { + ByPoints, + ByCount, + ByValue, + ByAmount, + ByOrder, + ByItems +} + +internal enum LeaderboardCategory { + Subscription, + Token, + Donation, + Order, + Other +} + +public partial class WebServer { + private const int DefaultLeaderboardSize = 10; + private const int MaxLeaderboardSize = 10000; + + private static readonly HashSet NonCurrencyMarkers = new(StringComparer.OrdinalIgnoreCase) { + "bits", "sub", "beets", "kudos", "order", "items", "member", "viewers", "???", "jewels", "gems" + }; + + internal async Task HandleLeaderboardRequestAsync(IHttpContext ctx) { + NameValueCollection query = HttpUtility.ParseQueryString(ctx.QueryString); + + (List? types, string? typeError) = + ParseLeaderboardTypes(ParseCsvParam(query, "type", "types", "event", "eventtype", "eventtypes")); + if (types == null) { + await ReturnError(ctx, typeError!); + return; + } + + // more than one event type - normalize users, sum final points only + bool combined = types.Count > 1; + SubathonEventType? primary = combined ? null : types[0]; + LeaderboardCategory category = combined ? LeaderboardCategory.Other : GetLeaderboardCategory(primary); + + (LeaderboardMethod? resolved, string? methodError) = combined + ? ResolveCombinedMethod(FirstParam(query, "method", "mode")) + : ResolveLeaderboardMethod(FirstParam(query, "method", "mode"), category); + if (resolved == null) { + await ReturnError(ctx, methodError!); + return; + } + + LeaderboardMethod method = resolved.Value; + + int top = DefaultLeaderboardSize; + string? topParam = FirstParam(query, "top", "n", "limit"); + if (!string.IsNullOrWhiteSpace(topParam)) { + if (topParam.Trim().Equals("all", StringComparison.OrdinalIgnoreCase)) { + top = 0; + } + else if (!int.TryParse(topParam.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out top)) { + await ReturnError(ctx, "Invalid 'top' parameter, expected a whole number or 'all'"); + return; + } + + top = Math.Clamp(top, 0, MaxLeaderboardSize); + } + + HashSet requestedMetas = ParseCsvParam(query, "meta", "metas", "tier", "tiers") + .Select(NormalizeLeaderboardMeta) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // ignore meta filters when multiple event types, complex + HashSet metas = combined ? new HashSet(StringComparer.OrdinalIgnoreCase) : requestedMetas; + HashSet blacklist = ParseCsvParam(query, "blacklist", "exclude", "ignore") + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + (Dictionary? aliases, string? aliasError) = + ParseUserAliases(ParseCsvParam(query, "alias", "aliases", "merge")); + if (aliases == null) { + await ReturnError(ctx, aliasError!); + return; + } + + bool includeUnprocessed = Utils.IsTruthy(FirstParam(query, "includeunprocessed", "unprocessed")); + + string targetCurrency = (FirstParam(query, "currency", "target") ?? + _config.Get("Currency", "Primary", "USD")!).ToUpperInvariant().Trim(); + if (!IsCurrencyCode(targetCurrency)) { + await ReturnError(ctx, $"Invalid 'currency' parameter: {targetCurrency}"); + return; + } + + await using AppDbContext db = await _factory.CreateDbContextAsync(); + (SubathonData? subathon, string? subathonError) = await ResolveSubathonAsync(db, query); + if (subathon == null) { + await ReturnError(ctx, subathonError!); + return; + } + + bool isMoney = IsMoneyMethod(method, category); + bool needsTokens = !isMoney && method is LeaderboardMethod.ByValue; + + List queryTypes = types.Select(t => (SubathonEventType?)t).ToList(); + IAsyncEnumerable events = db.SubathonEvents.AsNoTracking() + .Where(e => e.SubathonId == subathon.Id && queryTypes.Contains(e.EventType)) + .Where(e => includeUnprocessed || e.ProcessedToSubathon) + .AsAsyncEnumerable(); + + Dictionary entries = + await BuildLeaderboardEntriesAsync(events, metas, blacklist, aliases, isMoney, needsTokens); + + var unconverted = new List(); + Dictionary rates = isMoney + ? await BuildRateMapAsync(entries.Values.SelectMany(e => e.Money.Keys), targetCurrency, unconverted) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + + List<(LeaderboardEntry entry, double value)> ranked = entries.Values + .Select(e => (entry: e, value: GetLeaderboardValue(e, method, isMoney, rates))) + .OrderByDescending(r => r.value) + .ThenBy(r => r.entry.User, StringComparer.OrdinalIgnoreCase) + .ToList(); + + IEnumerable<(LeaderboardEntry entry, double value)> page = top > 0 ? ranked.Take(top) : ranked; + + object response = new { + subathon_id = subathon.Id, + subathon_name = subathon.Name, + subathon_active = subathon.IsActive, + event_type = combined ? null : types[0].ToString(), + event_types = types.Select(t => t.ToString()).ToArray(), + source = combined ? null : primary.GetSource().ToString(), + sources = types.Select(t => ((SubathonEventType?)t).GetSource().ToString()).Distinct().ToArray(), + label = combined ? null : primary.GetLabel(), + combined, + method = method.ToString(), + unit = MethodUnit(method, isMoney, targetCurrency), + currency = isMoney ? targetCurrency : null, + meta = metas.Count == 0 ? null : metas.OrderBy(m => m, StringComparer.OrdinalIgnoreCase).ToArray(), + meta_ignored = combined && requestedMetas.Count > 0, + blacklist = blacklist.OrderBy(b => b, StringComparer.OrdinalIgnoreCase).ToArray(), + aliases = aliases.Count == 0 + ? null + : aliases.GroupBy(a => a.Value, StringComparer.OrdinalIgnoreCase) + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, + g => g.Select(a => a.Key).OrderBy(a => a, StringComparer.OrdinalIgnoreCase).ToArray()), + include_unprocessed = includeUnprocessed, + top, + user_count = ranked.Count, + event_count = ranked.Sum(r => r.entry.Events), + total = Math.Round(ranked.Sum(r => r.value), 2), + unconverted_currencies = unconverted.Count == 0 ? null : unconverted.ToArray(), + results = page.Select((r, i) => new { + rank = i + 1, + user = r.entry.User, + value = Math.Round(r.value, 2), + events = r.entry.Events, + count = r.entry.Items + }).ToArray() + }; + + string json = JsonSerializer.Serialize(response, new JsonSerializerOptions { + WriteIndented = true + }); + await ctx.WriteResponse(200, json, true, "application/json"); + } + + private async Task> BuildLeaderboardEntriesAsync( + IAsyncEnumerable events, HashSet metas, HashSet blacklist, + Dictionary aliases, bool needsMoney, bool needsTokens) { + Dictionary entries = new(StringComparer.OrdinalIgnoreCase); + Dictionary<(SubathonEventType?, string?), bool> commissionFlags = new(); + + await foreach (SubathonEvent ev in events) { + string user = NormalizeUser(ev.User); + if (user.Length == 0) continue; + + if (IsBlacklistedUser(user, blacklist)) continue; + bool aliased = aliases.TryGetValue(user, out string? canonical); + if (aliased) user = canonical!; + if (IsBlacklistedUser(user, blacklist)) continue; + if (!MatchesLeaderboardMeta(ev, metas)) continue; + + if (!entries.TryGetValue(user, out LeaderboardEntry? entry)) { + entry = new LeaderboardEntry { User = user }; + entries[user] = entry; + } + + if (aliased) entry.User = user; + + entry.Events++; + entry.Items += Math.Max(ev.Amount, 0); + entry.Points += ev.GetFinalPointsValue(); + + if (needsTokens && + double.TryParse(ev.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out double tokenValue)) + entry.Tokens += tokenValue * (ev.EventType.IsOrder() ? 1 : Math.Max(ev.Amount, 0)); + + if (!needsMoney) continue; + (double amount, string currency)? money = ExtractMoney(ev, commissionFlags); + if (money == null) continue; + entry.Money.TryGetValue(money.Value.currency, out double existing); + entry.Money[money.Value.currency] = existing + money.Value.amount; + } + + return entries; + } + + private static async Task<(SubathonData?, string?)> ResolveSubathonAsync(AppDbContext db, + NameValueCollection query, bool includeMultiplier = false) { + IQueryable set = includeMultiplier + ? db.SubathonDatas.Include(s => s.Multiplier).AsNoTracking() + : db.SubathonDatas.AsNoTracking(); + + string? raw = FirstParam(query, "subathon", "subathonid"); + if (string.IsNullOrWhiteSpace(raw) || raw.Trim().Equals("active", StringComparison.OrdinalIgnoreCase)) { + SubathonData? active = await set.FirstOrDefaultAsync(s => s.IsActive); + return (active, active == null ? "No active subathon" : null); + } + + string trimmed = raw.Trim(); + if (!Guid.TryParse(trimmed, out Guid id)) + return (null, $"Invalid 'subathon' parameter, expected a subathon Id or 'active': {trimmed}"); + + SubathonData? found = await set.FirstOrDefaultAsync(s => s.Id == id); + return (found, found == null ? $"No subathon with Id {id}" : null); + } + + private static (List?, string?) ParseLeaderboardTypes(List raw) { + if (raw.Count == 0) return (null, "Missing or invalid 'type' parameter"); + + var types = new List(); + foreach (string entry in raw) { + if (entry.All(char.IsDigit) || !Enum.TryParse(entry, true, out SubathonEventType parsed) + || parsed is SubathonEventType.Unknown or SubathonEventType.Command) + return (null, $"Missing or invalid 'type' parameter: {entry}"); + if (!types.Contains(parsed)) types.Add(parsed); + } + + return (types, null); + } + + private static (LeaderboardMethod?, string?) ResolveCombinedMethod(string? raw) { + if (string.IsNullOrWhiteSpace(raw)) return (LeaderboardMethod.ByPoints, null); + + string trimmed = raw.Trim(); + if (trimmed.All(char.IsDigit) || !Enum.TryParse(trimmed, true, out LeaderboardMethod method)) + return (null, $"Unknown 'method' parameter: {trimmed}"); + + if (method is LeaderboardMethod.ByPoints) return (LeaderboardMethod.ByPoints, null); + return (null, + $"Method '{method}' is not valid with more than one 'type', only {LeaderboardMethod.ByPoints} is"); + } + + private static string NormalizeUser(string? user) { + return (user ?? string.Empty).Trim().TrimStart('@').Trim(); + } + + private static (Dictionary?, string?) ParseUserAliases(List raw) { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (raw.Count == 0) return (map, null); + + foreach (string pair in raw) { + int split = pair.IndexOf(':'); + if (split <= 0 || split == pair.Length - 1) + return (null, $"Invalid 'alias' entry '{pair}', expected 'canonical:alternate'"); + + string canonical = NormalizeUser(pair[..split]); + if (canonical.Length == 0) + return (null, $"Invalid 'alias' entry '{pair}', the canonical name is empty"); + + foreach (string alt in pair[(split + 1)..] + .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(NormalizeUser) + .Where(a => a.Length > 0)) { + if (map.TryGetValue(alt, out string? existing) && + !string.Equals(existing, canonical, StringComparison.OrdinalIgnoreCase)) + return (null, $"Alias '{alt}' is mapped to both '{existing}' and '{canonical}'"); + map[alt] = canonical; + } + } + + return CollapseAliasChains(map); + } + + private static (Dictionary?, string?) CollapseAliasChains(Dictionary map) { + var collapsed = new Dictionary(map.Count, StringComparer.OrdinalIgnoreCase); + + foreach ((string alt, string canonical) in map) { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase) { alt }; + string target = canonical; + + while (map.TryGetValue(target, out string? next)) { + if (!seen.Add(target)) + return (null, $"Alias '{alt}' forms a loop through '{target}'"); + target = next; + } + + if (!string.Equals(alt, target, StringComparison.OrdinalIgnoreCase)) collapsed[alt] = target; + } + + return (collapsed, null); + } + + private static LeaderboardCategory GetLeaderboardCategory(SubathonEventType? type) { + if (type.IsOrder()) return LeaderboardCategory.Order; + if (type.IsSubscription() || type.IsGift()) return LeaderboardCategory.Subscription; + if (type.IsToken()) return LeaderboardCategory.Token; + if (type.IsCurrencyDonation()) return LeaderboardCategory.Donation; + return LeaderboardCategory.Other; + } + + private static (LeaderboardMethod?, string?) ResolveLeaderboardMethod(string? raw, LeaderboardCategory category) { + LeaderboardMethod fallback = category switch { + LeaderboardCategory.Subscription => LeaderboardMethod.ByPoints, + LeaderboardCategory.Token => LeaderboardMethod.ByValue, + LeaderboardCategory.Donation => LeaderboardMethod.ByAmount, + LeaderboardCategory.Order => LeaderboardMethod.ByValue, + _ => LeaderboardMethod.ByCount + }; + + if (string.IsNullOrWhiteSpace(raw)) return (fallback, null); + + string trimmed = raw.Trim(); + if (trimmed.All(char.IsDigit) || !Enum.TryParse(trimmed, true, out LeaderboardMethod method)) + return (null, $"Unknown 'method' parameter: {trimmed}"); + + LeaderboardMethod? canonical = category switch { + LeaderboardCategory.Subscription => method switch { + LeaderboardMethod.ByPoints => LeaderboardMethod.ByPoints, + LeaderboardMethod.ByCount => LeaderboardMethod.ByCount, + _ => null + }, + LeaderboardCategory.Token => method switch { + LeaderboardMethod.ByValue or LeaderboardMethod.ByAmount => LeaderboardMethod.ByValue, + LeaderboardMethod.ByPoints => LeaderboardMethod.ByPoints, + LeaderboardMethod.ByCount => LeaderboardMethod.ByCount, + _ => null + }, + LeaderboardCategory.Donation => method switch { + LeaderboardMethod.ByAmount or LeaderboardMethod.ByValue => LeaderboardMethod.ByAmount, + LeaderboardMethod.ByPoints => LeaderboardMethod.ByPoints, + LeaderboardMethod.ByCount => LeaderboardMethod.ByCount, + _ => null + }, + LeaderboardCategory.Order => method switch { + LeaderboardMethod.ByValue or LeaderboardMethod.ByAmount => LeaderboardMethod.ByValue, + LeaderboardMethod.ByOrder or LeaderboardMethod.ByCount => LeaderboardMethod.ByOrder, + LeaderboardMethod.ByItems => LeaderboardMethod.ByItems, + LeaderboardMethod.ByPoints => LeaderboardMethod.ByPoints, + _ => null + }, + _ => method switch { + LeaderboardMethod.ByCount => LeaderboardMethod.ByCount, + LeaderboardMethod.ByPoints => LeaderboardMethod.ByPoints, + _ => null + } + }; + + return canonical == null + ? (null, $"Method '{method}' is not valid for {category.ToString().ToLowerInvariant()} events") + : (canonical, null); + } + + private static double GetLeaderboardValue(LeaderboardEntry entry, LeaderboardMethod method, bool isMoney, + IReadOnlyDictionary rates) { + if (isMoney) { + var sum = 0.0; + foreach ((string currency, double amount) in entry.Money) + sum += amount * (rates.TryGetValue(currency, out double rate) ? rate : 0); + return sum; + } + + return method switch { + LeaderboardMethod.ByPoints => entry.Points, + LeaderboardMethod.ByValue => entry.Tokens, + LeaderboardMethod.ByOrder => entry.Events, + _ => entry.Items + }; + } + + private bool IsCommissionAsDonation(SubathonEvent ev, Dictionary<(SubathonEventType?, string?), bool> cache) { + (SubathonEventType? EventType, string? EventTypeMeta) key = (ev.EventType, ev.EventTypeMeta); + if (cache.TryGetValue(key, out bool cached)) return cached; + + bool value = Utils.IsCommissionAsDonation(_config, ev); + cache[key] = value; + return value; + } + + private (double amount, string currency)? ExtractMoney(SubathonEvent ev, + Dictionary<(SubathonEventType?, string?), bool> commissionFlags) { + if (IsCommissionAsDonation(ev, commissionFlags) && + TrySplitSecondaryValue(ev, out double commission, out string commissionCurrency)) + return (commission, commissionCurrency); + + if (IsCurrencyCode(ev.Currency) && + double.TryParse(ev.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out double value)) + return (value, ev.Currency!.ToUpperInvariant().Trim()); + + if (TrySplitSecondaryValue(ev, out double secondary, out string secondaryCurrency)) + return (secondary, secondaryCurrency); + + return null; + } + + private static bool TrySplitSecondaryValue(SubathonEvent ev, out double amount, out string currency) { + amount = 0; + currency = string.Empty; + if (string.IsNullOrWhiteSpace(ev.SecondaryValue) || !ev.SecondaryValue.Contains('|')) return false; + + string[] parts = ev.SecondaryValue.Split('|'); + if (parts.Length < 2 || !IsCurrencyCode(parts[1])) return false; + if (!double.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out amount)) return false; + + currency = parts[1].ToUpperInvariant().Trim(); + return true; + } + + private static async Task> BuildRateMapAsync(IEnumerable currencies, + string target, List unconverted) { + var rates = new Dictionary(StringComparer.OrdinalIgnoreCase); + var service = AppServices.Provider?.GetService(); + + foreach (string currency in currencies.Distinct(StringComparer.OrdinalIgnoreCase)) { + if (string.Equals(currency, target, StringComparison.OrdinalIgnoreCase)) { + rates[currency] = 1.0; + continue; + } + + double rate = service == null ? 0 : await service.ConvertAsync(1.0, currency, target); + rates[currency] = rate; + if (rate <= 0) unconverted.Add(currency.ToUpperInvariant()); + } + + return rates; + } + + private static bool IsMoneyMethod(LeaderboardMethod method, LeaderboardCategory category) { + return method is LeaderboardMethod.ByAmount || + (method is LeaderboardMethod.ByValue && category is LeaderboardCategory.Order); + } + + private static string MethodUnit(LeaderboardMethod method, bool isMoney, string currency) { + if (isMoney) return currency; + return method switch { + LeaderboardMethod.ByPoints => "points", + LeaderboardMethod.ByItems => "items", + LeaderboardMethod.ByOrder => "orders", + LeaderboardMethod.ByValue => "tokens", + _ => "count" + }; + } + + private static string NormalizeLeaderboardMeta(string meta) { + string trimmed = meta.Trim(); + return trimmed.ToLowerInvariant() switch { + "t1" => "1000", + "t2" => "2000", + "t3" => "3000", + _ => trimmed + }; + } + + private static bool MatchesLeaderboardMeta(SubathonEvent ev, HashSet metas) { + if (metas.Count == 0) return true; + if (!string.IsNullOrWhiteSpace(ev.EventTypeMeta) && + metas.Contains(NormalizeLeaderboardMeta(ev.EventTypeMeta))) return true; + return !string.IsNullOrWhiteSpace(ev.Value) && metas.Contains(NormalizeLeaderboardMeta(ev.Value)); + } + + private static bool IsBlacklistedUser(string user, HashSet blacklist) { + if (blacklist.Count == 0) return false; + string trimmed = user.Trim(); + if (blacklist.Contains(trimmed)) return true; + return blacklist.Where(entry => entry.EndsWith('*')) + .Any(entry => trimmed.StartsWith(entry[..^1], StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsCurrencyCode(string? currency) { + if (string.IsNullOrWhiteSpace(currency)) return false; + string trimmed = currency.Trim(); + return trimmed.Length == 3 && trimmed.All(char.IsLetter) && !NonCurrencyMarkers.Contains(trimmed); + } + + private static string? FirstParam(NameValueCollection query, params string[] keys) { + foreach (string key in keys) { + string? value = query.AllKeys + .Where(k => k != null && string.Equals(k, key, StringComparison.OrdinalIgnoreCase)) + .Select(k => query[k]) + .FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); + if (value != null) return value; + } + + return null; + } + + private static List ParseCsvParam(NameValueCollection query, params string[] keys) { + var values = new List(); + foreach (string key in keys) + foreach (string? actual in query.AllKeys.Where(k => + k != null && string.Equals(k, key, StringComparison.OrdinalIgnoreCase))) { + string[]? raw = query.GetValues(actual); + if (raw == null) continue; + values.AddRange(raw.SelectMany(v => + v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))); + } + + return values; + } + + private static async Task ReturnError(IHttpContext ctx, string message, int code = 400) { + string json = JsonSerializer.Serialize(new { error = message }); + await ctx.WriteResponse(code, json, true, "application/json"); + } + + private sealed class LeaderboardEntry { + public readonly Dictionary Money = new(StringComparer.OrdinalIgnoreCase); + public int Events; + public long Items; + public double Points; + public double Tokens; + public string User = ""; + } +} diff --git a/SubathonManager.Server/WebServer.Api.cs b/SubathonManager.Server/WebServer.Api.cs index de6da18c..d1ff8093 100644 --- a/SubathonManager.Server/WebServer.Api.cs +++ b/SubathonManager.Server/WebServer.Api.cs @@ -1,66 +1,66 @@ -using System.Text.Json; +using System.Collections.Specialized; +using System.Text.Json; +using System.Web; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using SubathonManager.Core.Models; -using SubathonManager.Core.Events; using SubathonManager.Core.Enums; -using SubathonManager.Integration; +using SubathonManager.Core.Events; +using SubathonManager.Core.Models; using SubathonManager.Data; +using SubathonManager.Integration; using SubathonManager.Server.Interfaces; // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Server; -public partial class WebServer -{ - private void SetupApiRoutes() - { - _routes.Add((new RouteKey("POST", "/api/data/control"),HandleDataControlRequestAsync )); - _routes.Add((new RouteKey("PUT", "/api/data/control"),HandleDataControlRequestAsync )); - - _routes.Add((new RouteKey("GET", "/api/data/status"),HandleStatusRequestAsync)); - - _routes.Add((new RouteKey("GET", "/api/data/amounts"),HandleAmountsRequestAsync )); - - _routes.Add((new RouteKey("GET", "/api/data/values"),HandleValuesRequestAsync )); - - _routes.Add((new RouteKey("GET", "/api/data/commands"),HandleCommandsRequestAsync )); - - _routes.Add((new RouteKey("PUT", "/api/data/values"),HandleValuesPatchRequestAsync)); - _routes.Add((new RouteKey("POST", "/api/data/values"),HandleValuesPatchRequestAsync )); - _routes.Add((new RouteKey("PATCH", "/api/data/values"),HandleValuesPatchRequestAsync )); - - _routes.Add((new RouteKey("GET", "/api/select"),HandleSelectAsync)); - - _routes.Add((new RouteKey("POST", "/api/update-position/"),HandleWidgetUpdateAsync )); - _routes.Add((new RouteKey("POST", "/api/update-size/"),HandleWidgetUpdateAsync)); - _routes.Add((new RouteKey("POST", "/api/update-dimensions/"),HandleWidgetUpdateAsync)); - _routes.Add((new RouteKey("POST", "/api/widget-action/"),HandleWidgetActionAsync)); +public partial class WebServer { + private void SetupApiRoutes() { + _routes.Add((new RouteKey("POST", "/api/data/control"), HandleDataControlRequestAsync)); + _routes.Add((new RouteKey("PUT", "/api/data/control"), HandleDataControlRequestAsync)); + + _routes.Add((new RouteKey("GET", "/api/data/status"), HandleStatusRequestAsync)); + + _routes.Add((new RouteKey("GET", "/api/data/amounts"), HandleAmountsRequestAsync)); + + _routes.Add((new RouteKey("GET", "/api/data/values"), HandleValuesRequestAsync)); + + _routes.Add((new RouteKey("GET", "/api/data/commands"), HandleCommandsRequestAsync)); + + _routes.Add((new RouteKey("GET", "/api/data/leaderboard"), HandleLeaderboardRequestAsync)); + + _routes.Add((new RouteKey("PUT", "/api/data/values"), HandleValuesPatchRequestAsync)); + _routes.Add((new RouteKey("POST", "/api/data/values"), HandleValuesPatchRequestAsync)); + _routes.Add((new RouteKey("PATCH", "/api/data/values"), HandleValuesPatchRequestAsync)); + + _routes.Add((new RouteKey("GET", "/api/select"), HandleSelectAsync)); + + _routes.Add((new RouteKey("POST", "/api/update-position/"), HandleWidgetUpdateAsync)); + _routes.Add((new RouteKey("POST", "/api/update-size/"), HandleWidgetUpdateAsync)); + _routes.Add((new RouteKey("POST", "/api/update-dimensions/"), HandleWidgetUpdateAsync)); + _routes.Add((new RouteKey("POST", "/api/widget-action/"), HandleWidgetActionAsync)); } - internal async Task HandleWidgetActionAsync(IHttpContext ctx) - { - var parts = ctx.Path.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length < 3 || !Guid.TryParse(parts[2], out var widgetId)) - { + internal async Task HandleWidgetActionAsync(IHttpContext ctx) { + string[] parts = ctx.Path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 3 || !Guid.TryParse(parts[2], out Guid widgetId)) { await ctx.WriteResponse(400, "Invalid Widget ID"); return; } string body; - using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) + using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) { body = await reader.ReadToEndAsync(); + } - try - { + try { var data = JsonSerializer.Deserialize>(body); - string name = data != null && data.TryGetValue("action", out var el) && el.ValueKind == JsonValueKind.String + string name = data != null && data.TryGetValue("action", out JsonElement el) && + el.ValueKind == JsonValueKind.String ? el.GetString() ?? string.Empty : string.Empty; - if (!Enum.TryParse(name, ignoreCase: true, out var action)) - { + if (!Enum.TryParse(name, true, out WidgetContextAction action)) { await ctx.WriteResponse(400, "Unknown action"); return; } @@ -68,52 +68,46 @@ internal async Task HandleWidgetActionAsync(IHttpContext ctx) WidgetEvents.RaiseWidgetAction(widgetId, action); await ctx.WriteResponse(200, "OK"); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "Failed to handle widget context action"); await ctx.WriteResponse(400, "Invalid action data"); } } - - internal async Task HandleSelectAsync(IHttpContext ctx) - { - var path = ctx.Path; - + + internal async Task HandleSelectAsync(IHttpContext ctx) { + string path = ctx.Path; + // Fast Close await ctx.WriteResponse(200, "OK"); - var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3) - { + string[] parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 3) { string widgetId = parts[2]; - if (Guid.TryParse(widgetId, out var widgetGuid)) - { - WidgetEvents.RaiseSelectEditorWidget(widgetGuid); - } + if (Guid.TryParse(widgetId, out Guid widgetGuid)) WidgetEvents.RaiseSelectEditorWidget(widgetGuid); } } - - internal async Task HandleWidgetUpdateAsync(IHttpContext ctx) - {; - var path = ctx.Path; - - var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3) - { + + internal async Task HandleWidgetUpdateAsync(IHttpContext ctx) { + ; + string path = ctx.Path; + + string[] parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 3) { string widgetId = parts[2]; string body; - using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) + using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) { body = await reader.ReadToEndAsync(); + } + var data = JsonSerializer.Deserialize>(body); - if (data == null) - { + if (data == null) { await ctx.WriteResponse(400, "Invalid update data"); return; } var widgetHelper = new WidgetEntityHelper(_factory, null); - bool success = false; + var success = false; if (path.StartsWith("/api/update-size/", StringComparison.OrdinalIgnoreCase)) success = await widgetHelper.UpdateWidgetScale(widgetId, data); else if (path.StartsWith("/api/update-position/", StringComparison.OrdinalIgnoreCase)) @@ -121,100 +115,88 @@ internal async Task HandleWidgetUpdateAsync(IHttpContext ctx) else if (path.StartsWith("/api/update-dimensions/", StringComparison.OrdinalIgnoreCase)) success = await widgetHelper.UpdateWidgetDimensions(widgetId, data); - if (success) - { + if (success) { await ctx.WriteResponse(200, "OK"); return; } - + await ctx.WriteResponse(404, "Widget Not Found"); return; } + await ctx.WriteResponse(400, "Invalid Widget ID"); } - private async Task HandleDataControlRequestAsync(IHttpContext ctx) - { + private async Task HandleDataControlRequestAsync(IHttpContext ctx) { string body; - using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) + using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) { body = await reader.ReadToEndAsync(); + } var data = new Dictionary(); - try - { + try { data = JsonSerializer.Deserialize>($"{body}"); } - catch (JsonException ex) - { + catch (JsonException ex) { _logger?.LogError(ex, "Invalid control data"); await ctx.WriteResponse(400, "Invalid control data"); return; } - if (data == null || data.Count == 0) - { + if (data == null || data.Count == 0) { await ctx.WriteResponse(400, "Invalid control data"); return; } - SubathonEventType type = SubathonEventType.Unknown; + var type = SubathonEventType.Unknown; if (!data.ContainsKey("type") || !data.TryGetValue("type", out JsonElement elem) - || !Enum.TryParse(elem.GetString()!, ignoreCase: true, out type)) - { - + || !Enum.TryParse(elem.GetString()!, true, out type)) { await ctx.WriteResponse(400, "Invalid control data"); return; } - bool success = false; - if (type == SubathonEventType.Command) - { + var success = false; + if (type == SubathonEventType.Command) { success = ExternalEventService.ProcessExternalCommand(data); } - else if (((SubathonEventType?)type).IsCurrencyDonation() && ((SubathonEventType?)type).IsExternal()) - { + else if (((SubathonEventType?)type).IsCurrencyDonation() && ((SubathonEventType?)type).IsExternal()) { success = ExternalEventService.ProcessExternalDonation(data); } - else if (((SubathonEventType?)type).IsSubscription() && ((SubathonEventType?)type).IsExternal()) - { + else if (((SubathonEventType?)type).IsSubscription() && ((SubathonEventType?)type).IsExternal()) { success = ExternalEventService.ProcessExternalSub(data); } - else - { + else { await ctx.WriteResponse(400, "Invalid control data"); return; } - if (success) - { + if (success) { await ctx.WriteResponse(200, "OK"); return; } + await ctx.WriteResponse(400, "Invalid API Request"); } - internal async Task HandleStatusRequestAsync(IHttpContext ctx) - { - await using var db = await _factory.CreateDbContextAsync(); + internal async Task HandleStatusRequestAsync(IHttpContext ctx) { + await using AppDbContext db = await _factory.CreateDbContextAsync(); SubathonData? subathon = await db.SubathonDatas.Include(s => s.Multiplier) .AsNoTracking() .FirstOrDefaultAsync(s => s.IsActive); TimeSpan? multiplierRemaining = TimeSpan.Zero; - if (subathon == null) - { + if (subathon == null) { await ctx.WriteResponse(400, "Invalid status request"); return; } + if (subathon.Multiplier.Duration != null && subathon.Multiplier.Duration > TimeSpan.Zero - && subathon.Multiplier.Started != null) - { + && subathon.Multiplier.Started != null) { DateTime? multEndTime = subathon.Multiplier.Started + subathon.Multiplier.Duration; multiplierRemaining = multEndTime! - DateTime.Now; } - object response = new - { + object response = new { millis_cumulated = subathon.MillisecondsCumulative, millis_elapsed = subathon.MillisecondsElapsed, millis_remaining = subathon.MillisecondsRemaining(), @@ -227,91 +209,89 @@ internal async Task HandleStatusRequestAsync(IHttpContext ctx) is_paused = subathon.IsPaused, is_locked = subathon.IsLocked, is_reversed = subathon.IsSubathonReversed(), - multiplier = new - { + multiplier = new { running = subathon.Multiplier.IsRunning(), apply_points = subathon.Multiplier.ApplyToPoints, apply_time = subathon.Multiplier.ApplyToSeconds, is_from_hypetrain = subathon.Multiplier.FromHypeTrain, started_at = subathon.Multiplier.Started, duration_seconds = Math.Round(subathon.Multiplier.Duration?.TotalSeconds ?? 0), - duration_remaining_seconds = Math.Round(multiplierRemaining.Value.TotalSeconds), + duration_remaining_seconds = Math.Round(multiplierRemaining.Value.TotalSeconds) } }; - string json = JsonSerializer.Serialize(response, new JsonSerializerOptions - { + string json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true }); await ctx.WriteResponse(200, json); } - internal async Task HandleAmountsRequestAsync(IHttpContext ctx) - { - await using var db = await _factory.CreateDbContextAsync(); - SubathonData? subathon = await db.SubathonDatas.Include(s => s.Multiplier) - .AsNoTracking() - .FirstOrDefaultAsync(s => s.IsActive); - if (subathon == null) - { - await ctx.WriteResponse(400, "Invalid status request"); + internal async Task HandleAmountsRequestAsync(IHttpContext ctx) { + NameValueCollection query = HttpUtility.ParseQueryString(ctx.QueryString); + + await using AppDbContext db = await _factory.CreateDbContextAsync(); + (SubathonData? subathon, string? subathonError) = await ResolveSubathonAsync(db, query, true); + if (subathon == null) { + await ctx.WriteResponse(400, subathonError ?? "Invalid status request"); return; } - var events = await db.SubathonEvents + + List events = await db.SubathonEvents .Where(e => e.SubathonId == subathon.Id && e.ProcessedToSubathon) .Where(e => e.EventType != SubathonEventType.Command && e.EventType != SubathonEventType.Unknown) .ToListAsync(); - - var simulated = events.Where(e => e.User != null && (e.User.StartsWith("SYSTEM") || e.User.StartsWith("SIMULATED"))).ToList(); - var real = events.Where(e => e.User != null && !e.User.StartsWith("SYSTEM") && !e.User.StartsWith("SIMULATED")).ToList(); - - object response = new - { + + List simulated = events + .Where(e => e.User != null && (e.User.StartsWith("SYSTEM") || e.User.StartsWith("SIMULATED"))).ToList(); + List real = events + .Where(e => e.User != null && !e.User.StartsWith("SYSTEM") && !e.User.StartsWith("SIMULATED")).ToList(); + + object response = new { + subathon_id = subathon.Id, + subathon_name = subathon.Name, + subathon_active = subathon.IsActive, simulated = BuildDataSummary(simulated), real = BuildDataSummary(real) }; - string json = JsonSerializer.Serialize(response, new JsonSerializerOptions - { + string json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true }); await ctx.WriteResponse(200, json); } - - internal static object[] BuildCommandCatalog() => - Enum.GetValues() + + internal static object[] BuildCommandCatalog() { + return Enum.GetValues() .Where(c => c is not (SubathonCommandType.None or SubathonCommandType.Unknown)) - .Select(object (c) => new - { + .Select(object (c) => new { command = c.ToString(), description = c.GetDescription(), requires_parameter = c.IsParametersRequired(), is_control = c.IsControlTypeCommand() }).ToArray(); + } - internal async Task HandleCommandsRequestAsync(IHttpContext ctx) - { + internal async Task HandleCommandsRequestAsync(IHttpContext ctx) { string json = JsonSerializer.Serialize(new { commands = BuildCommandCatalog() }); - await ctx.WriteResponse(200, json, addCors: true, contentType: "application/json"); + await ctx.WriteResponse(200, json, true, "application/json"); } - private async Task HandleValuesRequestAsync(IHttpContext ctx) - { - var json = await _valueHelper.GetAllAsJsonAsync(); + private async Task HandleValuesRequestAsync(IHttpContext ctx) { + string json = await _valueHelper.GetAllAsJsonAsync(); await ctx.WriteResponse(200, json); } - internal async Task HandleValuesPatchRequestAsync(IHttpContext ctx) - { + internal async Task HandleValuesPatchRequestAsync(IHttpContext ctx) { string body; - using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) + using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) { body = await reader.ReadToEndAsync(); + } + int patched = await _valueHelper.PatchFromJsonAsync(body); int code; string msg; - switch (patched) - { + switch (patched) { case -1: code = 400; msg = "Error patching values"; @@ -325,17 +305,15 @@ internal async Task HandleValuesPatchRequestAsync(IHttpContext ctx) msg = $"Patched {patched} Values"; break; } + await ctx.WriteResponse(code, msg); } - private object BuildDataSummary(List events) - { + private object BuildDataSummary(List events) { var result = new Dictionary(); - - static string NormalizeTier(string meta) - { - return meta switch - { + + static string NormalizeTier(string meta) { + return meta switch { "1000" => "T1", "2000" => "T2", "3000" => "T3", @@ -343,24 +321,21 @@ static string NormalizeTier(string meta) }; } - var groups = events.GroupBy(e => e.EventType); + IEnumerable> groups = events.GroupBy(e => e.EventType); - foreach (var g in groups) - { - string? key = g.Key!.ToString(); + foreach (IGrouping g in groups) { + var key = g.Key!.ToString(); if (key == null) continue; - if (g.Key.IsCurrencyDonation()) - { + if (g.Key.IsCurrencyDonation()) { result[key] = g - .Where(e => !string.IsNullOrWhiteSpace(e.Currency)) + .Where(e => !string.IsNullOrWhiteSpace(e.Currency)) .GroupBy(e => e.Currency ?? "") .ToDictionary( t => t.Key, - t => - { + t => { double sum = t.Sum(e => - double.TryParse(e.Value, out var amount) + double.TryParse(e.Value, out double amount) ? amount : 0 ); @@ -368,61 +343,54 @@ static string NormalizeTier(string meta) } ); } - else if (g.Key.IsSubscription()) - { + else if (g.Key.IsSubscription()) { result[key] = g.GroupBy(e => NormalizeTier(e.Value)) .ToDictionary( t => t.Key, t => t.Sum(x => x.Amount) ); } - else if (g.Key.IsToken()) - { - result[key] = g.Sum(e => int.TryParse(e.Value, out var v) ? v : 0); + else if (g.Key.IsToken()) { + result[key] = g.Sum(e => int.TryParse(e.Value, out int v) ? v : 0); } - else if (g.Key.IsOrder()) - { - var breakdown = g - .Where(e => !string.IsNullOrWhiteSpace(e.Currency)) + else if (g.Key.IsOrder()) { + Dictionary breakdown = g + .Where(e => !string.IsNullOrWhiteSpace(e.Currency)) .GroupBy(e => e.Currency ?? "") .ToDictionary( t => t.Key, - t => - { + t => { double sum = t.Sum(e => - double.TryParse(string.Equals(e.Value, "new", StringComparison.OrdinalIgnoreCase) - ? "1" : e.Value, out var amount) + double.TryParse(string.Equals(e.Value, "new", StringComparison.OrdinalIgnoreCase) + ? "1" + : e.Value, out double amount) ? amount : 0 ); return Math.Round(sum, 2); } ); - result[key] = new Dictionary - { + result[key] = new Dictionary { ["count"] = g.Count(), ["breakdown"] = breakdown }; } - else - { - switch (g.Key) - { + else { + switch (g.Key) { case SubathonEventType.TwitchFollow: result[key] = g.Count(); break; case SubathonEventType.TwitchRaid: - result[key] = new - { + result[key] = new { count = g.Count(), - total_viewers = g.Sum(e => int.TryParse(e.Value, out var v) ? v : 0) + total_viewers = g.Sum(e => int.TryParse(e.Value, out int v) ? v : 0) }; break; } } } + return result; } - } \ No newline at end of file diff --git a/SubathonManager.Server/WebServer.Overlays.cs b/SubathonManager.Server/WebServer.Overlays.cs index ddba1da6..4181fa58 100644 --- a/SubathonManager.Server/WebServer.Overlays.cs +++ b/SubathonManager.Server/WebServer.Overlays.cs @@ -1,54 +1,45 @@ -using System.Globalization; +using System.Collections.Specialized; +using System.Globalization; using System.Net; using System.Text; using System.Text.RegularExpressions; +using System.Web; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Data; using SubathonManager.Server.Interfaces; namespace SubathonManager.Server; -public partial class WebServer -{ - - public void AddRoute(Core.Models.Route route) - { - foreach (var widget in route.Widgets) - { +public partial class WebServer { + public void AddRoute(Route route) { + foreach (Widget widget in route.Widgets) { string folder = Path.GetDirectoryName(widget.HtmlPath)!; - if (!_servedFolders.Add(folder)) - { - _logger?.LogInformation($"Registered static folder: {folder}"); - } + if (!_servedFolders.Add(folder)) _logger?.LogInformation($"Registered static folder: {folder}"); } } - internal async Task HandleWidgetRequest(IHttpContext ctx) - { - var path = ctx.Path; - var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + internal async Task HandleWidgetRequest(IHttpContext ctx) { + string path = ctx.Path; + string[] parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2) - { + if (parts.Length >= 2) { string widgetId = parts[1]; - if (Guid.TryParse(widgetId, out var widgetGuid)) - { - - await using var db = await _factory.CreateDbContextAsync(); - var widget = await db.Widgets + if (Guid.TryParse(widgetId, out Guid widgetGuid)) { + await using AppDbContext db = await _factory.CreateDbContextAsync(); + Widget? widget = await db.Widgets .Include(ww => ww.CssVariables) .Include(ww => ww.JsVariables) .FirstOrDefaultAsync(w => w.Id == widgetGuid); - if (widget != null) - { + if (widget != null) { string relativePath = string.Join('/', parts.Skip(2)); - if (widget.Type.IsAsset() && string.IsNullOrWhiteSpace(relativePath)) - { + if (widget.Type.IsAsset() && string.IsNullOrWhiteSpace(relativePath)) { string fileName = Path.GetFileName(widget.HtmlPath); string html = widget.Type == WidgetType.Video ? $"" @@ -62,32 +53,27 @@ internal async Task HandleWidgetRequest(IHttpContext ctx) ? widget.HtmlPath : Path.Combine(folder, relativePath.Replace('/', Path.DirectorySeparatorChar)); - if (WidgetFiles.Current.Exists(filePath)) - { - if (Path.GetExtension(filePath).Equals(".html", StringComparison.OrdinalIgnoreCase)) - { + if (WidgetFiles.Current.Exists(filePath)) { + if (Path.GetExtension(filePath).Equals(".html", StringComparison.OrdinalIgnoreCase)) { string html = WidgetFiles.Current.ReadAllText(filePath) ?? string.Empty; + string clientScript = GetWebsocketInjectionScript(); + var cssOverrides = new StringBuilder(); - cssOverrides.AppendLine(GetWebsocketInjectionScript()); cssOverrides.AppendLine(""); + if (html.Contains("", StringComparison.OrdinalIgnoreCase)) - { - html = html.Replace("", cssOverrides + "\n", StringComparison.OrdinalIgnoreCase); - } + html = html.Replace("", clientScript + "\n" + cssOverrides + "\n", + StringComparison.OrdinalIgnoreCase); else - { - html += cssOverrides; - } - + html = clientScript + "\n" + html + cssOverrides; + var jsOverrides = new StringBuilder(); jsOverrides.AppendLine("\n\n"); var fontLoader = """ @@ -113,7 +99,7 @@ function loadGoogleFont(fontName) { link.rel = 'stylesheet'; document.head.appendChild(link); } - + function loadCdnFont(fontName) { const link = document.createElement('link'); link.href = `https://fonts.cdnfonts.com/css/${fontName.replace(/ /g, '-').toLowerCase()}`; @@ -137,19 +123,17 @@ function loadBunnyFont(fontName) { } """; - if (html.Contains(" "; + } - private string GenerateMergedPage(Core.Models.Route route, bool isEditor = false) - { - StringBuilder sb = new StringBuilder(); + private string GenerateMergedPage(Route route, bool isEditor = false) { + var sb = new StringBuilder(); sb.AppendLine( $"overlay-{route.Id}"); sb.AppendLine($@" @@ -363,6 +342,11 @@ @keyframes pulse {{ outline-color: rgba(100, 180, 255, 0.9); animation: pulse 1.5s infinite; }} + .widget-chrome.selected {{ + outline-color: rgba(100, 180, 255, 0.95); + box-shadow: 0 0 14px rgba(100,180,255,0.55); + }} + .chrome-hidden {{ opacity: 0.4 !important; outline-color: rgba(60, 10, 10, 0.4); @@ -406,7 +390,6 @@ @keyframes pulse {{ "); if (isEditor) - { sb.AppendLine(@" "); - } string overlayClass = isEditor ? "overlay-edit" : ""; sb.AppendLine($@"
"); - foreach (var w in route.Widgets) - { + foreach (Widget w in route.Widgets) { if (!isEditor && !w.Visibility) continue; if (w.ScaleX == 0) w.ScaleX = 1; if (w.ScaleY == 0) w.ScaleY = 1; - string cssClass = "widget-wrapper"; + var cssClass = "widget-wrapper"; if (!w.Visibility) cssClass += " widget-hidden"; - string sx = w.ScaleX.ToString(CultureInfo.InvariantCulture); - string sy = w.ScaleY.ToString(CultureInfo.InvariantCulture); - string px = w.X.ToString(CultureInfo.InvariantCulture); - string py = w.Y.ToString(CultureInfo.InvariantCulture); + var sx = w.ScaleX.ToString(CultureInfo.InvariantCulture); + var sy = w.ScaleY.ToString(CultureInfo.InvariantCulture); + var px = w.X.ToString(CultureInfo.InvariantCulture); + var py = w.Y.ToString(CultureInfo.InvariantCulture); sb.AppendLine($@"
"); } - if (isEditor) - { + if (isEditor) { sb.AppendLine(@"
"); - foreach (var w in route.Widgets) - { - string chromeClass = "widget-chrome"; + foreach (Widget w in route.Widgets) { + var chromeClass = "widget-chrome"; if (!w.Visibility) chromeClass += " chrome-hidden"; sb.AppendLine($@"
"); } + sb.AppendLine("
"); } sb.AppendLine("
"); - sb.AppendLine(@$" + sb.AppendLine(@" "); sb.AppendLine(@$"{GetWebsocketInjectionScript(route.Id.ToString())}"); // moving/resizing/selecting in edit mode - if (isEditor) - { + if (isEditor) { sb.AppendLine(@" "); - + // resize as separate listeners - sb.AppendLine(@$" + sb.AppendLine(@" + "); + + // arrow-key nudge + sb.AppendLine(@" + "); diff --git a/SubathonManager.Server/WebServer.WebSocket.cs b/SubathonManager.Server/WebServer.WebSocket.cs index a45f9c96..bcf1d8f5 100644 --- a/SubathonManager.Server/WebServer.WebSocket.cs +++ b/SubathonManager.Server/WebServer.WebSocket.cs @@ -5,25 +5,24 @@ using Microsoft.Extensions.Logging; using SubathonManager.Core; using SubathonManager.Core.Enums; -using SubathonManager.Core.Models; using SubathonManager.Core.Events; +using SubathonManager.Core.Models; using SubathonManager.Core.Objects; using SubathonManager.Data; using SubathonManager.Integration; using SubathonManager.Server.Interfaces; using SubathonManager.Services; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Server; -public partial class WebServer -{ +public partial class WebServer { + private static readonly TimeSpan OutboundDrainTimeout = TimeSpan.FromSeconds(2); private readonly List _clients = new(); private readonly object _lock = new(); - private static readonly TimeSpan OutboundDrainTimeout = TimeSpan.FromSeconds(2); - private void SetupWebsocketListeners() - { + private void SetupWebsocketListeners() { SubathonEvents.SubathonDataUpdate += SendSubathonDataUpdate; SubathonEvents.SubathonEventProcessed += SendSubathonEventProcessed; SubathonEvents.SubathonGoalCompleted += SendGoalCompleted; @@ -36,7 +35,7 @@ private void SetupWebsocketListeners() SubathonEvents.PromptRunStarted += OnPromptStart; SubathonEvents.PromptRunUpdate += OnPromptRunUpdate; SubathonEvents.PromptRunProgressUpdated += OnPromptProgress; - + OverlayEvents.WidgetVarsUpdated += SendWidgetVarsUpdate; OverlayEvents.WidgetRefreshRequested += SendWidgetReload; @@ -46,8 +45,7 @@ private void SetupWebsocketListeners() WheelEvents.WheelDataChanged += SendWheelDataChanged; } - private void StopWebsocketServer() - { + private void StopWebsocketServer() { SubathonEvents.SubathonDataUpdate -= SendSubathonDataUpdate; SubathonEvents.SubathonEventProcessed -= SendSubathonEventProcessed; SubathonEvents.SubathonGoalCompleted -= SendGoalCompleted; @@ -56,11 +54,11 @@ private void StopWebsocketServer() SubathonEvents.SubathonValueConfigRequested -= SendSubathonValues; SubathonEvents.SubathonTotalsUpdated -= SendSubathonTotals; SubathonEvents.SubscriptionTotalsUpdated -= SendSubscriptionTotals; - + SubathonEvents.PromptRunStarted -= OnPromptStart; SubathonEvents.PromptRunUpdate -= OnPromptRunUpdate; SubathonEvents.PromptRunProgressUpdated -= OnPromptProgress; - + OverlayEvents.WidgetVarsUpdated -= SendWidgetVarsUpdate; OverlayEvents.WidgetRefreshRequested -= SendWidgetReload; @@ -70,54 +68,46 @@ private void StopWebsocketServer() WheelEvents.WheelDataChanged -= SendWheelDataChanged; List clientsCopy; - lock (_lock) - { + lock (_lock) { clientsCopy = _clients.ToList(); _clients.Clear(); } - foreach (var client in clientsCopy) + foreach (IWebSocketClient client in clientsCopy) client.CompleteOutbound(); } - private void OnPromptStart(SubathonPromptRun subathonPromptRun, SubathonPrompt? subathonPrompt) - { - SendPromptData(subathonPromptRun, 0); + private void OnPromptStart(SubathonPromptRun subathonPromptRun, SubathonPrompt? subathonPrompt) { + SendPromptData(subathonPromptRun); } - private void OnPromptRunUpdate(SubathonPromptRun subathonPromptRun, SubathonPrompt? subathonPrompt) - { - Task.Run(async () => - { - await using var db = await _factory.CreateDbContextAsync(); + private void OnPromptRunUpdate(SubathonPromptRun subathonPromptRun, SubathonPrompt? subathonPrompt) { + Task.Run(async () => { + await using AppDbContext db = await _factory.CreateDbContextAsync(); long current = await PromptOrchestratorService.GetCurrentCountAsync(db, subathonPromptRun.LinkedPrompt!); long progress = current - subathonPromptRun.BaselineCount; SendPromptData(subathonPromptRun, progress); }); } - private void OnPromptProgress(SubathonPromptRun subathonPromptRun, long progress) - { + private void OnPromptProgress(SubathonPromptRun subathonPromptRun, long progress) { SendPromptData(subathonPromptRun, progress); } - - internal void SendWidgetReload(Guid widgetId, float x, float y, int width, int height, float scaleX, float scaleY) - { - var data = new { - type = "widget_reload", + + internal void SendWidgetReload(Guid widgetId, float x, float y, int width, int height, float scaleX, float scaleY) { + var data = new { + type = "widget_reload", widgetId = widgetId.ToString(), x, y, width, height, scaleX, scaleY }; BroadcastObject(data, WebsocketClientMessageType.Overlay); } - internal void SendPromptData(SubathonPromptRun? run, long progress = 0) - { - object data = new - { + internal void SendPromptData(SubathonPromptRun? run, long progress = 0) { + object data = new { type = "prompt_update", status = run == null ? "None" : run.Status.ToString(), - progress = progress, + progress, target = run?.LinkedPrompt?.Value ?? 0, seconds_remaining = run?.TimeRemaining().TotalSeconds ?? 0, start_time = run?.StartedAt, @@ -127,35 +117,33 @@ internal void SendPromptData(SubathonPromptRun? run, long progress = 0) prompt_type = $"{run?.LinkedPrompt?.Type}", prompt_subtype = $"{run?.LinkedPrompt?.SubType}", prompt_eventtype = $"{run?.LinkedPrompt?.FilterEventType}", - prompt_eventtype_metafilter = run?.LinkedPrompt?.FilterMeta + prompt_eventtype_metafilter = run?.LinkedPrompt?.FilterMeta }; BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } - - internal void SendWidgetVarsUpdate(Guid widgetId, - IEnumerable cssVars, IEnumerable jsVars) - { - var data = new - { + + internal void SendWidgetVarsUpdate(Guid widgetId, + IEnumerable cssVars, IEnumerable jsVars) { + var data = new { type = "widget_vars_update", widgetId = widgetId.ToString(), cssVars = cssVars.Select(v => new { name = v.Name, value = v.Value }), - jsVars = jsVars.Select(v => new { name = v.Name, value = v.Value, - injectLine = v.GetInjectLine() }) + jsVars = jsVars.Select(v => new { + name = v.Name, value = v.Value, + injectLine = v.GetInjectLine() + }) }; BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } - internal void SendSubathonValues(string jsonData) - { - var newData = $"{{ \"type\": \"value_config\", \"ws_type\": \"{WebsocketClientMessageType.ValueConfig}\", \"data\": {jsonData} }}"; + internal void SendSubathonValues(string jsonData) { + var newData = + $"{{ \"type\": \"value_config\", \"ws_type\": \"{WebsocketClientMessageType.ValueConfig}\", \"data\": {jsonData} }}"; Broadcast(newData, OutboundCoalesceKey.None, WebsocketClientTypeHelper.ConfigConsumersList); } - internal void SendGoalsUpdated(List goals, long currentPoints, GoalsType type) - { - object data = new - { + internal void SendGoalsUpdated(List goals, long currentPoints, GoalsType type) { + object data = new { type = "goals_list", points = currentPoints, goals = goals.Select(goal => GoalToObject(goal, currentPoints)).ToArray(), @@ -164,50 +152,43 @@ internal void SendGoalsUpdated(List goals, long currentPoints, Goa BroadcastObject(data, OutboundCoalesceKey.GoalsList, WebsocketClientTypeHelper.ConsumersList); } - private object GoalToObject(SubathonGoal goal, long currentPoints) - { - return new - { + private object GoalToObject(SubathonGoal goal, long currentPoints) { + return new { text = goal.Text, points = goal.Points, completed = goal.Points <= currentPoints }; } - internal void SendGoalCompleted(SubathonGoal goal, long currentPoints) - { - object data = new - { + internal void SendGoalCompleted(SubathonGoal goal, long currentPoints) { + object data = new { type = "goal_completed", goal_text = goal.Text, - goal_points = goal.Points, + goal_points = goal.Points, points = currentPoints }; BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } - private async Task InitConnection(IWebSocketClient socket) - { - await using var db = await _factory.CreateDbContextAsync(); + private async Task InitConnection(IWebSocketClient socket) { + await using AppDbContext db = await _factory.CreateDbContextAsync(); SubathonData? subathon = await db.SubathonDatas.Include(s => s.Multiplier) .FirstOrDefaultAsync(s => s.IsActive); if (subathon is null) return; string configValues = await _valueHelper.GetAllAsJsonAsync(); SendSubathonValues(configValues); - + await SelectSendAsync(socket, SubathonDataToObject(subathon)); - - SubathonGoalSet? goalSet = await db.SubathonGoalSets.AsNoTracking(). - Include(g=> g.Goals).FirstOrDefaultAsync(g => g.IsActive); - if (goalSet != null) - { + + SubathonGoalSet? goalSet = await db.SubathonGoalSets.AsNoTracking().Include(g => g.Goals) + .FirstOrDefaultAsync(g => g.IsActive); + if (goalSet != null) { long val = subathon.Points; if (goalSet.Type == GoalsType.Money) - val = (long) Math.Floor(subathon.MoneySum ?? 0); + val = (long)Math.Floor(subathon.MoneySum ?? 0); - object data = new - { + object data = new { type = "goals_list", points = val, goals = goalSet.Goals.Select(goal => GoalToObject(goal, val)).ToArray(), @@ -219,37 +200,35 @@ private async Task InitConnection(IWebSocketClient socket) SubathonPromptRun? promptRun = await db.SubathonPromptRuns.AsNoTracking() .Include(p => p.LinkedPrompt) .FirstOrDefaultAsync(p => p.Status == SubathonPromptRunStatus.Active && p.ExpiresAt > DateTime.Now); - if (promptRun is { LinkedPrompt: not null }) - { + if (promptRun is { LinkedPrompt: not null }) { long current = await PromptOrchestratorService.GetCurrentCountAsync(db, promptRun.LinkedPrompt); long progress = current - promptRun.BaselineCount; SendPromptData(promptRun, progress); } - else - SendPromptData(null, 0); - - var totals = await EventService.GetSubathonTotalsAsync(db); + else { + SendPromptData(null); + } + + SubathonTotals? totals = await EventService.GetSubathonTotalsAsync(db); if (totals != null) await SelectSendAsync(socket, SubathonTotalsToObject(totals)); - var subTotals = await EventService.GetSubscriptionTotalsAsync(db); + SubscriptionTotals? subTotals = await EventService.GetSubscriptionTotalsAsync(db); if (subTotals != null) await SelectSendAsync(socket, SubscriptionTotalsToObject(subTotals)); - var activeWheel = await db.WheelSets + WheelSet? activeWheel = await db.WheelSets .Include(w => w.WheelItems) .ThenInclude(i => i.Action) .AsNoTracking() .FirstOrDefaultAsync(w => w.IsActive); - if (activeWheel != null) - { - int spinsOwed = StateValueHelper.Get(db, StateKeys.WheelSpinsOwed); - var items = activeWheel.WheelItems.Select(WheelItemToObject).ToList(); - var wheelData = new - { + if (activeWheel != null) { + var spinsOwed = StateValueHelper.Get(db, StateKeys.WheelSpinsOwed); + List items = activeWheel.WheelItems.Select(WheelItemToObject).ToList(); + var wheelData = new { type = "wheel_data", wheel = new { id = activeWheel.Id, name = activeWheel.Name, spin_count = activeWheel.SpinCount, items }, spins_owed = spinsOwed @@ -258,10 +237,8 @@ private async Task InitConnection(IWebSocketClient socket) } } - private object SubathonTotalsToObject(SubathonTotals totals) - { - return new - { + private object SubathonTotalsToObject(SubathonTotals totals) { + return new { type = "subathon_totals", currency = totals.Currency, money_sum = totals.MoneySum, @@ -291,54 +268,49 @@ private object SubathonTotalsToObject(SubathonTotals totals) .ToDictionary(k => k.Key.ToString(), k => k.Value), follow_count = totals.Simulated.FollowLikeTotal, follow_count_by_type = totals.Simulated.FollowLikeByEvent - .ToDictionary(k => k.Key.ToString(), k => k.Value), + .ToDictionary(k => k.Key.ToString(), k => k.Value) } }; } - private object SubscriptionTotalsToObject(SubscriptionTotals totals) - { - return new - { + private object SubscriptionTotalsToObject(SubscriptionTotals totals) { + return new { type = "subscription_totals", sub_total = totals.SubTotal, sub_total_by_type = totals.SubTotalByEvent .ToDictionary(k => k.Key.ToString(), k => k.Value), sub_total_by_type_tier = totals.SubTotalByEventTier .ToDictionary(k => k.Key.ToString(), k => k.Value), - simulated = new - { + simulated = new { sub_total = totals.Simulated.SubTotal, sub_total_by_type = totals.Simulated.SubTotalByEvent .ToDictionary(k => k.Key.ToString(), k => k.Value), sub_total_by_type_tier = totals.Simulated.SubTotalByEventTier - .ToDictionary(k => k.Key.ToString(), k => k.Value), + .ToDictionary(k => k.Key.ToString(), k => k.Value) } }; } - private object SubathonEventToObject(SubathonEvent subathonEvent) - { - var trueSource = subathonEvent.EventType.GetTypeTrueSource(subathonEvent.EventTypeMeta); + private object SubathonEventToObject(SubathonEvent subathonEvent) { + string? trueSource = subathonEvent.EventType.GetTypeTrueSource(subathonEvent.EventTypeMeta); var eventType = subathonEvent.EventType.ToString(); if (subathonEvent.EventType == SubathonEventType.GoAffProOrder - && GoAffProOrderHelper.TryGetStore(subathonEvent.EventTypeMeta, out var store)) - { + && GoAffProOrderHelper.TryGetStore(subathonEvent.EventTypeMeta, out GoAffProStore? store)) { trueSource = store.InternalName; eventType = store.InternalEventName; } - object data = new - { + + object data = new { type = "event", event_type = eventType, - source = subathonEvent.Source.ToString(), + source = subathonEvent.Source.ToString(), seconds_added = subathonEvent.GetFinalSecondsValueRaw() < 0.5 ? 0 : subathonEvent.GetFinalSecondsValue(), points_added = subathonEvent.GetFinalPointsValue(), - user = subathonEvent.User, + user = subathonEvent.User, value = subathonEvent.Value, // sometimes useful amount = subathonEvent.Amount, // sometimes useful currency = subathonEvent.Currency, // sometimes useful - command = subathonEvent.Command.ToString(), // only useful if eventType is command + command = subathonEvent.Command.ToString(), // only useful if eventType is command event_timestamp = subathonEvent.EventTimestamp, reversed = subathonEvent.WasReversed, sub_type = subathonEvent.EventType.GetSubType().ToString(), @@ -349,49 +321,41 @@ private object SubathonEventToObject(SubathonEvent subathonEvent) return data; } - internal void SendSubathonEventProcessed(SubathonEvent subathonEvent, bool effective) - { - bool showOverride = _config.GetBool("App", "ShowLockedEvents", false); + internal void SendSubathonEventProcessed(SubathonEvent subathonEvent, bool effective) { + bool showOverride = _config.GetBool("App", "ShowLockedEvents"); if (!showOverride && !subathonEvent.ProcessedToSubathon) return; BroadcastObject(SubathonEventToObject(subathonEvent), WebsocketClientTypeHelper.ConsumersList); } - internal void SendSubathonTotals(SubathonTotals totals) - { + internal void SendSubathonTotals(SubathonTotals totals) { BroadcastObject(SubathonTotalsToObject(totals), OutboundCoalesceKey.SubathonTotals, WebsocketClientTypeHelper.ConsumersList); } - internal void SendSubscriptionTotals(SubscriptionTotals totals) - { + internal void SendSubscriptionTotals(SubscriptionTotals totals) { BroadcastObject(SubscriptionTotalsToObject(totals), OutboundCoalesceKey.SubscriptionTotals, WebsocketClientTypeHelper.ConsumersList); } - internal void SendRefreshRequest(Guid id) - { - BroadcastObject(new - { + internal void SendRefreshRequest(Guid id) { + BroadcastObject(new { type = "refresh_request", id = id.ToString() }, WebsocketClientMessageType.Overlay); } - private object SubathonDataToObject(SubathonData subathon) - { + private object SubathonDataToObject(SubathonData subathon) { TimeSpan? multiplierRemaining = TimeSpan.Zero; if (subathon.Multiplier.Duration != null && subathon.Multiplier.Duration > TimeSpan.Zero - && subathon.Multiplier.Started != null) - { + && subathon.Multiplier.Started != null) { DateTime? multEndTime = subathon.Multiplier.Started + subathon.Multiplier.Duration; multiplierRemaining = multEndTime - DateTime.Now; } long roundedMoney = subathon.GetRoundedMoneySum(); double fractionalMoney = subathon.GetRoundedMoneySumWithCents(); - - object data = new - { + + object data = new { type = "subathon_timer", total_seconds = subathon.TimeRemainingRounded().TotalSeconds, days = subathon.TimeRemainingRounded().Days, @@ -403,47 +367,42 @@ private object SubathonDataToObject(SubathonData subathon) fractional_money = fractionalMoney, currency = subathon.Currency, is_paused = subathon.IsPaused, - is_locked = subathon.IsLocked, + is_locked = subathon.IsLocked, is_reversed = subathon.IsSubathonReversed(), multiplier_points = subathon.Multiplier.ApplyToPoints ? subathon.Multiplier.Multiplier : 1, multiplier_time = subathon.Multiplier.ApplyToSeconds ? subathon.Multiplier.Multiplier : 1, multiplier_start_time = subathon.Multiplier.Started, multiplier_seconds_total = Math.Round(subathon.Multiplier.Duration?.TotalSeconds ?? 0), - multiplier_seconds_remaining = Math.Round(multiplierRemaining.Value.TotalSeconds), - total_seconds_elapsed = (int) (subathon.MillisecondsElapsed / 1000), - total_seconds_added = (int) (subathon.MillisecondsCumulative / 1000) + multiplier_seconds_remaining = Math.Round(multiplierRemaining.Value.TotalSeconds), + total_seconds_elapsed = (int)(subathon.MillisecondsElapsed / 1000), + total_seconds_added = (int)(subathon.MillisecondsCumulative / 1000) }; return data; } - internal void SendSubathonDataUpdate(SubathonData subathon, DateTime time) - { + internal void SendSubathonDataUpdate(SubathonData subathon, DateTime time) { BroadcastObject(SubathonDataToObject(subathon), OutboundCoalesceKey.SubathonTimer, WebsocketClientTypeHelper.ConsumersList); } - internal void AddSocketClient(IWebSocketClient socket) - { - lock (_lock) - { + internal void AddSocketClient(IWebSocketClient socket) { + lock (_lock) { _clients.Add(socket); _logger?.LogDebug("{ClientsCount} websocket clients connected", _clients.Count); } + socket.StartOutbound(); } - - public async Task HandleWebSocketRequestAsync(IHttpContext ctx) - { - if (!ctx.IsWebSocket) - { + + public async Task HandleWebSocketRequestAsync(IHttpContext ctx) { + if (!ctx.IsWebSocket) { await ctx.WriteResponse(400, "Invalid Websocket Request"); return; } - var accept = ctx.AcceptWebSocketAsync(); + Task? accept = ctx.AcceptWebSocketAsync(); - if (accept is null) - { + if (accept is null) { await ctx.WriteResponse(400, "Not a WebSocket request"); return; } @@ -451,25 +410,22 @@ public async Task HandleWebSocketRequestAsync(IHttpContext ctx) using WebSocket socket = await accept; IWebSocketClient client = new WebSocketClient(socket); AddSocketClient(client); - + _logger?.LogDebug("New WebSocket Client Connected [{ClientClientId}].", client.ClientId); - - try - { + + try { await Listen(client); } - finally - { + finally { await client.CompleteOutboundAsync(OutboundDrainTimeout); - foreach (var clientIntegrationSource in client.IntegrationSources) - { + foreach (SubathonEventSource clientIntegrationSource in client.IntegrationSources) { WebServerEvents.RaiseWebSocketIntegrationSourceChange(clientIntegrationSource.ToString(), false); - _logger?.LogDebug("WebSocket Client disconnected for Integration: {ClientIntegrationSource}", clientIntegrationSource); + _logger?.LogDebug("WebSocket Client disconnected for Integration: {ClientIntegrationSource}", + clientIntegrationSource); } - lock (_lock) - { + lock (_lock) { _clients.Remove(client); _logger?.LogDebug("{ClientsCount} websocket clients connected", _clients.Count); } @@ -477,76 +433,66 @@ public async Task HandleWebSocketRequestAsync(IHttpContext ctx) _logger?.LogDebug("WebSocket Client Disconnected [{ClientClientId}]", client.ClientId); } } - - private async Task Listen(IWebSocketClient socket) - { + + private async Task Listen(IWebSocketClient socket) { var buffer = new byte[1024 * 8]; - while (socket.State == WebSocketState.Open) - { - var result = await socket.ReceiveAsync(buffer, CancellationToken.None); - if (result.MessageType == WebSocketMessageType.Close) - { + while (socket.State == WebSocketState.Open) { + WebSocketReceiveResult result = await socket.ReceiveAsync(buffer, CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) { await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client closed", CancellationToken.None); break; } string msg = Encoding.UTF8.GetString(buffer, 0, result.Count); - WebsocketClientMessageType clientMessageType = WebsocketClientMessageType.None; - try - { - var json = JsonDocument.Parse(msg); - if (json.RootElement.TryGetProperty("ws_type", out var type)) - { - switch (type.GetString()) - { + var clientMessageType = WebsocketClientMessageType.None; + try { + JsonDocument json = JsonDocument.Parse(msg); + if (json.RootElement.TryGetProperty("ws_type", out JsonElement type)) { + switch (type.GetString()) { case "ping": socket.TryEnqueue(Encoding.UTF8.GetBytes("{\"ws_type\":\"pong\"}")); break; case "hello": - _logger?.LogDebug($"[WebSocket] [{socket.ClientId}] Hello from {json.RootElement.GetProperty("origin").GetString()}"); + _logger?.LogDebug( + $"[WebSocket] [{socket.ClientId}] Hello from {json.RootElement.GetProperty("origin").GetString()}"); break; } if (Enum.TryParse(type.GetString(), out clientMessageType)) - { - if (socket.ClientTypes.Count >= 1 && !socket.ClientTypes.Contains(clientMessageType)) - { - _logger?.LogDebug($"WebSocket ClientType [{clientMessageType}] identified for client [{socket.ClientId}]"); + if (socket.ClientTypes.Count >= 1 && !socket.ClientTypes.Contains(clientMessageType)) { + _logger?.LogDebug( + $"WebSocket ClientType [{clientMessageType}] identified for client [{socket.ClientId}]"); socket.ClientTypes.Add(clientMessageType); - if (clientMessageType.IsConsumer() && socket.ClientTypes.Contains(WebsocketClientMessageType.Generic)) - { + if (clientMessageType.IsConsumer() && + socket.ClientTypes.Contains(WebsocketClientMessageType.Generic)) { socket.ClientTypes.Remove(WebsocketClientMessageType.Generic); await InitConnection(socket); } else if (clientMessageType == WebsocketClientMessageType.ValueConfig && - socket.ClientTypes.Contains(WebsocketClientMessageType.Generic)) - { + socket.ClientTypes.Contains(WebsocketClientMessageType.Generic)) { socket.ClientTypes.Remove(WebsocketClientMessageType.Generic); string configValues = await _valueHelper.GetAllAsJsonAsync(); - var newData = $"{{ \"type\": \"value_config\", \"ws_type\": \"{WebsocketClientMessageType.ValueConfig}\", \"data\": {configValues} }}"; + var newData = + $"{{ \"type\": \"value_config\", \"ws_type\": \"{WebsocketClientMessageType.ValueConfig}\", \"data\": {configValues} }}"; await SelectSendStringAsync(socket, newData); } } - } } - switch (clientMessageType) - { - case WebsocketClientMessageType.Command: - { + switch (clientMessageType) { + case WebsocketClientMessageType.Command: { if (json.RootElement.TryGetProperty("request", out JsonElement reqElem) - && string.Equals(reqElem.GetString(), "commands", StringComparison.OrdinalIgnoreCase)) - { - await SelectSendAsync(socket, new - { + && string.Equals(reqElem.GetString(), "commands", StringComparison.OrdinalIgnoreCase)) { + await SelectSendAsync(socket, new { type = "command_list", ws_type = nameof(WebsocketClientMessageType.Command), commands = BuildCommandCatalog() }); break; } + if (!json.RootElement.TryGetProperty("type", out JsonElement elem) - || !Enum.TryParse(elem.GetString()!, ignoreCase: true, out SubathonEventType seType) + || !Enum.TryParse(elem.GetString()!, true, out SubathonEventType seType) || seType == SubathonEventType.Unknown) continue; if (seType != SubathonEventType.Command) continue; @@ -559,8 +505,7 @@ private async Task Listen(IWebSocketClient socket) bool success = ExternalEventService.ProcessExternalCommand(data); data.TryGetValue("command", out JsonElement cmdElem); data.TryGetValue("context", out JsonElement ctxElem); - await SelectSendAsync(socket, new - { + await SelectSendAsync(socket, new { type = "command_ack", ws_type = nameof(WebsocketClientMessageType.Command), command = cmdElem.ValueKind == JsonValueKind.String ? cmdElem.GetString() : null, @@ -569,35 +514,33 @@ private async Task Listen(IWebSocketClient socket) }); break; } - case WebsocketClientMessageType.IntegrationSource: - { + case WebsocketClientMessageType.IntegrationSource: { if (json.RootElement.TryGetProperty("source", out JsonElement src) && - Enum.TryParse(src.GetString()!, ignoreCase: true, out SubathonEventSource source) - && !socket.IntegrationSources.Contains(source)) - { + Enum.TryParse(src.GetString()!, true, out SubathonEventSource source) + && !socket.IntegrationSources.Contains(source)) { _logger?.LogDebug($"WebSocket Client [{socket.ClientId}] added Integration: {source}"); socket.IntegrationSources.Add(source); WebServerEvents.RaiseWebSocketIntegrationSourceChange(source.ToString(), true); } - + if (!json.RootElement.TryGetProperty("type", out JsonElement elem) - || !Enum.TryParse(elem.GetString()!, ignoreCase: true, out SubathonEventType seType) + || !Enum.TryParse(elem.GetString()!, true, out SubathonEventType seType) || seType == SubathonEventType.Unknown) continue; - + Dictionary data = json.RootElement .EnumerateObject() .ToDictionary(p => p.Name, p => p.Value); - - if (((SubathonEventType?)seType).IsCurrencyDonation() && ((SubathonEventType?)seType).IsExternal()) - { + + if (((SubathonEventType?)seType).IsCurrencyDonation() && + ((SubathonEventType?)seType).IsExternal()) { if (!socket.IntegrationSources.Contains(((SubathonEventType?)seType).GetSource())) socket.IntegrationSources.Add(((SubathonEventType?)seType).GetSource()); ExternalEventService.ProcessExternalDonation(data); } - else if (((SubathonEventType?)seType).IsSubscription() && ((SubathonEventType?)seType).IsExternal()) - { + else if (((SubathonEventType?)seType).IsSubscription() && + ((SubathonEventType?)seType).IsExternal()) { if (!socket.IntegrationSources.Contains(((SubathonEventType?)seType).GetSource())) socket.IntegrationSources.Add(((SubathonEventType?)seType).GetSource()); ExternalEventService.ProcessExternalSub(data); @@ -605,40 +548,38 @@ private async Task Listen(IWebSocketClient socket) break; } - case WebsocketClientMessageType.ValueConfig when json.RootElement.TryGetProperty("data", out var data): - { + case WebsocketClientMessageType.ValueConfig + when json.RootElement.TryGetProperty("data", out JsonElement data): { int patched = await _valueHelper.PatchFromJsonDataAsync(data); var resMsg = ""; if (patched == -1) resMsg = "Error Patching"; else if (patched == 0) resMsg = "No patches needed"; else resMsg = $"Patched {patched} Values"; - object resp = new - { + object resp = new { ws_type = WebsocketClientMessageType.ValueConfig, response = resMsg }; await SelectSendAsync(socket, resp); break; } - case WebsocketClientMessageType.ValueConfig: - { + case WebsocketClientMessageType.ValueConfig: { string configValues = await _valueHelper.GetAllAsJsonAsync(); - var newData = $"{{ \"type\": \"value_config\", \"ws_type\": \"{WebsocketClientMessageType.ValueConfig}\", \"data\": {configValues} }}"; + var newData = + $"{{ \"type\": \"value_config\", \"ws_type\": \"{WebsocketClientMessageType.ValueConfig}\", \"data\": {configValues} }}"; await SelectSendStringAsync(socket, newData); break; } - case WebsocketClientMessageType.WheelControl: - { - if (!json.RootElement.TryGetProperty("id", out var idProp) - || !Guid.TryParse(idProp.GetString(), out var histId)) + case WebsocketClientMessageType.WheelControl: { + if (!json.RootElement.TryGetProperty("id", out JsonElement idProp) + || !Guid.TryParse(idProp.GetString(), out Guid histId)) break; - if (!json.RootElement.TryGetProperty("status", out var statusProp) - || !Enum.TryParse(statusProp.GetString(), ignoreCase: true, out WheelSpinHistoryStatus newStatus)) + if (!json.RootElement.TryGetProperty("status", out JsonElement statusProp) + || !Enum.TryParse(statusProp.GetString(), true, out WheelSpinHistoryStatus newStatus)) break; - await using var db = await _factory.CreateDbContextAsync(); - var history = await db.WheelSpinHistories + await using AppDbContext db = await _factory.CreateDbContextAsync(); + WheelSpinHistory? history = await db.WheelSpinHistories .Include(h => h.LinkedItem).ThenInclude(i => i!.Action) .Include(h => h.LinkedWheel) .FirstOrDefaultAsync(h => h.Id == histId); @@ -649,24 +590,21 @@ private async Task Listen(IWebSocketClient socket) history.UpdatedAt = DateTime.Now; await db.SaveChangesAsync(); - int spinsOwed = StateValueHelper.Get(db, StateKeys.WheelSpinsOwed); + var spinsOwed = StateValueHelper.Get(db, StateKeys.WheelSpinsOwed); WheelEvents.RaiseWheelSpinStatusChanged(history, spinsOwed); break; } } } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, ex.Message); } } } - private static object? WheelItemToObject(WheelItem? item) - { + private static object? WheelItemToObject(WheelItem? item) { if (item == null) return null; - return new - { + return new { id = item.Id, text = item.Text, weight = item.Weight, @@ -674,18 +612,17 @@ private async Task Listen(IWebSocketClient socket) is_infinite = item.IsInfinite, enabled = item.Enabled, index = item.Index, - action = item.Action == null ? null : (object?)new - { - type = item.Action.ActionType.ToString(), - parameter = item.Action.Parameter - } + action = item.Action == null + ? null + : (object?)new { + type = item.Action.ActionType.ToString(), + parameter = item.Action.Parameter + } }; } - private void SendWheelSpinStarted(WheelSet wheel, int delaySeconds) - { - var data = new - { + private void SendWheelSpinStarted(WheelSet wheel, int delaySeconds) { + var data = new { type = "wheel_spin_start", wheel_id = wheel.Id, wheel_name = wheel.Name, @@ -695,16 +632,13 @@ private void SendWheelSpinStarted(WheelSet wheel, int delaySeconds) BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } - private void SendWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int _) - { - var itemSnapshot = WheelItemToObject(item); - var data = new - { + private void SendWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int _) { + object? itemSnapshot = WheelItemToObject(item); + var data = new { type = "wheel_spin_result", wheel = new { id = wheel.Id, name = wheel.Name }, item = itemSnapshot, - history = new - { + history = new { id = history.Id, status = history.Status.ToString(), created_at = history.CreatedAt, @@ -715,11 +649,9 @@ private void SendWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHisto BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } - private void SendWheelSpinStatusChanged(WheelSpinHistory history, int _) - { - var itemSnapshot = WheelItemToObject(history.LinkedItem); - var data = new - { + private void SendWheelSpinStatusChanged(WheelSpinHistory history, int _) { + object? itemSnapshot = WheelItemToObject(history.LinkedItem); + var data = new { type = "wheel_spin_status", history_id = history.Id, status = history.Status.ToString(), @@ -729,15 +661,13 @@ private void SendWheelSpinStatusChanged(WheelSpinHistory history, int _) BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } - private void SendWheelDataChanged(WheelSet wheel, int spinsOwed) - { + private void SendWheelDataChanged(WheelSet wheel, int spinsOwed) { // snapshot - var wheelId = wheel.Id; - var wheelName = wheel.Name; - var spinCount = wheel.SpinCount; - var items = wheel.WheelItems.Select(WheelItemToObject).ToList(); - var data = new - { + Guid wheelId = wheel.Id; + string wheelName = wheel.Name; + int spinCount = wheel.SpinCount; + List items = wheel.WheelItems.Select(WheelItemToObject).ToList(); + var data = new { type = "wheel_data", wheel = new { id = wheelId, name = wheelName, spin_count = spinCount, items }, spins_owed = spinsOwed @@ -745,159 +675,380 @@ private void SendWheelDataChanged(WheelSet wheel, int spinsOwed) BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } - private void BroadcastObject(object data, OutboundCoalesceKey key, params WebsocketClientMessageType[] types) - => Broadcast(JsonSerializer.Serialize(data), key, types); + private void BroadcastObject(object data, OutboundCoalesceKey key, params WebsocketClientMessageType[] types) { + Broadcast(JsonSerializer.Serialize(data), key, types); + } - private void BroadcastObject(object data, params WebsocketClientMessageType[] types) - => Broadcast(JsonSerializer.Serialize(data), OutboundCoalesceKey.None, types); + private void BroadcastObject(object data, params WebsocketClientMessageType[] types) { + Broadcast(JsonSerializer.Serialize(data), OutboundCoalesceKey.None, types); + } - private void Broadcast(string json, OutboundCoalesceKey key, params WebsocketClientMessageType[] types) - { + private void Broadcast(string json, OutboundCoalesceKey key, params WebsocketClientMessageType[] types) { byte[] bytes = Encoding.UTF8.GetBytes(json); List clientsCopy; - lock (_lock) + lock (_lock) { clientsCopy = _clients.ToList(); + } - foreach (var ws in clientsCopy.Where(ws => + foreach (IWebSocketClient ws in clientsCopy.Where(ws => ws.State == WebSocketState.Open && ws.ClientTypes.Any(types.Contains))) - { ws.TryEnqueue(bytes, key); - } } - internal Task SelectSendAsync(IWebSocketClient client, object data) - => SelectSendStringAsync(client, JsonSerializer.Serialize(data)); + internal Task SelectSendAsync(IWebSocketClient client, object data) { + return SelectSendStringAsync(client, JsonSerializer.Serialize(data)); + } - internal Task SelectSendStringAsync(IWebSocketClient client, string data) - { + internal Task SelectSendStringAsync(IWebSocketClient client, string data) { if (client.State == WebSocketState.Open) client.TryEnqueue(Encoding.UTF8.GetBytes(data)); return Task.CompletedTask; } - public string GetWebsocketInjectionScript(string? routeId = "") - { - string script = $@" - - "; - return script; - } -} \ No newline at end of file + public string GetWebsocketInjectionScript(string? routeId = "") { + string route = routeId ?? string.Empty; + bool isOverlay = !string.IsNullOrWhiteSpace(route); + + return ClientScriptTemplate + .Replace("__WS_URL__", $"ws://localhost:{Port}/ws") + .Replace("__ROUTE_ID__", route) + .Replace("__EMPTY_GUID__", Guid.Empty.ToString()) + .Replace("__IS_OVERLAY__", isOverlay ? "true" : "false"); + } + + private const string ClientScriptTemplate = """ + + """; +} diff --git a/SubathonManager.Server/WebServer.Webhooks.cs b/SubathonManager.Server/WebServer.Webhooks.cs index 09d47124..7c417a79 100644 --- a/SubathonManager.Server/WebServer.Webhooks.cs +++ b/SubathonManager.Server/WebServer.Webhooks.cs @@ -5,20 +5,16 @@ namespace SubathonManager.Server; -public partial class WebServer -{ - private void SetupWebhookRoutes() - { - foreach (var integration in AppServices.Provider.GetServices()) +public partial class WebServer { + private void SetupWebhookRoutes() { + foreach (IWebhookIntegration integration in AppServices.Provider.GetServices()) RegisterWebhookRoute(integration); } - private void RegisterWebhookRoute(IWebhookIntegration integration) - { - var captured = integration; - _routes.Add((new RouteKey("POST", captured.WebhookPath), async ctx => - { - var headers = ctx.Headers; + private void RegisterWebhookRoute(IWebhookIntegration integration) { + IWebhookIntegration captured = integration; + _routes.Add((new RouteKey("POST", captured.WebhookPath), async ctx => { + IReadOnlyDictionary headers = ctx.Headers; using var ms = new MemoryStream(); await ctx.Body.CopyToAsync(ms); byte[] rawBody = ms.ToArray(); @@ -27,4 +23,4 @@ private void RegisterWebhookRoute(IWebhookIntegration integration) })); _logger?.LogDebug("Registered webhook route: POST {Path}", captured.WebhookPath); } -} +} \ No newline at end of file diff --git a/SubathonManager.Server/WebServer.cs b/SubathonManager.Server/WebServer.cs index 08ed0e84..890338ad 100644 --- a/SubathonManager.Server/WebServer.cs +++ b/SubathonManager.Server/WebServer.cs @@ -4,100 +4,54 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using SubathonManager.Data; -using SubathonManager.Core.Models; using SubathonManager.Core; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Data; using SubathonManager.Server.Interfaces; namespace SubathonManager.Server; -public partial class WebServer : IAppService -{ +public partial class WebServer : IAppService { + private readonly IConfig _config; + // ReSharper disable once InconsistentNaming internal readonly IDbContextFactory _factory; - public int Port { get; set; } - private WebApplication? _app; - public bool Running { get; private set; } - - private readonly HashSet _servedFolders = new(); private readonly ILogger? _logger; - private readonly IConfig _config; - private SubathonValueConfigHelper _valueHelper; - record RouteKey(string Method, string Pattern); private readonly List<(RouteKey key, Func handler)> _routes = new(); - - internal Task InvokeHandleRequest(IHttpContext ctx) - => HandleRequestAsync(ctx); - internal object InvokeBuildDataSummary(List e) - => BuildDataSummary(e); + private readonly HashSet _servedFolders = new(); + private WebApplication? _app; + private readonly SubathonValueConfigHelper _valueHelper; - public WebServer(ILogger? logger, IConfig config, IDbContextFactory factory) - { + public WebServer(ILogger? logger, IConfig config, IDbContextFactory factory) { _factory = factory; _config = config; // unused but handy to have for future _logger = logger ?? AppServices.Provider.GetRequiredService>(); - _valueHelper = new(factory, null); - using (var db = _factory.CreateDbContext()) - { - var routes = db.Routes.ToList(); + _valueHelper = new SubathonValueConfigHelper(factory, null); + using (AppDbContext db = _factory.CreateDbContext()) { + List routes = db.Routes.ToList(); if (routes.Count == 0) - { _logger?.LogDebug("No routes found"); - } - else { - foreach (var route in routes) - { + else + foreach (Route route in routes) AddRoute(route); - } - } } - - Port = int.Parse(_config.Get("Server", "Port", "14040")!); - } - internal void Initialize() - { - StopServerAsync().GetAwaiter().GetResult(); - - _routes.Clear(); - SetupApiRoutes(); - SetupWebhookRoutes(); - SetupOverlayRoutes(); - SetupWebsocketListeners(); - - var builder = WebApplication.CreateSlimBuilder(); - builder.Logging.ClearProviders(); - - builder.Services.AddSingleton(); - builder.WebHost.UseUrls($"http://127.0.0.1:{Port}"); - - var app = builder.Build(); - app.UseWebSockets(); - app.Run(ctx => HandleRequestAsync(new AspNetContextAdapter(ctx))); - _app = app; + Port = int.Parse(_config.Get("Server", "Port", "14040")!); } - private void SetupOverlayRoutes() - { - _routes.Add((new RouteKey("GET", "/ws"),HandleWebSocketRequestAsync)); - _routes.Add((new RouteKey("GET", "/widget/"),HandleWidgetRequest )); - _routes.Add((new RouteKey("GET", "/route/"),HandleRouteRequest )); - _routes.Add((new RouteKey("GET", ResourcePaths.UrlPrefix),HandleResourceRequest )); - } + public int Port { get; set; } + public bool Running { get; private set; } - public async Task StartAsync(CancellationToken cancellationToken = default) - { + public async Task StartAsync(CancellationToken cancellationToken = default) { Port = int.Parse(_config.Get("Server", "Port", "14040")!); const int maxAttempts = 3; - for (int attempt = 1; attempt <= maxAttempts; attempt++) - { - try - { + for (var attempt = 1; attempt <= maxAttempts; attempt++) + try { Initialize(); await _app!.StartAsync(cancellationToken); Running = true; @@ -105,8 +59,7 @@ public async Task StartAsync(CancellationToken cancellationToken = default) WebServerEvents.RaiseWebServerStatusChange(Running); return; } - catch (Exception ex) - { + catch (Exception ex) { Running = false; await StopServerAsync(); @@ -115,110 +68,139 @@ public async Task StartAsync(CancellationToken cancellationToken = default) if (attempt < maxAttempts) await Task.Delay(250, cancellationToken); } - } _logger?.LogError("WebServer could not start on port {Port} after {Max} attempts", Port, maxAttempts); WebServerEvents.RaiseWebServerStatusChange(Running); } - private async Task StopServerAsync() - { + public async Task StopAsync(CancellationToken cancellationToken = default) { + Running = false; + await StopServerAsync(); + WebServerEvents.RaiseWebServerStatusChange(Running); + } + + internal Task InvokeHandleRequest(IHttpContext ctx) { + return HandleRequestAsync(ctx); + } + + internal object InvokeBuildDataSummary(List e) { + return BuildDataSummary(e); + } + + internal void Initialize() { + StopServerAsync().GetAwaiter().GetResult(); + + _routes.Clear(); + SetupApiRoutes(); + SetupWebhookRoutes(); + SetupOverlayRoutes(); + SetupWebsocketListeners(); + + WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + + builder.Services.AddSingleton(); + builder.WebHost.UseUrls($"http://127.0.0.1:{Port}"); + + WebApplication app = builder.Build(); + app.UseWebSockets(); + app.Run(ctx => HandleRequestAsync(new AspNetContextAdapter(ctx))); + _app = app; + } + + private void SetupOverlayRoutes() { + _routes.Add((new RouteKey("GET", "/ws"), HandleWebSocketRequestAsync)); + _routes.Add((new RouteKey("GET", "/widget/"), HandleWidgetRequest)); + _routes.Add((new RouteKey("GET", "/route/"), HandleRouteRequest)); + _routes.Add((new RouteKey("GET", ResourcePaths.UrlPrefix), HandleResourceRequest)); + } + + private async Task StopServerAsync() { StopWebsocketServer(); if (_app == null) return; - var app = _app; + WebApplication? app = _app; _app = null; - try - { + try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); await app.StopAsync(cts.Token); } - catch { /**/ } - try { await app.DisposeAsync(); } catch { /**/ } + catch { + /**/ + } + + try { + await app.DisposeAsync(); + } + catch { + /**/ + } } - public void Stop() - { + public void Stop() { Running = false; - try - { + try { StopServerAsync().GetAwaiter().GetResult(); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "WebServer Error on Stopping"); } - finally - { + finally { WebServerEvents.RaiseWebServerStatusChange(Running); } } - public async Task StopAsync(CancellationToken cancellationToken = default) - { - Running = false; - await StopServerAsync(); - WebServerEvents.RaiseWebServerStatusChange(Running); - } - - internal Func? MatchRoute(string method, string path) - { + internal Func? MatchRoute(string method, string path) { return _routes .Where(r => r.key.Method == method && path.StartsWith(r.key.Pattern)) .Select(r => r.handler) .FirstOrDefault(); } - - private async Task HandleRequestAsync(IHttpContext ctx) - { + + private async Task HandleRequestAsync(IHttpContext ctx) { string path = ctx.Path ?? "/"; - var method = ctx.Method; + string method = ctx.Method; if (path != "/ws") _logger?.LogDebug($"Request:[{method}] {path}"); else _logger?.LogTrace($"Request: [{method}] {path}"); - - - bool handled = false; - if (path.Contains("/externalPath/")) - { + + var handled = false; + + if (path.Contains("/externalPath/")) { path = path.Split("/externalPath/").Last(); - if (File.Exists(path)) - { + if (File.Exists(path)) { await ctx.ServeFile(path, GetContentType(path)); return; } + await ctx.WriteResponse(400, "File not found"); return; } - - var routeHandler = MatchRoute(method, path); - if (routeHandler != null) - { + + Func? routeHandler = MatchRoute(method, path); + if (routeHandler != null) { handled = true; await routeHandler(ctx); } if (handled) return; - + // Check if request is for a local file in a *known* widget folder // so we can load properly resources the html desires - foreach (var folder in _servedFolders) - { - var fixedFolder = folder.Replace("\\", "/"); - if (path.Contains(fixedFolder) && File.Exists(path.TrimStart('/'))) - { + foreach (string folder in _servedFolders) { + string fixedFolder = folder.Replace("\\", "/"); + if (path.Contains(fixedFolder) && File.Exists(path.TrimStart('/'))) { await ctx.ServeFile(path.TrimStart('/'), GetContentType(path)); return; } } - int code = 404; - string msg = "404 Not Found"; - if (path.StartsWith("/api")) - { + var code = 404; + var msg = "404 Not Found"; + if (path.StartsWith("/api")) { code = 400; msg = "Invalid API Request"; } @@ -226,54 +208,53 @@ private async Task HandleRequestAsync(IHttpContext ctx) await ctx.WriteResponse(code, msg); } - internal static string GetContentType(string filePath) - { + internal static string GetContentType(string filePath) { string ext = Path.GetExtension(filePath).ToLower(); - string contentType = ext switch - { + string contentType = ext switch { // web standard ".html" or ".htm" => "text/html", ".css" => "text/css", ".js" => "application/javascript", ".json" => "application/json", - + // img ".png" => "image/png", ".jpg" or ".jpeg" => "image/jpeg", ".gif" => "image/gif", ".webp" => "image/webp", ".avif" => "image/avif", - ".bmp" => "image/bmp", - ".svg" => "image/svg+xml", - ".ico" => "image/x-icon", - + ".bmp" => "image/bmp", + ".svg" => "image/svg+xml", + ".ico" => "image/x-icon", + // video ".mp4" => "video/mp4", ".m4v" => "video/x-m4v", ".webm" => "video/webm", ".ogv" => "video/ogg", - + // audio ".mp3" => "audio/mpeg", ".wav" => "audio/wav", ".ogg" or ".oga" => "audio/ogg", ".opus" => "audio/opus", ".m4a" => "audio/mp4", - + // local fonts ".woff" => "font/woff", ".woff2" => "font/woff2", ".ttf" => "font/ttf", ".otf" => "font/otf", - + // idk but other files ".txt" => "text/plain", ".csv" => "text/csv", ".xml" => "application/xml", - + _ => "application/octet-stream" }; return contentType; } + private record RouteKey(string Method, string Pattern); } \ No newline at end of file diff --git a/SubathonManager.Server/WebSocketClient.cs b/SubathonManager.Server/WebSocketClient.cs index fedefdeb..b29c5115 100644 --- a/SubathonManager.Server/WebSocketClient.cs +++ b/SubathonManager.Server/WebSocketClient.cs @@ -1,48 +1,60 @@ using System.Net.WebSockets; using Microsoft.Extensions.Logging; using SubathonManager.Core.Enums; +using SubathonManager.Server.Interfaces; namespace SubathonManager.Server; -public class WebSocketClient : IWebSocketClient -{ - private readonly WebSocket _socket; - private Guid _clientId = Guid.NewGuid(); +public class WebSocketClient : IWebSocketClient { private readonly WebSocketOutboundQueue _outbound; + private readonly WebSocket _socket; - public WebSocketClient(WebSocket socket, ILogger? logger = null) - { + public WebSocketClient(WebSocket socket, ILogger? logger = null) { _socket = socket; - _outbound = new WebSocketOutboundQueue(_clientId, + _outbound = new WebSocketOutboundQueue(ClientId, (buffer, ct) => _socket.SendAsync(buffer, WebSocketMessageType.Text, true, ct), () => _socket.State, Abort, logger); } public List IntegrationSources { get; } = new(); - public Guid ClientId => _clientId; + public Guid ClientId { get; } = Guid.NewGuid(); - public List ClientTypes { get; set; } = new(){ WebsocketClientMessageType.Generic }; + public List ClientTypes { get; set; } = new() { WebsocketClientMessageType.Generic }; public WebSocketState State => _socket.State; public Task SendAsync(ArraySegment buffer, WebSocketMessageType messageType, bool endOfMessage, - CancellationToken cancellationToken) => _socket.SendAsync(buffer, messageType, endOfMessage, cancellationToken); + CancellationToken cancellationToken) { + return _socket.SendAsync(buffer, messageType, endOfMessage, cancellationToken); + } - public Task ReceiveAsync(ArraySegment buffer, CancellationToken cancellationToke) - => _socket.ReceiveAsync(buffer, cancellationToke); + public Task ReceiveAsync(ArraySegment buffer, CancellationToken cancellationToke) { + return _socket.ReceiveAsync(buffer, cancellationToke); + } - public Task CloseAsync(WebSocketCloseStatus closeStatus, string statusDescription, CancellationToken cancellationToken) - => _socket.CloseAsync(closeStatus, statusDescription, cancellationToken); + public Task CloseAsync(WebSocketCloseStatus closeStatus, string statusDescription, + CancellationToken cancellationToken) { + return _socket.CloseAsync(closeStatus, statusDescription, cancellationToken); + } - public void Abort() => _socket.Abort(); + public void Abort() { + _socket.Abort(); + } - public void StartOutbound() => _outbound.Start(); + public void StartOutbound() { + _outbound.Start(); + } - public bool TryEnqueue(byte[] payload, OutboundCoalesceKey key = OutboundCoalesceKey.None) - => _outbound.TryEnqueue(payload, key); + public bool TryEnqueue(byte[] payload, OutboundCoalesceKey key = OutboundCoalesceKey.None) { + return _outbound.TryEnqueue(payload, key); + } - public void CompleteOutbound() => _outbound.Complete(); + public void CompleteOutbound() { + _outbound.Complete(); + } - public Task CompleteOutboundAsync(TimeSpan drainTimeout) => _outbound.CompleteAsync(drainTimeout); -} + public Task CompleteOutboundAsync(TimeSpan drainTimeout) { + return _outbound.CompleteAsync(drainTimeout); + } +} \ No newline at end of file diff --git a/SubathonManager.Server/WebSocketOutboundQueue.cs b/SubathonManager.Server/WebSocketOutboundQueue.cs index 4f96dd5b..b6b21223 100644 --- a/SubathonManager.Server/WebSocketOutboundQueue.cs +++ b/SubathonManager.Server/WebSocketOutboundQueue.cs @@ -4,8 +4,7 @@ namespace SubathonManager.Server; -public enum OutboundCoalesceKey -{ +public enum OutboundCoalesceKey { None = 0, SubathonTimer, SubathonTotals, @@ -13,46 +12,41 @@ public enum OutboundCoalesceKey GoalsList } -internal sealed class WebSocketOutboundQueue -{ +internal sealed class WebSocketOutboundQueue { private const int Capacity = 256; private static readonly long StallTimeoutMs = (long)TimeSpan.FromSeconds(30).TotalMilliseconds; - private readonly record struct Slot(byte[]? Payload, OutboundCoalesceKey Key); - private static readonly int KeyCount = Enum.GetValues().Length; + private readonly Guid _clientId; + + private readonly byte[]?[] _latest = new byte[]?[KeyCount]; + private readonly ILogger? _logger; + private readonly int[] _marked = new int[KeyCount]; + private readonly Action _onStalled; private readonly Channel _queue = Channel.CreateBounded( - new BoundedChannelOptions(Capacity) - { + new BoundedChannelOptions(Capacity) { FullMode = BoundedChannelFullMode.Wait, SingleReader = true, SingleWriter = false, AllowSynchronousContinuations = false }); - private readonly byte[]?[] _latest = new byte[]?[KeyCount]; - private readonly int[] _marked = new int[KeyCount]; - private readonly Func, CancellationToken, Task> _send; private readonly Func _state; - private readonly Action _onStalled; - private readonly ILogger? _logger; - private readonly Guid _clientId; + private int _completed; private long _fullSinceTicks; + private Task _pump = Task.CompletedTask; private int _stalledFired; private int _started; - private int _completed; - private Task _pump = Task.CompletedTask; internal WebSocketOutboundQueue( Guid clientId, Func, CancellationToken, Task> send, Func state, Action onStalled, - ILogger? logger) - { + ILogger? logger) { _clientId = clientId; _send = send; _state = state; @@ -60,20 +54,18 @@ internal WebSocketOutboundQueue( _logger = logger; } - internal void Start() - { + internal void Start() { if (Interlocked.Exchange(ref _started, 1) == 1) return; _pump = Task.Run(PumpAsync); } - internal bool TryEnqueue(byte[] payload, OutboundCoalesceKey key = OutboundCoalesceKey.None) - { + internal bool TryEnqueue(byte[] payload, OutboundCoalesceKey key = OutboundCoalesceKey.None) { if (Volatile.Read(ref _completed) == 1) return false; if (key == OutboundCoalesceKey.None) return _queue.Writer.TryWrite(new Slot(payload, key)) ? NoteWritten() : NoteFull(); - int i = (int)key; + var i = (int)key; Interlocked.Exchange(ref _latest[i], payload); if (Interlocked.Exchange(ref _marked[i], 1) == 1) return true; @@ -84,20 +76,17 @@ internal bool TryEnqueue(byte[] payload, OutboundCoalesceKey key = OutboundCoale return NoteFull(); } - private bool NoteWritten() - { + private bool NoteWritten() { Volatile.Write(ref _fullSinceTicks, 0); return true; } - private bool NoteFull() - { + private bool NoteFull() { long now = Environment.TickCount64; long prev = Interlocked.CompareExchange(ref _fullSinceTicks, now, 0); long fullSince = prev == 0 ? now : prev; - if (now - fullSince >= StallTimeoutMs && Interlocked.Exchange(ref _stalledFired, 1) == 0) - { + if (now - fullSince >= StallTimeoutMs && Interlocked.Exchange(ref _stalledFired, 1) == 0) { _logger?.LogWarning( "WebSocket client [{ClientId}] outbound queue has been full for over 30s. dropping connection", _clientId); @@ -107,50 +96,42 @@ private bool NoteFull() return false; } - private async Task PumpAsync() - { - try - { + private async Task PumpAsync() { + try { while (await _queue.Reader.WaitToReadAsync().ConfigureAwait(false)) - { - while (_queue.Reader.TryRead(out Slot slot)) - { - byte[]? payload = slot.Payload; - if (payload is null) - { - int i = (int)slot.Key; - Interlocked.Exchange(ref _marked[i], 0); - payload = Interlocked.Exchange(ref _latest[i], null); - if (payload is null) continue; - } - - if (_state() != WebSocketState.Open) return; - await _send(payload, CancellationToken.None).ConfigureAwait(false); + while (_queue.Reader.TryRead(out Slot slot)) { + byte[]? payload = slot.Payload; + if (payload is null) { + var i = (int)slot.Key; + Interlocked.Exchange(ref _marked[i], 0); + payload = Interlocked.Exchange(ref _latest[i], null); + if (payload is null) continue; } + + if (_state() != WebSocketState.Open) return; + await _send(payload, CancellationToken.None).ConfigureAwait(false); } } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogDebug(ex, "WebSocket client [{ClientId}] outbound pump stopped", _clientId); } } - internal void Complete() - { + internal void Complete() { if (Interlocked.Exchange(ref _completed, 1) == 1) return; _queue.Writer.TryComplete(); } - internal async Task CompleteAsync(TimeSpan drainTimeout) - { + internal async Task CompleteAsync(TimeSpan drainTimeout) { Complete(); - try - { + try { await _pump.WaitAsync(drainTimeout).ConfigureAwait(false); } - catch (TimeoutException) - { - _logger?.LogDebug("WebSocket client [{ClientId}] outbound queue did not drain time before timeout", _clientId); + catch (TimeoutException) { + _logger?.LogDebug("WebSocket client [{ClientId}] outbound queue did not drain time before timeout", + _clientId); } } -} + + private readonly record struct Slot(byte[]? Payload, OutboundCoalesceKey Key); +} \ No newline at end of file diff --git a/SubathonManager.Services/CommandService.cs b/SubathonManager.Services/CommandService.cs index a2e69063..9a64be83 100644 --- a/SubathonManager.Services/CommandService.cs +++ b/SubathonManager.Services/CommandService.cs @@ -1,36 +1,34 @@ -using Microsoft.Extensions.DependencyInjection; -using SubathonManager.Core.Enums; +using IniParser.Model; +using Microsoft.Extensions.DependencyInjection; using SubathonManager.Core; +using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Services; -public static class CommandService -{ - internal static IConfig? AppConfig { get; private set; } - - static CommandService() - { +public static class CommandService { + static CommandService() { AppConfig ??= AppServices.Provider?.GetRequiredService()!; } - - public static bool ChatCommandRequest(SubathonEventSource source, string message, string user, + + internal static IConfig? AppConfig { get; private set; } + + public static bool ChatCommandRequest(SubathonEventSource source, string message, string user, bool isBroadcaster, bool isModerator, bool isVip, DateTime? timestamp, Guid? overrideGuid = null, - SubathonCommandType cmdOverride = SubathonCommandType.None) - { + SubathonCommandType cmdOverride = SubathonCommandType.None) { timestamp ??= DateTime.Now; if (source.IsExternalSource()) message = $"EXTERNAL {message}"; SubathonCommandType command = ValidateCommand(message, cmdOverride); if (command == SubathonCommandType.Unknown) return false; - - SubathonEvent subathonEvent = new SubathonEvent - { + + var subathonEvent = new SubathonEvent { Source = source, EventTimestamp = timestamp.Value, Command = command, @@ -56,67 +54,59 @@ public static bool ChatCommandRequest(SubathonEventSource source, string message return true; } - internal static void SetConfig(IConfig config) - { + internal static void SetConfig(IConfig config) { AppConfig = config; } private static bool ValidateUser(SubathonEvent subathonEvent, string user, bool isBroadcaster, - bool isModerator, bool isVip) - { + bool isModerator, bool isVip) { if (isBroadcaster) return true; - string configKey = $"Commands.{subathonEvent.Command}.permissions"; - if (isModerator && AppConfig!.GetBool("Chat", $"{configKey}.Mods", false)) + var configKey = $"Commands.{subathonEvent.Command}.permissions"; + if (isModerator && AppConfig!.GetBool("Chat", $"{configKey}.Mods")) return true; - if (isVip && AppConfig!.GetBool("Chat", $"{configKey}.VIPs", false)) + if (isVip && AppConfig!.GetBool("Chat", $"{configKey}.VIPs")) return true; - + string[] whitelist = AppConfig!.Get("Chat", $"{configKey}.Whitelist")!.ToLower() .Split(',').Select(s => s.Trim()).ToArray(); if (whitelist.Contains(user.ToLower().Trim())) return true; - + return false; } - private static SubathonCommandType ValidateCommand(string message, SubathonCommandType cmdOverride) - { + private static SubathonCommandType ValidateCommand(string message, SubathonCommandType cmdOverride) { if (cmdOverride != SubathonCommandType.None && cmdOverride != SubathonCommandType.Unknown) return cmdOverride; string[] parts = message.Split(' '); - string cmdName = parts[0].Substring(1, parts[0].Length -1 ).Trim(); - - foreach (var keyData in AppConfig!.GetSection("Chat")!) - { + string cmdName = parts[0].Substring(1, parts[0].Length - 1).Trim(); + + foreach (KeyData? keyData in AppConfig!.GetSection("Chat")!) { if (!keyData.KeyName.StartsWith("Commands.")) continue; if (!keyData.Value.Equals(cmdName, StringComparison.InvariantCultureIgnoreCase)) continue; - if (Enum.TryParse(keyData.KeyName.Split('.')[1] ?? "Unknown", + if (Enum.TryParse(keyData.KeyName.Split('.')[1] ?? "Unknown", out SubathonCommandType command)) return command; } - + return SubathonCommandType.Unknown; } - private static bool ValidateParameters(SubathonEvent subathonEvent, string message) - { + private static bool ValidateParameters(SubathonEvent subathonEvent, string message) { string[] parts = message.Split(' '); if (subathonEvent.Command.IsParametersRequired() && parts.Length <= 1) return false; - if (!subathonEvent.Command.IsParametersRequired()) - { + if (!subathonEvent.Command.IsParametersRequired()) { subathonEvent.Value = $"{subathonEvent.Command}"; return true; } - bool isValid = false; - switch (subathonEvent.Command) - { + var isValid = false; + switch (subathonEvent.Command) { case SubathonCommandType.AddMoney: case SubathonCommandType.SubtractMoney: - if (parts.Length >= 3 && double.TryParse(parts[1], out double value)) - { + if (parts.Length >= 3 && double.TryParse(parts[1], out double value)) { if (value <= 0) break; var currencyService = AppServices.Provider.GetRequiredService(); string currency = parts[2]; @@ -126,29 +116,28 @@ private static bool ValidateParameters(SubathonEvent subathonEvent, string messa // event service sets it from command to donation adjustment isValid = true; } + break; case SubathonCommandType.AddPoints: case SubathonCommandType.SubtractPoints: case SubathonCommandType.SetPoints: - if (parts.Length >= 2 && int.TryParse(parts[1], out var parsedInt1)) - { + if (parts.Length >= 2 && int.TryParse(parts[1], out int parsedInt1)) { subathonEvent.Value = $"{subathonEvent.Command} {parsedInt1}"; subathonEvent.PointsValue = parsedInt1; subathonEvent.SecondsValue = 0; - isValid = parsedInt1 > 0 && subathonEvent.Command != SubathonCommandType.SetPoints || + isValid = (parsedInt1 > 0 && subathonEvent.Command != SubathonCommandType.SetPoints) || subathonEvent.Command == SubathonCommandType.SetPoints; } - break; + break; case SubathonCommandType.SetSpins: case SubathonCommandType.AddSpins: case SubathonCommandType.SubtractSpins: - if (parts.Length >= 2 && int.TryParse(parts[1], out var parsedInt2)) - { + if (parts.Length >= 2 && int.TryParse(parts[1], out int parsedInt2)) { subathonEvent.Value = $"{subathonEvent.Command} {parsedInt2}"; subathonEvent.Amount = parsedInt2; subathonEvent.SecondsValue = 0; - isValid = parsedInt2 > 0 && subathonEvent.Command != SubathonCommandType.SetSpins || + isValid = (parsedInt2 > 0 && subathonEvent.Command != SubathonCommandType.SetSpins) || subathonEvent.Command == SubathonCommandType.SetSpins; } @@ -166,17 +155,14 @@ private static bool ValidateParameters(SubathonEvent subathonEvent, string messa isValid = true; break; case SubathonCommandType.SetMultiplier: - double multiplier = double.MinValue; - bool applyTime = false; - bool applyPoints = false; - string durationString = ""; - - foreach (string part in parts) - { - if (part.ToLower().Contains('x') && multiplier <= double.MinValue + 5) - { - if (!double.TryParse(part.ToLower().Split('x')[0].Trim(), out multiplier)) - { + var multiplier = double.MinValue; + var applyTime = false; + var applyPoints = false; + var durationString = ""; + + foreach (string part in parts) { + if (part.ToLower().Contains('x') && multiplier <= double.MinValue + 5) { + if (!double.TryParse(part.ToLower().Split('x')[0].Trim(), out multiplier)) { multiplier = double.MinValue; continue; } @@ -188,24 +174,20 @@ private static bool ValidateParameters(SubathonEvent subathonEvent, string messa } if (!part.StartsWith('!') && !part.ToLower().Contains('x') && part.Any(c => char.IsDigit(c))) - { durationString += part; - } } - if ((!applyPoints && !applyTime) || (multiplier.Equals(1) || !(multiplier > 0))) - { + if ((!applyPoints && !applyTime) || multiplier.Equals(1) || !(multiplier > 0)) { subathonEvent.Value = $"{SubathonCommandType.SetMultiplier} Failed"; subathonEvent.Command = SubathonCommandType.StopMultiplier; isValid = true; } - else - { + else { if (multiplier <= double.MinValue + 5) return false; TimeSpan duration = Utils.ParseDurationString(durationString); durationString = duration == TimeSpan.Zero ? "x" : ((int)duration.TotalSeconds).ToString(); - string dataStr = $"{multiplier}|{durationString}s|{applyPoints}|{applyTime}"; + var dataStr = $"{multiplier}|{durationString}s|{applyPoints}|{applyTime}"; subathonEvent.Value = dataStr; isValid = true; diff --git a/SubathonManager.Services/CurrencyService.cs b/SubathonManager.Services/CurrencyService.cs index ae797b88..9ddae4f6 100644 --- a/SubathonManager.Services/CurrencyService.cs +++ b/SubathonManager.Services/CurrencyService.cs @@ -1,118 +1,102 @@ using System.Globalization; using System.Text.Json; -using SubathonManager.Core.Events; using Microsoft.Extensions.Logging; -using SubathonManager.Core; +using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; namespace SubathonManager.Services; -public class CurrencyService : IAppService -{ - internal string BaseUrl = "http://www.floatrates.com/daily/"; +public class CurrencyService : IAppService { + private readonly IConfig _config; private readonly HttpClient _httpClient; - private readonly TimeSpan _refreshInterval = TimeSpan.FromHours(24); - internal Dictionary Rates = new(); + private readonly ILogger? _logger; + private readonly TimeSpan _refreshInterval = TimeSpan.FromHours(24); private readonly SemaphoreSlim _refreshLock = new(1, 1); - private string _dataDirectory = Path.GetFullPath(Path.Combine(string.Empty + private readonly string _dataDirectory = Path.GetFullPath(Path.Combine(string.Empty , "data/currency")); - private readonly ILogger? _logger; - private readonly IConfig _config; + internal string BaseUrl = "http://www.floatrates.com/daily/"; - public CurrencyService(ILogger? logger, IConfig config, HttpClient httpClient) - { + internal Dictionary Rates = new(); + + public CurrencyService(ILogger? logger, IConfig config, HttpClient httpClient) { _logger = logger; _config = config; _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); Directory.CreateDirectory(_dataDirectory); } - private string CurrencyFilePath() - { - var defaultCurrency = _config.Get("Currency", "Primary", "USD")!.ToUpperInvariant().Trim(); - string currencyFile = Path.Combine(_dataDirectory, $"{defaultCurrency.ToLowerInvariant().Trim()}.json"); - return currencyFile; - } - - public async Task StartAsync(CancellationToken cancellationToken = default) - { - var currencyFilePath = CurrencyFilePath(); + public async Task StartAsync(CancellationToken cancellationToken = default) { + string currencyFilePath = CurrencyFilePath(); if (File.Exists(currencyFilePath)) - { - try - { + try { await LoadFromFileAsync(); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "Failed to load cached rates from file"); } - } + if (IsExpired()) - { - try - { + try { await FetchBaseAsync(); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "Failed to fetch new rates"); } - } - if (Rates.Count == 0) - { - _logger?.LogError("No exchange rates available (failed to load or fetch). CurrencyService will remain available but conversions may fail."); + if (Rates.Count == 0) { + _logger?.LogError( + "No exchange rates available (failed to load or fetch). CurrencyService will remain available but conversions may fail."); ErrorMessageEvents.RaiseErrorEvent("ERROR", "SYSTEM", "Could not fetch exchange rates for Currency Service. Failures may occur.", DateTime.Now); } } - public Task StopAsync(CancellationToken cancellationToken = default) - { + public Task StopAsync(CancellationToken cancellationToken = default) { return Task.CompletedTask; } - - public async Task> GetValidCurrenciesAsync() - { + + private string CurrencyFilePath() { + string defaultCurrency = _config.Get("Currency", "Primary", "USD")!.ToUpperInvariant().Trim(); + string currencyFile = Path.Combine(_dataDirectory, $"{defaultCurrency.ToLowerInvariant().Trim()}.json"); + return currencyFile; + } + + public async Task> GetValidCurrenciesAsync() { if (Rates.Count == 0) await FetchBaseAsync(); - var defaultCurrency = _config.Get("Currency", "Primary", "USD")!.ToUpperInvariant().Trim(); - var currencies = Rates.Keys.ToList(); + string defaultCurrency = _config.Get("Currency", "Primary", "USD")!.ToUpperInvariant().Trim(); + List currencies = Rates.Keys.ToList(); currencies.Add(defaultCurrency); return currencies; } - - public bool IsValidCurrency(string? currency) - { + + public bool IsValidCurrency(string? currency) { if (string.IsNullOrWhiteSpace(currency)) return false; currency = currency.ToUpperInvariant().Trim(); - var defaultCurrency = _config.Get("Currency", "Primary", "USD")!.ToUpperInvariant().Trim(); + string defaultCurrency = _config.Get("Currency", "Primary", "USD")!.ToUpperInvariant().Trim(); return currency == defaultCurrency || Rates.ContainsKey(currency); } - + private bool IsExpired() { - var currencyFilePath = CurrencyFilePath(); + string currencyFilePath = CurrencyFilePath(); if (!File.Exists(currencyFilePath)) return true; - var lastUpdated = File.GetLastWriteTimeUtc(currencyFilePath); + DateTime lastUpdated = File.GetLastWriteTimeUtc(currencyFilePath); return lastUpdated < DateTime.UtcNow - _refreshInterval; } - - private async Task FetchBaseAsync() - { + + private async Task FetchBaseAsync() { await _refreshLock.WaitAsync(); - try - { + try { if (!IsExpired()) return; - var defaultCurrency = _config + string defaultCurrency = _config .Get("Currency", "Primary", "USD")! .ToUpperInvariant() .Trim(); @@ -120,35 +104,31 @@ private async Task FetchBaseAsync() string url = BaseUrl + $"{defaultCurrency.ToLowerInvariant()}.json"; string json = await _httpClient.GetStringAsync(url); - var path = CurrencyFilePath(); + string path = CurrencyFilePath(); await File.WriteAllTextAsync(path, json); ParseRatesAsync(json); } - finally - { + finally { _refreshLock.Release(); } } - private async Task LoadFromFileAsync() - { - var currencyFilePath = CurrencyFilePath(); + private async Task LoadFromFileAsync() { + string currencyFilePath = CurrencyFilePath(); string json = await File.ReadAllTextAsync(currencyFilePath); ParseRatesAsync(json); } - - private void ParseRatesAsync(string json) - { - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; + + private void ParseRatesAsync(string json) { + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; Rates.Clear(); - foreach (var item in root.EnumerateObject().Select(kvp => kvp.Value)) - { - if (!item.TryGetProperty("code", out var codeProp) || - !item.TryGetProperty("rate", out var rateProp)) continue; + foreach (JsonElement item in root.EnumerateObject().Select(kvp => kvp.Value)) { + if (!item.TryGetProperty("code", out JsonElement codeProp) || + !item.TryGetProperty("rate", out JsonElement rateProp)) continue; string code = codeProp.GetString()!.ToUpperInvariant(); double rate = rateProp.ValueKind == JsonValueKind.String ? double.Parse(rateProp.GetString()!, CultureInfo.InvariantCulture) @@ -156,43 +136,38 @@ private void ParseRatesAsync(string json) Rates[code] = rate; } } - - public async Task ConvertAsync(double amount, string fromCurrency, string? toCurrency = null) - { + + public async Task ConvertAsync(double amount, string fromCurrency, string? toCurrency = null) { fromCurrency = fromCurrency.ToUpperInvariant().Trim(); - var defaultCurrency = _config.Get("Currency", "Primary", "USD")!.ToUpperInvariant().Trim(); + string defaultCurrency = _config.Get("Currency", "Primary", "USD")!.ToUpperInvariant().Trim(); toCurrency = string.IsNullOrWhiteSpace(toCurrency) ? defaultCurrency : toCurrency.ToUpperInvariant().Trim(); if (fromCurrency == toCurrency) return amount; - try - { + try { await FetchBaseAsync(); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "Failed to refresh rates, using cached data"); } - if (!IsValidCurrency(fromCurrency)) - { + if (!IsValidCurrency(fromCurrency)) { var message = $"{fromCurrency} is not a valid currency. Cannot convert {amount}"; - if (fromCurrency.ToUpperInvariant() is "ITEMS" or "ORDER" or "MEMBER") - { + if (fromCurrency.ToUpperInvariant() is "ITEMS" or "ORDER" or "MEMBER") { _logger?.LogDebug(message); return 0; } + _logger?.LogError(message); - - ErrorMessageEvents.RaiseErrorEvent("ERROR", "CurrencyService", + + ErrorMessageEvents.RaiseErrorEvent("ERROR", "CurrencyService", message, DateTime.Now); return 0; } - if (!IsValidCurrency(toCurrency)) - { + if (!IsValidCurrency(toCurrency)) { var message = $"{toCurrency} is not a valid target currency. Cannot convert {amount} {fromCurrency}"; _logger?.LogError(message); @@ -201,9 +176,8 @@ public async Task ConvertAsync(double amount, string fromCurrency, strin return 0; } - try - { - double fromRate = 1.0; + try { + var fromRate = 1.0; if (fromCurrency != defaultCurrency && !Rates.TryGetValue(fromCurrency, out fromRate)) throw new InvalidOperationException($"Rate for {fromCurrency} not found."); @@ -212,13 +186,12 @@ public async Task ConvertAsync(double amount, string fromCurrency, strin if (toCurrency == defaultCurrency) return baseAmount; - if (!Rates.TryGetValue(toCurrency, out var toRate)) + if (!Rates.TryGetValue(toCurrency, out double toRate)) throw new InvalidOperationException($"Rate for {toCurrency} not found."); return baseAmount * toRate; } - catch (Exception ex) - { + catch (Exception ex) { var message = $"Failed to convert {amount} {fromCurrency} to {toCurrency}"; _logger?.LogError(ex, message); ErrorMessageEvents.RaiseErrorEvent("ERROR", "CurrencyService", @@ -227,10 +200,8 @@ public async Task ConvertAsync(double amount, string fromCurrency, strin return 0; } - - internal void SetRates(Dictionary rates) - { + + internal void SetRates(Dictionary rates) { Rates = rates; } - } \ No newline at end of file diff --git a/SubathonManager.Services/DiscordWebhookService.cs b/SubathonManager.Services/DiscordWebhookService.cs index b46a6ea3..1e90785b 100644 --- a/SubathonManager.Services/DiscordWebhookService.cs +++ b/SubathonManager.Services/DiscordWebhookService.cs @@ -1,58 +1,58 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Net; using System.Text; using System.Text.Json; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Services; -public class DiscordWebhookService : IDisposable, IAppService -{ - private readonly IHttpClientFactory? _httpClientFactory; - private readonly ConcurrentQueue _eventQueue = new(); +public class DiscordWebhookService : IDisposable, IAppService { + private const string AppUsername = "Subathon Manager"; + + private const string AppAvatarUrl = + "https://raw.githubusercontent.com/WolfwithSword/SubathonManager/refs/heads/main/assets/icon.png"; + + private readonly HashSet _auditGoAffProMetas = new(); + private readonly IConfig _config; private readonly ConcurrentQueue _configQueue = new(); - private readonly ConcurrentQueue _wheelQueue = new(); - private readonly ConcurrentQueue _triggerQueue = new(); private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _ctsConfig = new(); - private readonly CancellationTokenSource _ctsWheel = new(); private readonly CancellationTokenSource _ctsTrigger = new(); - - private string? _eventWebhookUrl; // from config - private string? _webhookUrl; // from config - private string? _wheelWebhookUrl; // from config + private readonly CancellationTokenSource _ctsWheel = new(); + private readonly CurrencyService _currencyService; + private readonly ConcurrentQueue _eventQueue = new(); private readonly TimeSpan _flushInterval = TimeSpan.FromMinutes(1); - private readonly int _maxMsgPerMinute = 15; - private Task? _backgroundTask; - private Task? _backgroundConfigTask; - private Task? _backgroundWheelTask; - private Task? _backgroundTriggerTask; + private readonly IHttpClientFactory? _httpClientFactory; - private List _auditEventTypes = new(); - private readonly HashSet _auditGoAffProMetas = new(); - private bool _doSimulatedEvents = false; - private bool _doRemoteValuePatches = false; - private bool _doWheelSpinEvents = false; - private bool _doWheelTriggerEvents = false; - private readonly ILogger? _logger; - private readonly IConfig _config; - private readonly CurrencyService _currencyService; + private readonly int _maxMsgPerMinute = 15; + private readonly ConcurrentQueue _triggerQueue = new(); + private readonly ConcurrentQueue _wheelQueue = new(); - private const string AppUsername = "Subathon Manager"; + private readonly List _auditEventTypes = new(); + private Task? _backgroundConfigTask; + private Task? _backgroundTask; + private Task? _backgroundTriggerTask; + private Task? _backgroundWheelTask; + private bool _doRemoteValuePatches; + private bool _doSimulatedEvents; + private bool _doWheelSpinEvents; + private bool _doWheelTriggerEvents; - private const string AppAvatarUrl = - "https://raw.githubusercontent.com/WolfwithSword/SubathonManager/refs/heads/main/assets/icon.png"; + private string? _eventWebhookUrl; // from config + private string? _webhookUrl; // from config + private string? _wheelWebhookUrl; // from config - public DiscordWebhookService(ILogger? logger, IConfig config, CurrencyService currencyService, IHttpClientFactory? httpClientFactory = null) - { + public DiscordWebhookService(ILogger? logger, IConfig config, + CurrencyService currencyService, IHttpClientFactory? httpClientFactory = null) { _logger = logger; _config = config; _currencyService = currencyService; @@ -60,8 +60,7 @@ public DiscordWebhookService(ILogger? logger, IConfig con LoadFromConfig(); } - public Task StartAsync(CancellationToken cancellationToken = default) - { + public Task StartAsync(CancellationToken cancellationToken = default) { SubathonEvents.SubathonEventProcessed += OnSubathonEventProcessed; SubathonEvents.SubathonEventsDeleted += OnSubathonEventDeleted; ErrorMessageEvents.ErrorEventOccured += SendErrorEvent; @@ -77,43 +76,80 @@ public Task StartAsync(CancellationToken cancellationToken = default) return Task.CompletedTask; } - public void LoadFromConfig() - { - _eventWebhookUrl = _config.Get("Discord", "Events.WebhookUrl", ""); - _webhookUrl = _config.Get("Discord", "WebhookUrl", ""); - _wheelWebhookUrl = _config.Get("Discord", "Wheel.WebhookUrl", ""); + public async Task StopAsync(CancellationToken cancellationToken = default) { + await _cts.CancelAsync(); + if (_backgroundTask != null) + await _backgroundTask; + await _ctsConfig.CancelAsync(); + if (_backgroundConfigTask != null) + await _backgroundConfigTask; + await _ctsWheel.CancelAsync(); + if (_backgroundWheelTask != null) + await _backgroundWheelTask; + await _ctsTrigger.CancelAsync(); + if (_backgroundTriggerTask != null) + await _backgroundTriggerTask; + await FlushQueueAsync(); + await FlushConfigQueueAsync(); + await FlushWheelQueueAsync(); + await FlushTriggerQueueAsync(); + } + + public void Dispose() { + if (!_cts.IsCancellationRequested) + _cts.Cancel(); + if (!_ctsConfig.IsCancellationRequested) + _ctsConfig.Cancel(); + if (!_ctsWheel.IsCancellationRequested) + _ctsWheel.Cancel(); + + SubathonEvents.SubathonEventProcessed -= OnSubathonEventProcessed; + SubathonEvents.SubathonEventsDeleted -= OnSubathonEventDeleted; + ErrorMessageEvents.ErrorEventOccured -= SendErrorEvent; + ErrorMessageEvents.SendCustomEvent -= OnCustomEvent; + SubathonEvents.SubathonValuesPatched -= OnSubathonConfigValuesPatched; + WheelEvents.WheelSpinResult -= OnWheelSpinResult; + WheelEvents.WheelSpinStatusChanged -= OnWheelSpinStatusChanged; + WheelEvents.WheelSpinTriggerFired -= OnWheelSpinTriggerFired; + _cts.Dispose(); + _ctsConfig.Dispose(); + _ctsWheel.Dispose(); + if (!_ctsTrigger.IsCancellationRequested) + _ctsTrigger.Cancel(); + _ctsTrigger.Dispose(); + } + + public void LoadFromConfig() { + _eventWebhookUrl = _config.Get("Discord", "Events.WebhookUrl"); + _webhookUrl = _config.Get("Discord", "WebhookUrl"); + _wheelWebhookUrl = _config.Get("Discord", "Wheel.WebhookUrl"); _auditEventTypes.Clear(); - foreach (SubathonEventType type in Enum.GetValues()) - { + foreach (SubathonEventType type in Enum.GetValues()) { if (type.GetLegacyGoAffProSiteId() > 0) continue; // - if (_config.GetBool("Discord", $"Events.Log.{type}", false)) + if (_config.GetBool("Discord", $"Events.Log.{type}")) _auditEventTypes.Add(type); } _auditGoAffProMetas.Clear(); - foreach (var store in GoAffProStoreRegistry.All()) - { - if (_config.GetBool("Discord", $"Events.Log.GoAffProOrder.{store.SiteId}", false)) + foreach (GoAffProStore store in GoAffProStoreRegistry.All()) + if (_config.GetBool("Discord", $"Events.Log.GoAffProOrder.{store.SiteId}")) _auditGoAffProMetas.Add(store.SiteId.ToString()); - } - _doSimulatedEvents = _config.GetBool("Discord", "Events.Log.Simulated", false); - _doRemoteValuePatches = _config.GetBool("Discord", "Events.Log.RemoteConfig", false); - _doWheelSpinEvents = _config.GetBool("Discord", "Wheel.Log.Enabled", false); - _doWheelTriggerEvents = _config.GetBool("Discord", "Wheel.Log.Triggers", false); + _doSimulatedEvents = _config.GetBool("Discord", "Events.Log.Simulated"); + _doRemoteValuePatches = _config.GetBool("Discord", "Events.Log.RemoteConfig"); + _doWheelSpinEvents = _config.GetBool("Discord", "Wheel.Log.Enabled"); + _doWheelTriggerEvents = _config.GetBool("Discord", "Wheel.Log.Triggers"); } - private bool IsAuditedEventType(SubathonEvent subathonEvent) - { + private bool IsAuditedEventType(SubathonEvent subathonEvent) { if (subathonEvent.EventType == SubathonEventType.GoAffProOrder) return subathonEvent.EventTypeMeta != null && _auditGoAffProMetas.Contains(subathonEvent.EventTypeMeta); return _auditEventTypes.Contains(subathonEvent.EventType ?? SubathonEventType.Unknown); } - private void OnSubathonEventProcessed(SubathonEvent? subathonEvent, bool effective) - { - if (subathonEvent == null || string.IsNullOrEmpty(_eventWebhookUrl) ) return; + private void OnSubathonEventProcessed(SubathonEvent? subathonEvent, bool effective) { + if (subathonEvent == null || string.IsNullOrEmpty(_eventWebhookUrl)) return; if (!IsAuditedEventType(subathonEvent)) return; if (subathonEvent.Source == SubathonEventSource.Simulated && !_doSimulatedEvents) return; // only queue events we care about based on settings @@ -121,33 +157,27 @@ private void OnSubathonEventProcessed(SubathonEvent? subathonEvent, bool effecti _eventQueue.Enqueue(subathonEvent); } - private void OnSubathonConfigValuesPatched(List patched) - { + private void OnSubathonConfigValuesPatched(List patched) { if (!_doRemoteValuePatches) return; - foreach(var v in patched) + foreach (SubathonValueDto v in patched) _configQueue.Enqueue(v); } - private string MakeUsername() - { - string username = $"{AppUsername} - {AppServices.AppVersion}"; + private string MakeUsername() { + var username = $"{AppUsername} - {AppServices.AppVersion}"; return username.Substring(0, Math.Min(username.Length, 42)); } - private void OnCustomEvent(string message) - { - if (string.IsNullOrEmpty(_eventWebhookUrl) ) return; - var payload = new - { + private void OnCustomEvent(string message) { + if (string.IsNullOrEmpty(_eventWebhookUrl)) return; + var payload = new { username = MakeUsername(), avatar_url = AppAvatarUrl, - embeds = new[] - { - new - { + embeds = new[] { + new { title = "INFO", description = $"**Test**\n{message}", - color = 0xE3E3E3 , + color = 0xE3E3E3, timestamp = DateTime.Now.ToString("o") } } @@ -155,23 +185,18 @@ private void OnCustomEvent(string message) Task.Run(async () => { await SendWebhookAsync(payload, _eventWebhookUrl); }); } - private void OnSubathonEventDeleted(List? subathonEvents) - { - if (subathonEvents?.Count > 1 || subathonEvents is [{ Source: SubathonEventSource.Simulated }]) - { + private void OnSubathonEventDeleted(List? subathonEvents) { + if (subathonEvents?.Count > 1 || subathonEvents is [{ Source: SubathonEventSource.Simulated }]) { double totalPoints = 0; double totalSeconds = 0; double totalMoney = 0; - var currency = _config.Get("Currency", "Primary", "USD"); - foreach (var subathonEvent in subathonEvents) - { + string? currency = _config.Get("Currency", "Primary", "USD"); + foreach (SubathonEvent subathonEvent in subathonEvents) { totalPoints += subathonEvent.GetFinalPointsValue(); totalSeconds += subathonEvent.GetFinalSecondsValueRaw(); if (subathonEvent.EventType.IsCurrencyDonation() && - _currencyService.IsValidCurrency(subathonEvent.Currency)) - { - double result = Task.Run(async () => - { + _currencyService.IsValidCurrency(subathonEvent.Currency)) { + double result = Task.Run(async () => { return await _currencyService.ConvertAsync( double.Parse(subathonEvent.Value), subathonEvent.Currency!, currency); @@ -179,29 +204,27 @@ private void OnSubathonEventDeleted(List? subathonEvents) totalMoney += result; } } - - var embed = new - { - title=$"Deleted {subathonEvents.Count} Events", - description=$"**Seconds:** {Math.Round(totalSeconds, 2)}s\n**Points:** {totalPoints}\n**Dollars {currency}:** {Math.Round(totalMoney, 2)}", + + var embed = new { + title = $"Deleted {subathonEvents.Count} Events", + description = + $"**Seconds:** {Math.Round(totalSeconds, 2)}s\n**Points:** {totalPoints}\n**Dollars {currency}:** {Math.Round(totalMoney, 2)}", color = 0x86ACBD, timestamp = DateTime.Now.ToUniversalTime().ToString("o") }; - var payload = new - { + var payload = new { username = MakeUsername(), avatar_url = AppAvatarUrl, - embeds = new[] {embed} + embeds = new[] { embed } }; Task.Run(() => SendWebhookAsync(payload, _eventWebhookUrl) ); } - - if (subathonEvents?.Count == 1) - { + + if (subathonEvents?.Count == 1) { SubathonEvent subathonEvent = subathonEvents.Single().ShallowClone(); if (string.IsNullOrEmpty(_eventWebhookUrl)) return; if (!IsAuditedEventType(subathonEvent)) return; @@ -213,49 +236,37 @@ private void OnSubathonEventDeleted(List? subathonEvents) } } - private async Task ProcessValueQueueAsync() - { - try - { - while (!_ctsConfig.IsCancellationRequested) - { + private async Task ProcessValueQueueAsync() { + try { + while (!_ctsConfig.IsCancellationRequested) { await Task.Delay(_flushInterval, _ctsConfig.Token); await FlushConfigQueueAsync(); } } - catch (OperationCanceledException ex) - { + catch (OperationCanceledException ex) { _logger?.LogDebug(ex, ex.Message); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, ex.Message); - } + } } - + [ExcludeFromCodeCoverage] - private async Task FlushConfigQueueAsync() - { + private async Task FlushConfigQueueAsync() { if (_configQueue.IsEmpty) return; var events = new List(); // we will hard limit this to 5msg per min. 10 embeds per msg - while (events.Count < (5 * 10) && _configQueue.TryDequeue(out var sValue) ) - { - events.Add(sValue); - } + while (events.Count < 5 * 10 && _configQueue.TryDequeue(out SubathonValueDto? sValue)) events.Add(sValue); if (events.Count == 0) return; - - foreach (var batch in events.Chunk(10)) - { - var embeds = batch.Select(e => new - { - title=$"{e.Source} - {e.EventType}" + (string.IsNullOrWhiteSpace(e.Meta) ? "" : $" [{e.Meta}]"), - description=$"Config Updated Remotely: {e.ToValueString()}", + + foreach (List batch in events.Chunk(10)) { + var embeds = batch.Select(e => new { + title = $"{e.Source} - {e.EventType}" + (string.IsNullOrWhiteSpace(e.Meta) ? "" : $" [{e.Meta}]"), + description = $"Config Updated Remotely: {e.ToValueString()}", color = 0xffaa55 }); - var payload = new - { + var payload = new { username = MakeUsername(), avatar_url = AppAvatarUrl, embeds @@ -265,55 +276,47 @@ private async Task FlushConfigQueueAsync() await Task.Delay(1500, _ctsConfig.Token); } } - - private async Task ProcessQueueAsync() - { - try - { - while (!_cts.IsCancellationRequested) - { + + private async Task ProcessQueueAsync() { + try { + while (!_cts.IsCancellationRequested) { await Task.Delay(_flushInterval, _cts.Token); await FlushQueueAsync(); } } - catch (OperationCanceledException ex) - { + catch (OperationCanceledException ex) { _logger?.LogDebug(ex, ex.Message); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, ex.Message); } } [ExcludeFromCodeCoverage] - private async Task FlushQueueAsync() - { + private async Task FlushQueueAsync() { if (_eventQueue.IsEmpty) return; var events = new List(); - while (events.Count < (_maxMsgPerMinute * 10) && _eventQueue.TryDequeue(out var subathonEvent) ) - { + while (events.Count < _maxMsgPerMinute * 10 && _eventQueue.TryDequeue(out SubathonEvent? subathonEvent)) events.Add(subathonEvent); - } if (events.Count == 0) return; - + // max 10 embeds per msg // rate limit is ~30msgs/min // we do half that to be safe - foreach (var batch in events.Chunk(10)) - { - var embeds = batch.Select(e => new - { - title=$"{GoAffProOrderHelper.GetOrderKey(e.EventType, e.EventTypeMeta)}{(e.Value.EndsWith(" [DELETED]") ? " - Deleted" : (e.EventType == SubathonEventType.Command ? $" - {e.Command}" : - (e.Source == SubathonEventSource.Simulated ? " - Simulated" : "")))}", - description=BuildEventDescription(e), - color = (e.ProcessedToSubathon && e.Value.EndsWith(" [DELETED]") ? 0x691911 : (e.ProcessedToSubathon ? 0x00ff88 : 0xffaa55)), + foreach (List batch in events.Chunk(10)) { + var embeds = batch.Select(e => new { + title = + $"{GoAffProOrderHelper.GetOrderKey(e.EventType, e.EventTypeMeta)}{(e.Value.EndsWith(" [DELETED]") ? " - Deleted" : e.EventType == SubathonEventType.Command ? $" - {e.Command}" : + e.Source == SubathonEventSource.Simulated ? " - Simulated" : "")}", + description = BuildEventDescription(e), + color = e.ProcessedToSubathon && e.Value.EndsWith(" [DELETED]") ? 0x691911 : + e.ProcessedToSubathon ? 0x00ff88 : 0xffaa55, timestamp = e.EventTimestamp.ToUniversalTime().ToString("o"), - footer = new {text = $"{e.Source} {e.Id}\nCurrent: {TimeSpan.FromSeconds(e.CurrentTime)} | {e.CurrentPoints}"} + footer = new + { text = $"{e.Source} {e.Id}\nCurrent: {TimeSpan.FromSeconds(e.CurrentTime)} | {e.CurrentPoints}" } }); - var payload = new - { + var payload = new { username = MakeUsername(), avatar_url = AppAvatarUrl, embeds @@ -324,162 +327,97 @@ private async Task FlushQueueAsync() } } - private string BuildEventDescription(SubathonEvent e) - { + private string BuildEventDescription(SubathonEvent e) { var sb = new StringBuilder(); var userHeader = $"**User:** {e.User}"; if (e.EventType.GetTypeTrueSource() == $"{SubathonEventSource.MakeShip}" || e.EventType is SubathonEventType.JuniperMerchSale or SubathonEventType.ThroneCrowdGiftComplete) - { userHeader = $"**Item:** {e.TertiaryValue}"; - } - if (e.EventType == SubathonEventType.JuniperMerchSale) - { - sb.AppendLine($"**Store:** {e.User}"); - } + if (e.EventType == SubathonEventType.JuniperMerchSale) sb.AppendLine($"**Store:** {e.User}"); sb.AppendLine(userHeader); if (e.EventType is SubathonEventType.ThroneGiftContribution or SubathonEventType.ThroneGiftPurchase) - { sb.AppendLine($"**Item:** {e.TertiaryValue}"); - } - var val = e.Value.Replace("[DELETED]", "").Trim(); + string val = e.Value.Replace("[DELETED]", "").Trim(); if (e.Currency == "sub") - { - val = val switch - { + val = val switch { "1000" => "Tier 1", "2000" => "Tier 2", "3000" => "Tier 3", _ => val }; - } - if (e.Command is not (SubathonCommandType.Pause or SubathonCommandType.Resume or SubathonCommandType.Lock or SubathonCommandType.Unlock)) - { + if (e.Command is not (SubathonCommandType.Pause or SubathonCommandType.Resume or SubathonCommandType.Lock + or SubathonCommandType.Unlock)) { sb.AppendLine($"**Value:** {val} {(string.IsNullOrEmpty(e.Currency) ? "" : e.Currency)}"); - if (e.Command != SubathonCommandType.SetMultiplier && e.Command != SubathonCommandType.StopMultiplier) - { - sb.AppendLine($"**Seconds:** {Math.Round(e.GetFinalSecondsValueRaw(), 2)} | **Points:** {e.GetFinalPointsValue()}"); + if (e.Command != SubathonCommandType.SetMultiplier && e.Command != SubathonCommandType.StopMultiplier) { + sb.AppendLine( + $"**Seconds:** {Math.Round(e.GetFinalSecondsValueRaw(), 2)} | **Points:** {e.GetFinalPointsValue()}"); if (e.Command == SubathonCommandType.None) sb.AppendLine($"**Multipliers:** x{e.MultiplierSeconds} time | x{e.MultiplierPoints} pts"); } } - if (e.EventType == SubathonEventType.TwitchGiftSub) - { - sb.AppendLine($"**Amt:** x{e.Amount}"); - } + if (e.EventType == SubathonEventType.TwitchGiftSub) sb.AppendLine($"**Amt:** x{e.Amount}"); return sb.ToString(); } - - private async Task SendWebhookAsync(object payload, string? url, int retryCount = 0) - { + + private async Task SendWebhookAsync(object payload, string? url, int retryCount = 0) { if (string.IsNullOrEmpty(url)) return; - try - { - var http = _httpClientFactory?.CreateClient(nameof(DiscordWebhookService)); + try { + HttpClient? http = _httpClientFactory?.CreateClient(nameof(DiscordWebhookService)); if (http == null) return; - var json = JsonSerializer.Serialize(payload); + string json = JsonSerializer.Serialize(payload); var content = new StringContent(json, Encoding.UTF8, "application/json"); - var response = await http.PostAsync(url, content, _cts.Token); - string rc = ""; + HttpResponseMessage response = await http.PostAsync(url, content, _cts.Token); + var rc = ""; - if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests && retryCount < 3) - { + if (response.StatusCode == HttpStatusCode.TooManyRequests && retryCount < 3) { rc = await response.Content.ReadAsStringAsync(); - double retryAfter = 1.0; - try - { - using var doc = JsonDocument.Parse(rc); - if (doc.RootElement.TryGetProperty("retry_after", out var ra)) + var retryAfter = 1.0; + try { + using JsonDocument doc = JsonDocument.Parse(rc); + if (doc.RootElement.TryGetProperty("retry_after", out JsonElement ra)) retryAfter = ra.GetDouble(); } - catch {/**/} + catch { + /**/ + } int delayMs = (int)(retryAfter * 1000) + 200; - _logger?.LogWarning("Discord rate limited, retrying in {DelayMs}ms, attempt #{Attempt}", delayMs, retryCount); + _logger?.LogWarning("Discord rate limited, retrying in {DelayMs}ms, attempt #{Attempt}", delayMs, + retryCount); await Task.Delay(delayMs, _cts.Token); await SendWebhookAsync(payload, url, retryCount + 1); return; } - if (!response.IsSuccessStatusCode) - { + if (!response.IsSuccessStatusCode) { rc = await response.Content.ReadAsStringAsync(); _logger?.LogError("Webhook failed: {ResponseStatusCode} - {Rc}", response.StatusCode, rc); } } - catch (OperationCanceledException) { /**/ } - catch (Exception ex) - { + catch (OperationCanceledException) { + /**/ + } + catch (Exception ex) { _logger?.LogError(ex, ex.Message); } } - public async Task StopAsync(CancellationToken cancellationToken = default) - { - await _cts.CancelAsync(); - if (_backgroundTask != null) - await _backgroundTask; - await _ctsConfig.CancelAsync(); - if (_backgroundConfigTask != null) - await _backgroundConfigTask; - await _ctsWheel.CancelAsync(); - if (_backgroundWheelTask != null) - await _backgroundWheelTask; - await _ctsTrigger.CancelAsync(); - if (_backgroundTriggerTask != null) - await _backgroundTriggerTask; - await FlushQueueAsync(); - await FlushConfigQueueAsync(); - await FlushWheelQueueAsync(); - await FlushTriggerQueueAsync(); - } - - public void Dispose() - { - if (!_cts.IsCancellationRequested) - _cts.Cancel(); - if (!_ctsConfig.IsCancellationRequested) - _ctsConfig.Cancel(); - if (!_ctsWheel.IsCancellationRequested) - _ctsWheel.Cancel(); - - SubathonEvents.SubathonEventProcessed -= OnSubathonEventProcessed; - SubathonEvents.SubathonEventsDeleted -= OnSubathonEventDeleted; - ErrorMessageEvents.ErrorEventOccured -= SendErrorEvent; - ErrorMessageEvents.SendCustomEvent -= OnCustomEvent; - SubathonEvents.SubathonValuesPatched -= OnSubathonConfigValuesPatched; - WheelEvents.WheelSpinResult -= OnWheelSpinResult; - WheelEvents.WheelSpinStatusChanged -= OnWheelSpinStatusChanged; - WheelEvents.WheelSpinTriggerFired -= OnWheelSpinTriggerFired; - _cts.Dispose(); - _ctsConfig.Dispose(); - _ctsWheel.Dispose(); - if (!_ctsTrigger.IsCancellationRequested) - _ctsTrigger.Cancel(); - _ctsTrigger.Dispose(); - } - - public void SendErrorEvent(string level, string source, string message, DateTime time) - { + public void SendErrorEvent(string level, string source, string message, DateTime time) { if (string.IsNullOrEmpty(_webhookUrl)) return; - try - { - var payload = new - { + try { + var payload = new { username = MakeUsername(), avatar_url = AppAvatarUrl, - embeds = new[] - { - new - { + embeds = new[] { + new { title = $"{level}", description = $"**{source}**\n{message}", - color = level == "WARN" ? 0xFF7B00 : (level == "ERROR" ? 0xDE360D : 0xE3E3E3 ), + color = level == "WARN" ? 0xFF7B00 : level == "ERROR" ? 0xDE360D : 0xE3E3E3, timestamp = time.ToString("o") } } @@ -487,26 +425,20 @@ public void SendErrorEvent(string level, string source, string message, DateTime Task.Run(async () => { await SendWebhookAsync(payload, _webhookUrl); }); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, $"Failed to send webhook log: {ex.Message}"); } } [ExcludeFromCodeCoverage] - public async Task SendErrorLogAsync(string message, Exception? ex = null) - { + public async Task SendErrorLogAsync(string message, Exception? ex = null) { if (string.IsNullOrEmpty(_webhookUrl)) return; - try - { - var payload = new - { + try { + var payload = new { username = MakeUsername(), avatar_url = AppAvatarUrl, - embeds = new[] - { - new - { + embeds = new[] { + new { title = "⚠️ Error ⚠️", description = BuildErrorDescription(message, ex), color = 0xff5555, @@ -517,27 +449,23 @@ public async Task SendErrorLogAsync(string message, Exception? ex = null) await SendWebhookAsync(payload, _webhookUrl); } - catch (Exception ex2) - { + catch (Exception ex2) { _logger?.LogError(ex2, $"Failed to send webhook log: {ex2.Message}"); } } - - private string BuildErrorDescription(string message, Exception? ex) - { + + private string BuildErrorDescription(string message, Exception? ex) { var sb = new StringBuilder(); sb.AppendLine($"**Message:** {message}"); - if (ex != null) - { + if (ex != null) { sb.AppendLine(); sb.AppendLine($"**Exception:** `{ex.GetType().Name}`"); sb.AppendLine($"**Details:** {ex.Message}"); if (!string.IsNullOrWhiteSpace(ex.StackTrace)) sb.AppendLine($"```{ex.StackTrace.Split('\n').Take(5).Aggregate((a, b) => a + "\n" + b)}```"); } - else - { + else { sb.AppendLine(); sb.AppendLine("_No exception details provided._"); } @@ -545,57 +473,47 @@ private string BuildErrorDescription(string message, Exception? ex) return sb.ToString(); } - private void OnWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int spinsOwed) - { + private void OnWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int spinsOwed) { if (string.IsNullOrEmpty(_wheelWebhookUrl) || !_doWheelSpinEvents) return; _wheelQueue.Enqueue(new WheelLogEntry(wheel.Name, item, history, spinsOwed, false)); } - private void OnWheelSpinStatusChanged(WheelSpinHistory history, int spinsOwed) - { + private void OnWheelSpinStatusChanged(WheelSpinHistory history, int spinsOwed) { if (string.IsNullOrEmpty(_wheelWebhookUrl) || !_doWheelSpinEvents) return; - _wheelQueue.Enqueue(new WheelLogEntry(history.LinkedWheel?.Name ?? "Unknown", history.LinkedItem, history, spinsOwed, true)); + _wheelQueue.Enqueue(new WheelLogEntry(history.LinkedWheel?.Name ?? "Unknown", history.LinkedItem, history, + spinsOwed, true)); } - private void OnWheelSpinTriggerFired(WheelSpinTrigger trigger, WheelSpinTriggerHistory history, int newSpinsOwed) - { + private void OnWheelSpinTriggerFired(WheelSpinTrigger trigger, WheelSpinTriggerHistory history, int newSpinsOwed) { if (string.IsNullOrEmpty(_wheelWebhookUrl) || !_doWheelTriggerEvents) return; _triggerQueue.Enqueue(new TriggerLogEntry(trigger, history, newSpinsOwed)); } - private async Task ProcessTriggerQueueAsync() - { - try - { - while (!_ctsTrigger.IsCancellationRequested) - { + private async Task ProcessTriggerQueueAsync() { + try { + while (!_ctsTrigger.IsCancellationRequested) { await Task.Delay(_flushInterval, _ctsTrigger.Token); await FlushTriggerQueueAsync(); } } - catch (OperationCanceledException ex) - { + catch (OperationCanceledException ex) { _logger?.LogDebug(ex, ex.Message); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, ex.Message); } } [ExcludeFromCodeCoverage] - private async Task FlushTriggerQueueAsync() - { + private async Task FlushTriggerQueueAsync() { if (_triggerQueue.IsEmpty) return; var entries = new List(); - while (entries.Count < (_maxMsgPerMinute * 10) && _triggerQueue.TryDequeue(out var entry)) + while (entries.Count < _maxMsgPerMinute * 10 && _triggerQueue.TryDequeue(out TriggerLogEntry entry)) entries.Add(entry); if (entries.Count == 0) return; - foreach (var batch in entries.Chunk(10)) - { - var embeds = batch.Select(e => new - { + foreach (List batch in entries.Chunk(10)) { + var embeds = batch.Select(e => new { title = $"WheelSpin Trigger - {e.Trigger.EventType.GetSource()} {TriggerEventLabel(e.Trigger)}", description = BuildTriggerLogDescription(e), color = 0x9B59B6, @@ -603,8 +521,7 @@ private async Task FlushTriggerQueueAsync() footer = new { text = $"Trigger {e.Trigger.Id}\nHistory {e.History.Id}" } }); - var payload = new - { + var payload = new { username = MakeUsername(), avatar_url = AppAvatarUrl, embeds @@ -615,20 +532,20 @@ private async Task FlushTriggerQueueAsync() } } - private static string TriggerEventLabel(WheelSpinTrigger trigger) => trigger.EventType switch - { - SubathonEventType.GoAffProOrder => - GoAffProOrderHelper.GetOrderEventDisplayLabel(trigger.EventType, trigger.TierValue), - SubathonEventType.JuniperMerchSale => - OrderMetaFilter.Describe(trigger.EventType, trigger.TierValue), - SubathonEventType.MakeShipPledge or SubathonEventType.MakeShipSale - when !string.IsNullOrEmpty(trigger.TierValue) => - $"{trigger.EventType.GetLabel()} ({trigger.TierValue})", - _ => trigger.EventType.GetLabel() - }; - - private static string BuildTriggerLogDescription(TriggerLogEntry e) - { + private static string TriggerEventLabel(WheelSpinTrigger trigger) { + return trigger.EventType switch { + SubathonEventType.GoAffProOrder => + GoAffProOrderHelper.GetOrderEventDisplayLabel(trigger.EventType, trigger.TierValue), + SubathonEventType.JuniperMerchSale => + OrderMetaFilter.Describe(trigger.EventType, trigger.TierValue), + SubathonEventType.MakeShipPledge or SubathonEventType.MakeShipSale + when !string.IsNullOrEmpty(trigger.TierValue) => + $"{trigger.EventType.GetLabel()} ({trigger.TierValue})", + _ => trigger.EventType.GetLabel() + }; + } + + private static string BuildTriggerLogDescription(TriggerLogEntry e) { var sb = new StringBuilder(); sb.AppendLine($"**Event:** {TriggerEventLabel(e.Trigger)} ({e.Trigger.EventType.GetSource()})"); sb.AppendLine($"**User:** {e.History.TriggerUser ?? "Unknown"}"); @@ -639,39 +556,31 @@ private static string BuildTriggerLogDescription(TriggerLogEntry e) return sb.ToString(); } - private async Task ProcessWheelQueueAsync() - { - try - { - while (!_ctsWheel.IsCancellationRequested) - { + private async Task ProcessWheelQueueAsync() { + try { + while (!_ctsWheel.IsCancellationRequested) { await Task.Delay(_flushInterval, _ctsWheel.Token); await FlushWheelQueueAsync(); } } - catch (OperationCanceledException ex) - { + catch (OperationCanceledException ex) { _logger?.LogDebug(ex, ex.Message); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, ex.Message); } } [ExcludeFromCodeCoverage] - private async Task FlushWheelQueueAsync() - { + private async Task FlushWheelQueueAsync() { if (_wheelQueue.IsEmpty) return; var entries = new List(); - while (entries.Count < (_maxMsgPerMinute * 10) && _wheelQueue.TryDequeue(out var entry)) + while (entries.Count < _maxMsgPerMinute * 10 && _wheelQueue.TryDequeue(out WheelLogEntry entry)) entries.Add(entry); if (entries.Count == 0) return; - foreach (var batch in entries.Chunk(10)) - { - var embeds = batch.Select(e => new - { + foreach (List batch in entries.Chunk(10)) { + var embeds = batch.Select(e => new { title = e.IsStatusChange ? $"WheelSpin Status Change - {e.WheelName}" : $"WheelSpin - {e.WheelName}", @@ -681,8 +590,7 @@ private async Task FlushWheelQueueAsync() footer = new { text = $"Spin {e.History.Id}\nStatus: {e.History.Status}" } }); - var payload = new - { + var payload = new { username = MakeUsername(), avatar_url = AppAvatarUrl, embeds @@ -693,12 +601,12 @@ private async Task FlushWheelQueueAsync() } } - private string BuildWheelLogDescription(WheelLogEntry e) - { + private string BuildWheelLogDescription(WheelLogEntry e) { var sb = new StringBuilder(); sb.AppendLine($"**Item:** {e.Item?.Text ?? "(unknown)"}"); - sb.AppendLine($"**Quantity Remaining:** {(e.Item?.IsInfinite ?? false ? "∞" : e.Item?.Quantity.ToString() ?? "?")}"); - var action = e.Item?.Action; + sb.AppendLine( + $"**Quantity Remaining:** {(e.Item?.IsInfinite ?? false ? "∞" : e.Item?.Quantity.ToString() ?? "?")}"); + WheelSpinAction? action = e.Item?.Action; sb.AppendLine($"**Action:** {(action != null ? action.ActionType.ToString() : "Manual")}"); if (action != null && !string.IsNullOrEmpty(action.Parameter)) sb.AppendLine($"**Parameter:** {action.Parameter}"); @@ -707,27 +615,32 @@ private string BuildWheelLogDescription(WheelLogEntry e) return sb.ToString(); } - private static int WheelStatusColor(WheelSpinHistoryStatus status) => status switch - { - WheelSpinHistoryStatus.Done => 0x00ff88, - WheelSpinHistoryStatus.Cancelled => 0xFF6B6B, - _ => 0x6495ED - }; - - private readonly record struct WheelLogEntry(string WheelName, WheelItem? Item, WheelSpinHistory History, int SpinsOwed, bool IsStatusChange); - private readonly record struct TriggerLogEntry(WheelSpinTrigger Trigger, WheelSpinTriggerHistory History, int NewSpinsOwed); + private static int WheelStatusColor(WheelSpinHistoryStatus status) { + return status switch { + WheelSpinHistoryStatus.Done => 0x00ff88, + WheelSpinHistoryStatus.Cancelled => 0xFF6B6B, + _ => 0x6495ED + }; + } + private readonly record struct WheelLogEntry( + string WheelName, + WheelItem? Item, + WheelSpinHistory History, + int SpinsOwed, + bool IsStatusChange); + + private readonly record struct TriggerLogEntry( + WheelSpinTrigger Trigger, + WheelSpinTriggerHistory History, + int NewSpinsOwed); } - [ExcludeFromCodeCoverage] -internal static class LinqExtensions -{ - public static IEnumerable> Chunk(this IEnumerable source, int size) - { +internal static class LinqExtensions { + public static IEnumerable> Chunk(this IEnumerable source, int size) { var list = new List(size); - foreach (var item in source) - { + foreach (T item in source) { list.Add(item); if (list.Count < size) continue; yield return list; @@ -737,4 +650,4 @@ public static IEnumerable> Chunk(this IEnumerable source, int size if (list.Count > 0) yield return list; } -} +} \ No newline at end of file diff --git a/SubathonManager.Services/EventService.cs b/SubathonManager.Services/EventService.cs index 45e02cef..12ce91c8 100644 --- a/SubathonManager.Services/EventService.cs +++ b/SubathonManager.Services/EventService.cs @@ -1,13 +1,13 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using SubathonManager.Core.Models; -using SubathonManager.Data; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; using SubathonManager.Core.Objects; +using SubathonManager.Data; // ReSharper disable NullableWarningSuppressionIsUsed @@ -15,76 +15,77 @@ namespace SubathonManager.Services; // We don't need one of these for Subathon updates, because those fire every second anyways -public class EventService: IDisposable, IAppService -{ - private readonly IDbContextFactory _factory; +public class EventService : IDisposable, IAppService { + private readonly IConfig _config; private readonly CancellationTokenSource _cts = new(); + private readonly CurrencyService _currencyService; + private readonly IDbContextFactory _factory; + private readonly Lock _lock = new(); + private readonly ILogger? _logger; private readonly PriorityQueue _queue = new(); private readonly SemaphoreSlim _signal = new(0); - private readonly Lock _lock = new(); private Task? _processingTask; - private readonly CurrencyService _currencyService; - private readonly ILogger? _logger; - private readonly IConfig _config; - record EventProjection(SubathonEventType? EventType, SubathonEventSource Source, int Amount, string Value, bool IsSimulated, string? EventTypeMeta); public EventService(IDbContextFactory factory, ILogger? logger, IConfig config, - CurrencyService currencyService) - { + CurrencyService currencyService) { _factory = factory; _logger = logger; _config = config; _currencyService = currencyService; } - public Task StartAsync(CancellationToken ct = default) - { - Core.Events.SubathonEvents.SubathonEventCreated += AddSubathonEvent; + public Task StartAsync(CancellationToken ct = default) { + SubathonEvents.SubathonEventCreated += AddSubathonEvent; _processingTask = Task.Run(LoopAsync, ct); - _processingTask.ContinueWith(t => - _logger?.LogError("Event loop crashed: {AggregateException}", t.Exception), + _processingTask.ContinueWith(t => + _logger?.LogError("Event loop crashed: {AggregateException}", t.Exception), TaskContinuationOptions.OnlyOnFaulted); - Task.Run(() =>_currencyService.StartAsync(ct), ct); + Task.Run(() => _currencyService.StartAsync(ct), ct); _logger?.LogInformation("EventService started"); return Task.CompletedTask; } - public List ValidEventCurrencies() - { + public async Task StopAsync(CancellationToken ct = default) { + if (!_cts.IsCancellationRequested) + _cts.Cancel(); + _signal.Release(); + if (_processingTask != null) + await _processingTask; + } + + [ExcludeFromCodeCoverage] + public void Dispose() { + if (!_cts.IsCancellationRequested) + _cts.Cancel(); + _signal.Dispose(); + _cts.Dispose(); + } + + public List ValidEventCurrencies() { return Task.Run(() => _currencyService.GetValidCurrenciesAsync()).Result; } - + [ExcludeFromCodeCoverage] - public void ReInitCurrencyService() - { + public void ReInitCurrencyService() { Task.Run(() => _currencyService.StartAsync()); } - private void AddSubathonEvent(SubathonEvent subathonEvent) - { - lock (_lock) - { + private void AddSubathonEvent(SubathonEvent subathonEvent) { + lock (_lock) { _queue.Enqueue(subathonEvent, subathonEvent.EventTimestamp); } _signal.Release(); } - private async Task LoopAsync() - { - try - { - while (!_cts.Token.IsCancellationRequested) - { + private async Task LoopAsync() { + try { + while (!_cts.Token.IsCancellationRequested) { await _signal.WaitAsync(_cts.Token); SubathonEvent? next = null; - lock (_lock) - { - if (_queue.TryDequeue(out var item, out _)) - { - next = item; - } + lock (_lock) { + if (_queue.TryDequeue(out SubathonEvent? item, out _)) next = item; } if (next == null) continue; @@ -93,132 +94,135 @@ private async Task LoopAsync() SubathonEvents.RaiseSubathonEventProcessed(next, wasEffective); } } - catch (OperationCanceledException ex) - { + catch (OperationCanceledException ex) { _logger?.LogError($"Error in Event Loop: {ex.Message}", ex); } } - + public async Task<(bool, bool)> ProcessSubathonEvent(SubathonEvent ev) // effective, dupe-was-processed { - await using var db = await _factory.CreateDbContextAsync(); + await using AppDbContext db = await _factory.CreateDbContextAsync(); SubathonData? subathon = await db.SubathonDatas.Include(s => s.Multiplier) .FirstOrDefaultAsync(s => s.IsActive); - + if (subathon == null) return (false, false); - - SubathonEvent? dupeCheck = await db.SubathonEvents.AsNoTracking().SingleOrDefaultAsync(s => s.Id == ev.Id + + SubathonEvent? dupeCheck = await db.SubathonEvents.AsNoTracking().SingleOrDefaultAsync(s => s.Id == ev.Id && s.Source == ev.Source); if (dupeCheck is { ProcessedToSubathon: true }) return (false, true); - SubathonGoalSet? goalSet = await db.SubathonGoalSets.Include(s => s.Goals).AsNoTracking().SingleOrDefaultAsync(s => s.IsActive); - + SubathonGoalSet? goalSet = await db.SubathonGoalSets.Include(s => s.Goals).AsNoTracking() + .SingleOrDefaultAsync(s => s.IsActive); + double initialMoney = subathon!.GetRoundedMoneySumWithCents(); long initialPoints = subathon?.Points ?? 0; - if (goalSet?.Type == GoalsType.Money) initialPoints = (long) Math.Floor(initialMoney); - - if (subathon != null && ev.EventType == SubathonEventType.Command && ev.Command != SubathonCommandType.None) - { - var (bool1, bool2, hasRun) = await HandleCommandEvent(ev, db, subathon, goalSet, initialPoints); + if (goalSet?.Type == GoalsType.Money) initialPoints = (long)Math.Floor(initialMoney); + + if (subathon != null && ev.EventType == SubathonEventType.Command && ev.Command != SubathonCommandType.None) { + (bool bool1, bool bool2, bool hasRun) = await HandleCommandEvent(ev, db, subathon, goalSet, initialPoints); if (hasRun) return (bool1, bool2); } - var processPointsIfLocked = _config.GetBool("App", "OtherValuesWhenLocked", true); - if (subathon == null || (subathon.IsLocked && ev.EventType != SubathonEventType.TwitchHypeTrain && !processPointsIfLocked)) // we only do math if it's unlocked, otherwise, only link + bool processPointsIfLocked = _config.GetBool("App", "OtherValuesWhenLocked", true); + if (subathon == null || (subathon.IsLocked && ev.EventType != SubathonEventType.TwitchHypeTrain && + !processPointsIfLocked)) // we only do math if it's unlocked, otherwise, only link { ev.ProcessedToSubathon = false; - if (subathon != null) - { + if (subathon != null) { ev.SubathonId = subathon.Id; ev.CurrentTime = (int)subathon.TimeRemaining().TotalSeconds; ev.CurrentPoints = subathon.Points; } - try - { + try { db.Add(ev); await db.SaveChangesAsync(); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "Error in Event Loop: {ExMessage}", ex.Message); } return (false, false); } - + ev.SubathonId = subathon.Id; ev.CurrentTime = (int)subathon.TimeRemaining().TotalSeconds; ev.CurrentPoints = subathon.Points; - - if (ev.EventType == SubathonEventType.TwitchHypeTrain) - { - return await HandleHypeTrainEvent(ev, subathon, db); - } - if (ev.EventType == SubathonEventType.ThroneCrowdGiftComplete || ev.EventType.IsEvent()) - { + + if (ev.EventType == SubathonEventType.TwitchHypeTrain) return await HandleHypeTrainEvent(ev, subathon, db); + if (ev.EventType == SubathonEventType.ThroneCrowdGiftComplete || ev.EventType.IsEvent()) { ev.ProcessedToSubathon = true; db.Add(ev); await db.SaveChangesAsync(); return (ev.ProcessedToSubathon, false); } - + ev.MultiplierSeconds = subathon.Multiplier.ApplyToSeconds ? subathon.Multiplier.Multiplier : 1; ev.MultiplierPoints = subathon.Multiplier.ApplyToPoints ? subathon.Multiplier.Multiplier : 1; ev.WasReversed = subathon.IsSubathonReversed(); - + SubathonValue? subathonValue = null; - if (ev.EventType != SubathonEventType.Command && - (ev.EventType != SubathonEventType.ExternalSub || - ev is { EventType: SubathonEventType.ExternalSub, SecondsValue: 0, PointsValue: 0 }) && - ev.EventType != SubathonEventType.DonationAdjustment) - { + if (ev.EventType != SubathonEventType.Command && + (ev.EventType != SubathonEventType.ExternalSub || + ev is { EventType: SubathonEventType.ExternalSub, SecondsValue: 0, PointsValue: 0 }) && + ev.EventType != SubathonEventType.DonationAdjustment) { subathonValue = await db.SubathonValues.FirstOrDefaultAsync(v => - v.EventType == ev.EventType && ( (ev.EventTypeMeta != null && v.Meta.ToLower() == ev.EventTypeMeta.ToLower()) || - v.Meta.ToLower() == ev.Value.ToLower() || - v.Meta == string.Empty)); + v.EventType == ev.EventType && + ((ev.EventTypeMeta != null && v.Meta.ToLower() == ev.EventTypeMeta.ToLower()) || + v.Meta.ToLower() == ev.Value.ToLower() || + v.Meta == string.Empty)); subathonValue ??= await db.SubathonValues.FirstOrDefaultAsync(v => - v.EventType == ev.EventType && (v.Meta == "DEFAULT")); + v.EventType == ev.EventType && v.Meta == "DEFAULT"); if (subathonValue == null) return (false, false); } - if (ev.EventType != SubathonEventType.Command && - (ev.EventType != SubathonEventType.ExternalSub || - ev is { EventType: SubathonEventType.ExternalSub, SecondsValue: 0, PointsValue: 0 }) && - ev.EventType != SubathonEventType.DonationAdjustment) - { + if (ev.EventType != SubathonEventType.Command && + (ev.EventType != SubathonEventType.ExternalSub || + ev is { EventType: SubathonEventType.ExternalSub, SecondsValue: 0, PointsValue: 0 }) && + ev.EventType != SubathonEventType.DonationAdjustment) { /////////////////////////////////////////////////////////////// - ev.PointsValue = (int) Math.Floor(subathonValue!.Points); - if (double.TryParse(ev.Value, out var parsedValue) + ev.PointsValue = (int)Math.Floor(subathonValue!.Points); + if (double.TryParse(ev.Value, out double parsedValue) + && ev.Currency != "viewers" && ev.Currency != "sub" && ev.Currency != "member" && ev.Currency != "order" // allow items from orders && !_currencyService.IsValidCurrency(ev.Currency) - && !string.IsNullOrEmpty(ev.Value.Trim())) - { - ev.SecondsValue = parsedValue * subathonValue.Seconds; // also handles tokens, but points overwritten later - ev.PointsValue = (int?)(parsedValue * Math.Floor(subathonValue!.Points)); // i went through 21k+ lines of changes to find this missing + && !string.IsNullOrEmpty(ev.Value.Trim())) { + ev.SecondsValue = + parsedValue * subathonValue.Seconds; // also handles tokens, but points overwritten later + ev.PointsValue = + (int?)(parsedValue * + Math.Floor(subathonValue! + .Points)); // i went through 21k+ lines of changes to find this missing } - else if (!string.IsNullOrEmpty(ev.Currency) && "sub,member,viewers,bits,beets,order".Split(",").Contains(ev.Currency.ToLower())) // flat orders + else if (!string.IsNullOrEmpty(ev.Currency) && + "sub,member,viewers,bits,beets,order".Split(",").Contains(ev.Currency.ToLower())) // flat orders { ev.SecondsValue = subathonValue.Seconds; } else if (!string.IsNullOrEmpty(ev.Currency) && _currencyService.IsValidCurrency(ev.Currency) && (ev.EventType.IsCurrencyDonation() || - ev.EventType.IsOrder())) // includes orders when parsed as money mode + ev.EventType + .IsOrder())) // includes orders when parsed as money mode { double rate = Task.Run(() => _currencyService.ConvertAsync(double.Parse(ev.Value), ev.Currency)).Result; ev.SecondsValue = Math.Round(subathonValue.Seconds * rate, 2); ev.PointsValue = (int)Math.Floor(subathonValue!.Points * rate); } - else + else { ev.SecondsValue = subathonValue.Seconds; - + } + + if (ev.EventType.IsRaid()) { + // All raid types are always points per raid never viewer + ev.PointsValue = (int)Math.Floor(subathonValue!.Points); + } + if (ev.EventType.IsCurrencyDonation() && (string.IsNullOrEmpty(ev.Currency) || - !_currencyService.IsValidCurrency(ev.Currency))) - { + !_currencyService.IsValidCurrency(ev.Currency))) { ev.PointsValue = 0; ev.SecondsValue = 0; ev.ProcessedToSubathon = false; @@ -226,25 +230,21 @@ private async Task LoopAsync() ev.Currency = "???"; } - if (ev.EventType.IsToken() && double.TryParse(ev.Value, out var parsedBitsLikeValue)) - { + if (ev.EventType.IsToken() && double.TryParse(ev.Value, out double parsedBitsLikeValue)) // seconds in sub value are stored as 0.12 so it is done above - ev.PointsValue = (int) Math.Floor(((parsedBitsLikeValue / 100)) * subathonValue!.Points); - } + ev.PointsValue = (int)Math.Floor(parsedBitsLikeValue / 100 * subathonValue!.Points); } - if (ev.EventType == SubathonEventType.DonationAdjustment) - { + if (ev.EventType == SubathonEventType.DonationAdjustment) { ev.PointsValue = 0; ev.SecondsValue = 0; } - - if (ev.EventType == SubathonEventType.TwitchSub && ev.User != "SYSTEM") - { - var cutoff = DateTime.Now.AddDays(-3); + + if (ev.EventType == SubathonEventType.TwitchSub && ev.User != "SYSTEM") { + DateTime cutoff = DateTime.Now.AddDays(-3); // find if same tier, user, and is processed in last 3d, if so, return. Will be diff id's. // we check this late in case they co-processed - SubathonEvent? dupeTwitchSub = await db.SubathonEvents.AsNoTracking().SingleOrDefaultAsync(s => + SubathonEvent? dupeTwitchSub = await db.SubathonEvents.AsNoTracking().SingleOrDefaultAsync(s => s.Source == ev.Source && s.User == ev.User && s.Value == ev.Value && s.EventType == ev.EventType && s.ProcessedToSubathon @@ -252,9 +252,8 @@ private async Task LoopAsync() && s.EventTimestamp > cutoff); if (dupeTwitchSub != null) return (false, true); } - - if (ev.EventType.IsFollow() && ev.User != "SYSTEM") - { + + if (ev.EventType.IsFollow() && ev.User != "SYSTEM") { SubathonEvent? dupeFollowType = await db.SubathonEvents.AsNoTracking().SingleOrDefaultAsync(s => s.Source == ev.Source && s.User == ev.User && s.EventType == ev.EventType @@ -263,24 +262,20 @@ private async Task LoopAsync() if (dupeFollowType != null) return (false, true); } - int affected = 0; + var affected = 0; if (processPointsIfLocked && subathon.IsLocked) ev.SecondsValue = 0; - if (ev.SecondsValue != 0) - { + if (ev.SecondsValue != 0) { if (ev.WasReversed) affected += await db.Database.ExecuteSqlRawAsync( "UPDATE SubathonDatas SET MillisecondsCumulative = MillisecondsCumulative - {0} WHERE IsActive = 1 AND IsLocked = 0 AND Id = {1}", (long)TimeSpan.FromSeconds(ev.GetFinalSecondsValueRaw()).TotalMilliseconds, subathon.Id); else - { affected += await db.Database.ExecuteSqlRawAsync( "UPDATE SubathonDatas SET MillisecondsCumulative = MillisecondsCumulative + {0} WHERE IsActive = 1 AND IsLocked = 0 AND Id = {1}", (long)TimeSpan.FromSeconds(ev.GetFinalSecondsValueRaw()).TotalMilliseconds, subathon.Id); - } } - if (ev.PointsValue != 0) - { + if (ev.PointsValue != 0) { var lockVal = 0; if (subathon.IsLocked && processPointsIfLocked) lockVal = 1; affected += await db.Database.ExecuteSqlRawAsync( @@ -288,25 +283,21 @@ private async Task LoopAsync() (int)ev.GetFinalPointsValue(), subathon.Id, lockVal); } - if (ev is { PointsValue: 0, SecondsValue: 0 } && ev.Currency != "???") - { - ev.ProcessedToSubathon = true; - } + if (ev is { PointsValue: 0, SecondsValue: 0 } && ev.Currency != "???") ev.ProcessedToSubathon = true; - if (ev.EventType.IsCurrencyDonation() && !_currencyService.IsValidCurrency(ev.Currency)) - { + if (ev.EventType.IsCurrencyDonation() && !_currencyService.IsValidCurrency(ev.Currency)) { ev.ProcessedToSubathon = false; var msg = $"{ev.EventType} invalid currency donated. Amt: [{ev.Value}] Currency: [{ev.Currency}]"; // discord push error donation ErrorMessageEvents.RaiseErrorEvent("WARN", ev.Source.ToString(), msg, ev.EventTimestamp); _logger?.LogWarning(msg); } - + if (affected > 0 || ev.EventType == SubathonEventType.DonationAdjustment || - (processPointsIfLocked && subathon.IsLocked && ev.EventType.IsCurrencyDonation() && _currencyService.IsValidCurrency(ev.Currency)) || + (processPointsIfLocked && subathon.IsLocked && ev.EventType.IsCurrencyDonation() && + _currencyService.IsValidCurrency(ev.Currency)) || (Utils.IsCommissionAsDonation(_config, ev) - && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && ev.SecondaryValue.Contains('|'))) - { + && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && ev.SecondaryValue.Contains('|'))) { ev.ProcessedToSubathon = true; (bool asDono, double modifier) = Utils.GetAltCurrencyUseAsDonation(_config, ev.EventType); @@ -314,47 +305,41 @@ private async Task LoopAsync() if (subathon.IsLocked && processPointsIfLocked) lockVal = 1; if (Utils.IsCommissionAsDonation(_config, ev) - && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && ev.SecondaryValue.Contains('|')) - { - var value = ev.SecondaryValue.Split('|')[0]; - var currency = ev.SecondaryValue.Split('|')[1]; - if (_currencyService.IsValidCurrency(currency)) - { + && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && ev.SecondaryValue.Contains('|')) { + string value = ev.SecondaryValue.Split('|')[0]; + string currency = ev.SecondaryValue.Split('|')[1]; + if (_currencyService.IsValidCurrency(currency)) { double added = await _currencyService.ConvertAsync(double.Parse(value), currency, _config.Get("Currency", "Primary", "USD")); affected += await db.Database.ExecuteSqlRawAsync( "UPDATE SubathonDatas SET MoneySum = MoneySum + {0} WHERE IsActive = 1 AND IsLocked = {2} AND Id = {1}", added, subathon.Id, lockVal); - } - } else if (ev.EventType.IsCurrencyDonation() && ev.Currency != "???" && - _currencyService.IsValidCurrency(ev.Currency) && !string.IsNullOrWhiteSpace(ev.Currency)) - { - var value = ev.Value; + _currencyService.IsValidCurrency(ev.Currency) && !string.IsNullOrWhiteSpace(ev.Currency)) { + string value = ev.Value; double added = await _currencyService.ConvertAsync(double.Parse(value), ev.Currency, - _config.Get("Currency", "Primary", "USD")); + _config.Get("Currency", "Primary", "USD")); affected += await db.Database.ExecuteSqlRawAsync( "UPDATE SubathonDatas SET MoneySum = MoneySum + {0} WHERE IsActive = 1 AND IsLocked = {2} AND Id = {1}", added, subathon.Id, lockVal); } - else if (asDono) - { - double added = await _currencyService.ConvertAsync((double.Parse(ev.Value) * modifier) / 100, "USD", + else if (asDono) { + double added = await _currencyService.ConvertAsync(double.Parse(ev.Value) * modifier / 100, "USD", _config.Get("Currency", "Primary", "USD")); affected += await db.Database.ExecuteSqlRawAsync( "UPDATE SubathonDatas SET MoneySum = MoneySum + {0} WHERE IsActive = 1 AND IsLocked = {2} AND Id = {1}", added, subathon.Id, lockVal); } - + await db.Entry(subathon.Multiplier).ReloadAsync(); await db.Entry(subathon).ReloadAsync(); - + long pts = subathon.Points; if (goalSet?.Type == GoalsType.Money) pts = subathon.GetRoundedMoneySum(); - await CheckForGoalChange(db, pts, initialPoints); + await CheckForGoalChange(db, pts, initialPoints); db.Entry(subathon).State = EntityState.Detached; db.Entry(subathon.Multiplier).State = EntityState.Detached; SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); @@ -362,48 +347,42 @@ private async Task LoopAsync() if (dupeCheck != null) db.Update(ev); - else + else db.Add(ev); - + await db.SaveChangesAsync(); - + await db.Entry(subathon).ReloadAsync(); double newMoney = subathon.GetRoundedMoneySumWithCents(); - if (newMoney < initialMoney || initialMoney < newMoney) - { + if (newMoney < initialMoney || initialMoney < newMoney) { long pts = subathon.Points; if (goalSet?.Type == GoalsType.Money) pts = subathon.GetRoundedMoneySum(); - await CheckForGoalChange(db, pts, initialPoints); + await CheckForGoalChange(db, pts, initialPoints); SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); } + db.Entry(subathon).State = EntityState.Detached; - if (affected > 0 || (ev.ProcessedToSubathon)) + if (affected > 0 || ev.ProcessedToSubathon) await RaiseAllTotalsUpdatedAsync(db); - return (affected > 0 || (ev.ProcessedToSubathon), false); + return (affected > 0 || ev.ProcessedToSubathon, false); } - private async Task<(bool, bool)> HandleHypeTrainEvent(SubathonEvent ev, SubathonData subathon, AppDbContext db) - { - if (_config.GetBool("Twitch", "HypeTrainMultiplier.Enabled", false) && - (!subathon.Multiplier.IsRunning() || subathon.Multiplier.FromHypeTrain)) - { - if (ev.Value is "start" or "progress") - { + private async Task<(bool, bool)> HandleHypeTrainEvent(SubathonEvent ev, SubathonData subathon, AppDbContext db) { + if (_config.GetBool("Twitch", "HypeTrainMultiplier.Enabled") && + (!subathon.Multiplier.IsRunning() || subathon.Multiplier.FromHypeTrain)) { + if (ev.Value is "start" or "progress") { TimeSpan? duration = null; - bool applyPts = _config.GetBool("Twitch", "HypeTrainMultiplier.Points", false); - bool applyTime = _config.GetBool("Twitch", "HypeTrainMultiplier.Time", false); + bool applyPts = _config.GetBool("Twitch", "HypeTrainMultiplier.Points"); + bool applyTime = _config.GetBool("Twitch", "HypeTrainMultiplier.Time"); double.TryParse(_config.Get("Twitch", "HypeTrainMultiplier.Multiplier", "1"), - out var parsedAmt); + out double parsedAmt); if (!(subathon.Multiplier.IsRunning() && !parsedAmt.Equals(1) && (applyPts || applyTime))) - { - if (applyTime || applyPts) - { - + if (applyTime || applyPts) { ev.Value = $"start | x{parsedAmt}" + (applyPts ? " Points" : "") + (applyTime ? " Time" : ""); @@ -411,10 +390,8 @@ await db.Database.ExecuteSqlRawAsync( "UPDATE MultiplierDatas SET Multiplier = {0}, Duration = {1}, Started = {2}, ApplyToSeconds = {3}, ApplyToPoints = {4}, FromHypeTrain = {6} WHERE SubathonId = {5}", parsedAmt, duration!, DateTime.Now, applyTime, applyPts, subathon.Id, true); } - } } - else if (ev.Value == "end" && subathon.Multiplier.FromHypeTrain && subathon.Multiplier.IsRunning()) - { + else if (ev.Value == "end" && subathon.Multiplier.FromHypeTrain && subathon.Multiplier.IsRunning()) { // do multiplier end await db.Database.ExecuteSqlRawAsync( "UPDATE MultiplierDatas SET Multiplier = 1, Duration = null, ApplyToSeconds = {1}, ApplyToPoints = {2}, FromHypeTrain = {3} WHERE SubathonId = {0}", @@ -426,6 +403,7 @@ await db.Database.ExecuteSqlRawAsync( SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); } } + ev.ProcessedToSubathon = true; db.Add(ev); await db.SaveChangesAsync(); @@ -433,12 +411,10 @@ await db.Database.ExecuteSqlRawAsync( } private async Task<(bool, bool, bool)> HandleCommandEvent(SubathonEvent ev, AppDbContext db, SubathonData subathon, - SubathonGoalSet? goalSet, long initialPoints) - { + SubathonGoalSet? goalSet, long initialPoints) { // we allow commands to add even if locked - int ranCmd = int.MinValue; - switch (ev.Command) - { + var ranCmd = int.MinValue; + switch (ev.Command) { case SubathonCommandType.SetSpins: if (ev.Amount < 0) return (false, false, true); await StateValueHelper.SetAsync(_factory, StateKeys.WheelSpinsOwed, ev.Amount); @@ -446,13 +422,13 @@ await db.Database.ExecuteSqlRawAsync( break; case SubathonCommandType.AddSpins: if (ev.Amount < 1) return (false, false, true); - var spins = await StateValueHelper.GetAsync(_factory, StateKeys.WheelSpinsOwed, 0); + int spins = await StateValueHelper.GetAsync(_factory, StateKeys.WheelSpinsOwed, 0); await StateValueHelper.SetAsync(_factory, StateKeys.WheelSpinsOwed, spins + ev.Amount); WheelEvents.RaiseSpinsOwedUpdateFromEvent(ev.Amount + spins); break; case SubathonCommandType.SubtractSpins: if (ev.Amount < 1) return (false, false, true); - var spins2 = await StateValueHelper.GetAsync(_factory, StateKeys.WheelSpinsOwed, 0); + int spins2 = await StateValueHelper.GetAsync(_factory, StateKeys.WheelSpinsOwed, 0); await StateValueHelper.SetAsync(_factory, StateKeys.WheelSpinsOwed, int.Max(0, spins2 - ev.Amount)); WheelEvents.RaiseSpinsOwedUpdateFromEvent(int.Max(0, spins2 - ev.Amount)); break; @@ -471,39 +447,35 @@ await db.Database.ExecuteSqlRawAsync( case SubathonCommandType.SetPoints: if (ev.PointsValue < 0) return (false, false, true); ranCmd = await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET Points = {1} WHERE IsActive = 1 AND Id = {0}", + "UPDATE SubathonDatas SET Points = {1} WHERE IsActive = 1 AND Id = {0}", subathon.Id, ev.PointsValue!); break; case SubathonCommandType.AddPoints: if (ev.PointsValue < 1) return (false, false, true); ranCmd = await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET Points = Points + {1} WHERE IsActive = 1 AND Id = {0}", + "UPDATE SubathonDatas SET Points = Points + {1} WHERE IsActive = 1 AND Id = {0}", subathon.Id, ev.PointsValue!); break; case SubathonCommandType.SubtractPoints: if (ev.PointsValue < 1) return (false, false, true); ranCmd = await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET Points = Points - {1} WHERE IsActive = 1 AND Id = {0}", + "UPDATE SubathonDatas SET Points = Points - {1} WHERE IsActive = 1 AND Id = {0}", subathon.Id, ev.PointsValue!); break; case SubathonCommandType.SetTime: if (subathon.IsSubathonReversed()) - { ranCmd = await db.Database.ExecuteSqlRawAsync( "UPDATE SubathonDatas SET MillisecondsElapsed = {1} - MillisecondsCumulative WHERE IsActive = 1 AND Id = {0}", subathon.Id, ev.SecondsValue! * 1000); - } else - { ranCmd = await db.Database.ExecuteSqlRawAsync( "UPDATE SubathonDatas SET MillisecondsCumulative = MillisecondsElapsed + {1} WHERE IsActive = 1 AND Id = {0}", subathon.Id, ev.SecondsValue! * 1000); - } break; case SubathonCommandType.AddTime: ranCmd = await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET MillisecondsCumulative = MillisecondsCumulative + {1} WHERE IsActive = 1 AND Id = {0}", + "UPDATE SubathonDatas SET MillisecondsCumulative = MillisecondsCumulative + {1} WHERE IsActive = 1 AND Id = {0}", subathon.Id, ev.SecondsValue! * 1000); break; case SubathonCommandType.SubtractTime: @@ -513,59 +485,61 @@ await db.Database.ExecuteSqlRawAsync( break; case SubathonCommandType.Unlock: ranCmd = await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET IsLocked = 0 WHERE IsActive = 1 AND Id = {0}", + "UPDATE SubathonDatas SET IsLocked = 0 WHERE IsActive = 1 AND Id = {0}", subathon.Id); break; case SubathonCommandType.Lock: ranCmd = await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET IsLocked = 1 WHERE IsActive = 1 AND Id = {0}", + "UPDATE SubathonDatas SET IsLocked = 1 WHERE IsActive = 1 AND Id = {0}", subathon.Id); break; case SubathonCommandType.Resume: ranCmd = await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET IsPaused = 0 WHERE IsActive = 1 AND Id = {0}", + "UPDATE SubathonDatas SET IsPaused = 0 WHERE IsActive = 1 AND Id = {0}", subathon.Id); break; case SubathonCommandType.Pause: ranCmd = await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET IsPaused = 1 WHERE IsActive = 1 AND Id = {0}", + "UPDATE SubathonDatas SET IsPaused = 1 WHERE IsActive = 1 AND Id = {0}", subathon.Id); break; case SubathonCommandType.StopMultiplier: - ranCmd = await db.Database.ExecuteSqlRawAsync("UPDATE MultiplierDatas SET Multiplier = 1, Duration = null, ApplyToSeconds = {1}, ApplyToPoints = {2}, FromHypeTrain = {3} WHERE SubathonId = {0}", + ranCmd = await db.Database.ExecuteSqlRawAsync( + "UPDATE MultiplierDatas SET Multiplier = 1, Duration = null, ApplyToSeconds = {1}, ApplyToPoints = {2}, FromHypeTrain = {3} WHERE SubathonId = {0}", subathon.Id, false, false, false); - - if (string.IsNullOrEmpty(ev.Value)) + + if (string.IsNullOrEmpty(ev.Value)) ev.Value = $"{ev.Command}"; break; case SubathonCommandType.SetMultiplier: // string dataStr = $"{parsedAmt}|{durationStr}s|{applyPts}|{applyTime}"; string[] data = ev.Value.Split("|"); - if (!double.TryParse(data[0], out var parsedAmt)) + if (!double.TryParse(data[0], out double parsedAmt)) return (false, false, true); TimeSpan? duration; if (data[1] == "xs") duration = null; else duration = Utils.ParseDurationString(data[1]); - if (!bool.TryParse(data[2], out var applyPts) || !bool.TryParse(data[3], out var applyTime)) + if (!bool.TryParse(data[2], out bool applyPts) || !bool.TryParse(data[3], out bool applyTime)) return (false, false, true); ev.Value = $"{ev.Command} {ev.Value}"; - ranCmd = await db.Database.ExecuteSqlRawAsync("UPDATE MultiplierDatas SET Multiplier = {0}, Duration = {1}, Started = {2}, ApplyToSeconds = {3}, ApplyToPoints = {4}, FromHypeTrain = {6} WHERE SubathonId = {5}", + ranCmd = await db.Database.ExecuteSqlRawAsync( + "UPDATE MultiplierDatas SET Multiplier = {0}, Duration = {1}, Started = {2}, ApplyToSeconds = {3}, ApplyToPoints = {4}, FromHypeTrain = {6} WHERE SubathonId = {5}", parsedAmt, duration!, DateTime.Now, applyTime, applyPts, subathon.Id, false); break; } if (ranCmd < 0) return (false, false, false); - + await db.Entry(subathon.Multiplier).ReloadAsync(); await db.Entry(subathon).ReloadAsync(); db.Entry(subathon).State = EntityState.Detached; db.Entry(subathon.Multiplier).State = EntityState.Detached; SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); ev.ProcessedToSubathon = true; - ev.SubathonId = subathon.Id; + ev.SubathonId = subathon.Id; ev.CurrentTime = (int)subathon.TimeRemaining().TotalSeconds; ev.CurrentPoints = subathon.Points; @@ -573,80 +547,73 @@ await db.Database.ExecuteSqlRawAsync( await db.SaveChangesAsync(); long pts = subathon.Points; if (goalSet?.Type == GoalsType.Money) pts = subathon.GetRoundedMoneySum(); - await CheckForGoalChange(db, pts, initialPoints); + await CheckForGoalChange(db, pts, initialPoints); if (ev.Command is SubathonCommandType.AddMoney or SubathonCommandType.SubtractMoney or - SubathonCommandType.AddPoints or SubathonCommandType.SubtractPoints or SubathonCommandType.SetPoints) - { - var totals = await GetSubathonTotalsAsync(db); + SubathonCommandType.AddPoints or SubathonCommandType.SubtractPoints or SubathonCommandType.SetPoints) { + SubathonTotals? totals = await GetSubathonTotalsAsync(db); if (totals != null) SubathonEvents.RaiseSubathonTotalsUpdated(totals); } - return (true, false, true); + return (true, false, true); } - private static async Task CheckForGoalChange(AppDbContext db, long newPoints, long initialPoints) - { - if (newPoints != initialPoints) - { + private static async Task CheckForGoalChange(AppDbContext db, long newPoints, long initialPoints) { + if (newPoints != initialPoints) { // look for last completed goal according to initial, and then reg points. If diff, push either completed update, or list update if undo SubathonGoalSet? goalSet = await db.SubathonGoalSets.AsNoTracking() - .Include(g=> g.Goals).FirstOrDefaultAsync(g => g.IsActive); - if (goalSet != null) - { + .Include(g => g.Goals).FirstOrDefaultAsync(g => g.IsActive); + if (goalSet != null) { SubathonGoal? prevCompletedGoal1 = goalSet.Goals .OrderByDescending(g => g.Points) .FirstOrDefault(g => g.Points <= initialPoints); - + SubathonGoal? prevCompletedGoal2 = goalSet.Goals .OrderByDescending(g => g.Points) .FirstOrDefault(g => g.Points <= newPoints); - + if (prevCompletedGoal2 != null && - prevCompletedGoal1?.Id != prevCompletedGoal2.Id) - { + prevCompletedGoal1?.Id != prevCompletedGoal2.Id) { if (prevCompletedGoal1 == null || prevCompletedGoal1.Points <= prevCompletedGoal2.Points) SubathonEvents.RaiseSubathonGoalCompleted(prevCompletedGoal2, newPoints); else - SubathonEvents.RaiseSubathonGoalListUpdated(goalSet.Goals, newPoints, (GoalsType) goalSet.Type!); + SubathonEvents.RaiseSubathonGoalListUpdated(goalSet.Goals, newPoints, (GoalsType)goalSet.Type!); } - else if (prevCompletedGoal2 == null && prevCompletedGoal1 == null || newPoints < initialPoints) - { - SubathonEvents.RaiseSubathonGoalListUpdated(goalSet.Goals, newPoints, (GoalsType) goalSet.Type!); + else if ((prevCompletedGoal2 == null && prevCompletedGoal1 == null) || newPoints < initialPoints) { + SubathonEvents.RaiseSubathonGoalListUpdated(goalSet.Goals, newPoints, (GoalsType)goalSet.Type!); } } } + await Task.CompletedTask; } - public async Task DeleteSubathonEvent(AppDbContext db, SubathonEvent ev) - { + public async Task DeleteSubathonEvent(AppDbContext db, SubathonEvent ev) { if (ev.SubathonId == null) return; - + SubathonData? subathon = await db.SubathonDatas.Include(x => x.Multiplier).FirstOrDefaultAsync(s => s.IsActive); if (ev.SubathonId != subathon?.Id) return; - SubathonGoalSet? goalSet = await db.SubathonGoalSets.AsNoTracking().Include(g=> g.Goals).FirstOrDefaultAsync(g => g.IsActive); + SubathonGoalSet? goalSet = await db.SubathonGoalSets.AsNoTracking().Include(g => g.Goals) + .FirstOrDefaultAsync(g => g.IsActive); long initialPoints = subathon?.Points ?? 0; if (goalSet?.Type == GoalsType.Money) initialPoints = subathon?.GetRoundedMoneySum() ?? 0; - - long msToRemove = (long) Math.Ceiling(ev.GetFinalSecondsValueRaw() * 1000); - int pointsToRemove = (int) ev.GetFinalPointsValue(); + + var msToRemove = (long)Math.Ceiling(ev.GetFinalSecondsValueRaw() * 1000); + var pointsToRemove = (int)ev.GetFinalPointsValue(); double moneyToRemove = 0; - switch (ev.Command) - { + switch (ev.Command) { case SubathonCommandType.SubtractPoints: pointsToRemove = -pointsToRemove; break; case SubathonCommandType.SubtractTime: msToRemove = -msToRemove; break; - case SubathonCommandType.SetPoints or SubathonCommandType.SetTime: - { - string msg = "Cannot delete SetTime or SetPoints events. Retaining event..."; - ErrorMessageEvents.RaiseErrorEvent("WARN", $"Delete {ev.Command} Event", + case SubathonCommandType.SetPoints or SubathonCommandType.SetTime: { + var msg = "Cannot delete SetTime or SetPoints events. Retaining event..."; + ErrorMessageEvents.RaiseErrorEvent("WARN", $"Delete {ev.Command} Event", msg, DateTime.Now); _logger?.LogWarning(msg); return; @@ -654,19 +621,16 @@ public async Task DeleteSubathonEvent(AppDbContext db, SubathonEvent ev) } if (ev.ProcessedToSubathon && Utils.IsCommissionAsDonation(_config, ev) - && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && ev.SecondaryValue.Contains('|')) - { - var value = ev.SecondaryValue.Split('|')[0]; - var currency = ev.SecondaryValue.Split('|')[1]; + && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && + ev.SecondaryValue.Contains('|')) { + string value = ev.SecondaryValue.Split('|')[0]; + string currency = ev.SecondaryValue.Split('|')[1]; if (_currencyService.IsValidCurrency(currency)) - { moneyToRemove += await _currencyService.ConvertAsync(double.Parse(value), currency, _config.Get("Currency", "Primary", "USD")); - } - } - else if (ev.EventType.IsCurrencyDonation() && _currencyService.IsValidCurrency(ev.Currency) && ev.ProcessedToSubathon) - { + else if (ev.EventType.IsCurrencyDonation() && _currencyService.IsValidCurrency(ev.Currency) && + ev.ProcessedToSubathon) { moneyToRemove += await _currencyService.ConvertAsync(double.Parse(ev.Value), ev.Currency!, _config.Get("Currency", "Primary", "USD")); } @@ -675,69 +639,61 @@ public async Task DeleteSubathonEvent(AppDbContext db, SubathonEvent ev) // this is acceptable for now, as it can resync properly on toggle (bool asDono, double modifier) = Utils.GetAltCurrencyUseAsDonation(_config, ev.EventType); if (asDono) - { - moneyToRemove += await _currencyService.ConvertAsync(Math.Round((double.Parse(ev.Value) * modifier)/100, 2), "USD", + moneyToRemove += await _currencyService.ConvertAsync(Math.Round(double.Parse(ev.Value) * modifier / 100, 2), + "USD", _config.Get("Currency", "Primary", "USD")); - } - if (!ev.ProcessedToSubathon) - { + if (!ev.ProcessedToSubathon) { msToRemove = 0; pointsToRemove = 0; moneyToRemove = 0; } - if (ev is { WasReversed: true, Command: SubathonCommandType.None }) - { - msToRemove *= -1; - } - - int affected = 0; + if (ev is { WasReversed: true, Command: SubathonCommandType.None }) msToRemove *= -1; + + var affected = 0; if (msToRemove != 0) affected += await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET MillisecondsCumulative = MillisecondsCumulative - {0} WHERE IsActive = 1 AND Id = {1} " + "UPDATE SubathonDatas SET MillisecondsCumulative = MillisecondsCumulative - {0} WHERE IsActive = 1 AND Id = {1} " , msToRemove, ev.SubathonId); - + if (pointsToRemove != 0) affected += await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET Points = Points - {0} WHERE IsActive = 1 AND Id = {1} ", + "UPDATE SubathonDatas SET Points = Points - {0} WHERE IsActive = 1 AND Id = {1} ", pointsToRemove, ev.SubathonId); - + if (moneyToRemove != 0) affected += await db.Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET MoneySum = MoneySum - {0} WHERE IsActive = 1 AND Id = {1} ", + "UPDATE SubathonDatas SET MoneySum = MoneySum - {0} WHERE IsActive = 1 AND Id = {1} ", moneyToRemove, ev.SubathonId); await db.Entry(ev).ReloadAsync(); db.Remove(ev); await db.SaveChangesAsync(); - + var events = new List(); events.Add(ev); SubathonEvents.RaiseSubathonEventsDeleted(events); - + await RaiseAllTotalsUpdatedAsync(db); - if (affected > 0) - { + if (affected > 0) { await db.Entry(subathon!).ReloadAsync(); db.Entry(subathon!).State = EntityState.Detached; SubathonEvents.RaiseSubathonDataUpdate(subathon!, DateTime.Now); await DetectGoalStateChange(db, subathon!, goalSet, initialPoints); } } - - public async Task UndoSimulatedEvents(AppDbContext db, List events, bool doAll = false) - { + + public async Task UndoSimulatedEvents(AppDbContext db, List events, bool doAll = false) { // Remove simulated events from the active subathon only // idea - recent events in main page can have "remove" option each that invoke this with a list of 1 - - int pointsToRemove = 0; + + var pointsToRemove = 0; long msToRemove = 0; double moneyToRemove = 0; - try - { + try { SubathonData? subathon = await db.SubathonDatas.Include(x => x.Multiplier) .FirstOrDefaultAsync(s => s.IsActive); if (subathon == null) return; @@ -748,83 +704,65 @@ public async Task UndoSimulatedEvents(AppDbContext db, List event long initialPoints = goalSet?.Type == GoalsType.Money ? subathon.GetRoundedMoneySum() : subathon.Points; if (doAll) - { // Do All for current subathon events = await db.SubathonEvents.Where(e => e.SubathonId == subathon.Id && e.Source == SubathonEventSource.Simulated) .ToListAsync(); - } long bits = 0; - foreach (SubathonEvent ev in events) - { + foreach (SubathonEvent ev in events) { if (ev.SubathonId != subathon.Id) continue; if (!ev.ProcessedToSubathon) continue; - long ms = (long)Math.Ceiling(ev.GetFinalSecondsValueRaw() * 1000); + var ms = (long)Math.Ceiling(ev.GetFinalSecondsValueRaw() * 1000); if (ev is { WasReversed: true, Command: SubathonCommandType.None }) ms *= -1; msToRemove += ms; pointsToRemove += (int)ev.GetFinalPointsValue(); - if (ev.EventType.IsCurrencyDonation()) - { + if (ev.EventType.IsCurrencyDonation()) { moneyToRemove += await _currencyService.ConvertAsync(double.Parse(ev.Value), ev.Currency!, subathon.Currency!); } else if (Utils.IsCommissionAsDonation(_config, ev) - && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && - ev.SecondaryValue.Contains('|')) - { - var value = ev.SecondaryValue.Split('|')[0]; - var currency = ev.SecondaryValue.Split('|')[1]; + && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && + ev.SecondaryValue.Contains('|')) { + string value = ev.SecondaryValue.Split('|')[0]; + string currency = ev.SecondaryValue.Split('|')[1]; if (_currencyService.IsValidCurrency(currency)) - { moneyToRemove += await _currencyService.ConvertAsync(double.Parse(value), currency, subathon.Currency!); - } - } (bool asDono, double modifier) = Utils.GetAltCurrencyUseAsDonation(_config, ev.EventType); - if (asDono) - { - bits += (long)(int.Parse(ev.Value) * modifier); - } + if (asDono) bits += (long)(int.Parse(ev.Value) * modifier); } - if (bits > 0) - { + if (bits > 0) { double val = (double)bits / 100; moneyToRemove += await _currencyService.ConvertAsync(Math.Round(val, 2), "USD", subathon.Currency!); } - int affected = 0; + var affected = 0; if (msToRemove != 0) - { affected += await db.Database.ExecuteSqlRawAsync( "UPDATE SubathonDatas SET MillisecondsCumulative = MillisecondsCumulative - {0} WHERE IsActive = 1 AND Id = {1} " , msToRemove, subathon.Id); - } if (pointsToRemove != 0) - { affected += await db.Database.ExecuteSqlRawAsync( "UPDATE SubathonDatas SET Points = Points - {0} WHERE IsActive = 1 AND Id = {1} ", pointsToRemove, subathon.Id); - } if (!moneyToRemove.Equals(0)) - { affected += await db.Database.ExecuteSqlRawAsync( "UPDATE SubathonDatas SET MoneySum = MoneySum - {0} WHERE IsActive = 1 AND Id = {1} ", moneyToRemove, subathon.Id); - } // db.RemoveRange(events); // to avoid tracking issues in tests, we delete separate entities - var ids = events.Select(e => e.Id).ToList(); + List ids = events.Select(e => e.Id).ToList(); - var trackedEvents = await db.SubathonEvents + List trackedEvents = await db.SubathonEvents .Where(e => ids.Contains(e.Id)) .ToListAsync(); @@ -833,60 +771,48 @@ public async Task UndoSimulatedEvents(AppDbContext db, List event await db.SaveChangesAsync(); SubathonEvents.RaiseSubathonEventsDeleted(events); - if (affected > 0) - { - await DetectGoalStateChange(db, subathon, goalSet, initialPoints); - } + if (affected > 0) await DetectGoalStateChange(db, subathon, goalSet, initialPoints); db.Entry(subathon).State = EntityState.Detached; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError("UndoSimulatedEvents failed: {Message}", ex.Message); } - finally - { + finally { await RaiseAllTotalsUpdatedAsync(db); } } private async Task DetectGoalStateChange(AppDbContext db, SubathonData subathon, SubathonGoalSet? goalSet, - long initialPoints) - { + long initialPoints) { await db.Entry(subathon).ReloadAsync(); await db.Entry(subathon.Multiplier).ReloadAsync(); db.Entry(subathon).State = EntityState.Detached; db.Entry(subathon.Multiplier).State = EntityState.Detached; SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); - long pts = goalSet?.Type == GoalsType.Money ? subathon.GetRoundedMoneySum() : subathon.Points; + long pts = goalSet?.Type == GoalsType.Money ? subathon.GetRoundedMoneySum() : subathon.Points; if (pts != initialPoints) - { - if (goalSet != null) - { + if (goalSet != null) { SubathonGoal? prevCompletedGoal1 = goalSet.Goals .OrderByDescending(g => g.Points) .FirstOrDefault(g => g.Points <= initialPoints); - + SubathonGoal? prevCompletedGoal2 = goalSet.Goals .OrderByDescending(g => g.Points) .FirstOrDefault(g => g.Points <= pts); if (prevCompletedGoal1 != null && prevCompletedGoal2 != null && - prevCompletedGoal1.Id != prevCompletedGoal2.Id) - { - + prevCompletedGoal1.Id != prevCompletedGoal2.Id) { // the else is for if we undid stuff if (prevCompletedGoal1.Points <= prevCompletedGoal2.Points) SubathonEvents.RaiseSubathonGoalCompleted(prevCompletedGoal2, pts); else - SubathonEvents.RaiseSubathonGoalListUpdated(goalSet.Goals, pts, (GoalsType) goalSet.Type!); + SubathonEvents.RaiseSubathonGoalListUpdated(goalSet.Goals, pts, (GoalsType)goalSet.Type!); } } - } } - - public static async Task GetSubathonTotalsAsync(AppDbContext db) - { + + public static async Task GetSubathonTotalsAsync(AppDbContext db) { var subathon = await db.SubathonDatas .AsNoTracking() .Select(x => new { x.Id, x.IsActive, x.MoneySum, x.Currency }) @@ -894,7 +820,7 @@ private async Task DetectGoalStateChange(AppDbContext db, SubathonData subathon, if (subathon == null) return new SubathonTotals(); - var allEvents = await db.SubathonEvents + List allEvents = await db.SubathonEvents .AsNoTracking() .Where(e => e.SubathonId == subathon.Id && @@ -912,19 +838,23 @@ private async Task DetectGoalStateChange(AppDbContext db, SubathonData subathon, )) .ToListAsync(); - - var events= allEvents.Where(e => !e.IsSimulated).ToList(); - var simEvents= allEvents.Where(e => e.IsSimulated).ToList(); - static SubathonSimulatedTotals BuildSimulated(List src) - { - var subLikeSim= src.Where(e => e.EventType.GetSubType() is SubathonEventSubType.SubLike).ToList(); - var giftSubLikeSim= src.Where(e => e.EventType.GetSubType() is SubathonEventSubType.GiftSubLike).ToList(); - var tokenLikeSim = src.Where(e => e.EventType.GetSubType() is SubathonEventSubType.TokenLike).ToList(); - var orderLikeSim = src.Where(e => e.EventType.GetSubType() is SubathonEventSubType.OrderLike).ToList(); - var followLikeSim= src.Where(e => e.EventType.GetSubType() is SubathonEventSubType.FollowLike).ToList(); - return new SubathonSimulatedTotals - { + List events = allEvents.Where(e => !e.IsSimulated).ToList(); + List simEvents = allEvents.Where(e => e.IsSimulated).ToList(); + + static SubathonSimulatedTotals BuildSimulated(List src) { + List subLikeSim = + src.Where(e => e.EventType.GetSubType() is SubathonEventSubType.SubLike).ToList(); + List giftSubLikeSim = + src.Where(e => e.EventType.GetSubType() is SubathonEventSubType.GiftSubLike).ToList(); + List tokenLikeSim = + src.Where(e => e.EventType.GetSubType() is SubathonEventSubType.TokenLike).ToList(); + List orderLikeSim = + src.Where(e => e.EventType.GetSubType() is SubathonEventSubType.OrderLike).ToList(); + List followLikeSim = + src.Where(e => e.EventType.GetSubType() is SubathonEventSubType.FollowLike).ToList(); + + return new SubathonSimulatedTotals { SubLikeTotal = subLikeSim.Count + giftSubLikeSim.Sum(e => e.Amount), SubLikeByEvent = subLikeSim .GroupBy(e => e.EventType!.Value) @@ -932,39 +862,43 @@ static SubathonSimulatedTotals BuildSimulated(List src) .Concat(giftSubLikeSim .GroupBy(e => e.EventType!.Value) .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount))) - .ToDictionary(k => k.Key, v => v.Value),TokenLikeTotal = tokenLikeSim.Sum(e => long.TryParse(e.Value, out var v) ? v : 0), - TokenLikeByEvent = tokenLikeSim.GroupBy(e => e.EventType!.Value).ToDictionary(g => g.Key, g => g.Sum(e => long.TryParse(e.Value, out var v) ? v : 0)), - OrderCountByType = orderLikeSim.GroupBy(e => GoAffProOrderHelper.GetOrderKey(e.EventType, e.EventTypeMeta)).ToDictionary(g => g.Key, g => g.Count()), + .ToDictionary(k => k.Key, v => v.Value), + TokenLikeTotal = tokenLikeSim.Sum(e => long.TryParse(e.Value, out long v) ? v : 0), + TokenLikeByEvent = tokenLikeSim.GroupBy(e => e.EventType!.Value).ToDictionary(g => g.Key, + g => g.Sum(e => long.TryParse(e.Value, out long v) ? v : 0)), + OrderCountByType = orderLikeSim + .GroupBy(e => GoAffProOrderHelper.GetOrderKey(e.EventType, e.EventTypeMeta)) + .ToDictionary(g => g.Key, g => g.Count()), OrderItemsCountByType = orderLikeSim .GroupBy(e => GoAffProOrderHelper.GetOrderKey(e.EventType, e.EventTypeMeta)) .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount)), FollowLikeTotal = followLikeSim.Count, - FollowLikeByEvent= followLikeSim.GroupBy(e => e.EventType!.Value).ToDictionary(g => g.Key, g => g.Count()), + FollowLikeByEvent = followLikeSim.GroupBy(e => e.EventType!.Value) + .ToDictionary(g => g.Key, g => g.Count()) }; } - - var subLike = events + + List subLike = events .Where(e => e.EventType.GetSubType() is SubathonEventSubType.SubLike) .ToList(); - var giftSubLike = events + List giftSubLike = events .Where(e => e.EventType.GetSubType() is SubathonEventSubType.GiftSubLike) .ToList(); - var tokenLike = events + List tokenLike = events .Where(e => e.EventType.GetSubType() is SubathonEventSubType.TokenLike) .ToList(); - var orderLike = events + List orderLike = events .Where(e => e.EventType.GetSubType() is SubathonEventSubType.OrderLike) .ToList(); - var followLike = events + List followLike = events .Where(e => e.EventType.GetSubType() is SubathonEventSubType.FollowLike) .ToList(); - var simData = BuildSimulated(simEvents); - var totals = new SubathonTotals - { + SubathonSimulatedTotals simData = BuildSimulated(simEvents); + var totals = new SubathonTotals { MoneySum = subathon.MoneySum ?? 0, Currency = subathon.Currency, @@ -978,12 +912,12 @@ static SubathonSimulatedTotals BuildSimulated(List src) .ToDictionary(k => k.Key, v => v.Value), TokenLikeTotal = tokenLike - .Sum(e => long.TryParse(e.Value, out var v) ? v : 0), + .Sum(e => long.TryParse(e.Value, out long v) ? v : 0), TokenLikeByEvent = tokenLike .GroupBy(e => e.EventType!.Value) .ToDictionary( g => g.Key, - g => g.Sum(e => long.TryParse(e.Value, out var v) ? v : 0)), + g => g.Sum(e => long.TryParse(e.Value, out long v) ? v : 0)), OrderCountByType = orderLike .GroupBy(e => GoAffProOrderHelper.GetOrderKey(e.EventType, e.EventTypeMeta)) @@ -992,26 +926,24 @@ static SubathonSimulatedTotals BuildSimulated(List src) OrderItemsCountByType = orderLike .GroupBy(e => GoAffProOrderHelper.GetOrderKey(e.EventType, e.EventTypeMeta)) .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount)), - + FollowLikeTotal = followLike.Count, FollowLikeByEvent = followLike .GroupBy(e => e.EventType!.Value) .ToDictionary(g => g.Key, g => g.Count()), - + Simulated = simData }; return totals; } - private static string GetSubTierKey(EventProjection e) - { - var meta = !string.IsNullOrWhiteSpace(e.EventTypeMeta) ? e.EventTypeMeta + private static string GetSubTierKey(EventProjection e) { + string? meta = !string.IsNullOrWhiteSpace(e.EventTypeMeta) ? e.EventTypeMeta : string.IsNullOrWhiteSpace(e.Value) ? "DEFAULT" : e.Value; return e.EventType == null ? meta : e.EventType.Value.TierMetaDisplayName(meta); } - public static async Task GetSubscriptionTotalsAsync(AppDbContext db) - { + public static async Task GetSubscriptionTotalsAsync(AppDbContext db) { var subathon = await db.SubathonDatas .AsNoTracking() .Select(x => new { x.Id, x.IsActive }) @@ -1019,11 +951,11 @@ private static string GetSubTierKey(EventProjection e) if (subathon == null) return new SubscriptionTotals(); - var subEventTypes = SubathonEventSubTypeHelper.SubEventTypes + List subEventTypes = SubathonEventSubTypeHelper.SubEventTypes .Select(t => (SubathonEventType?)t) .ToList(); - var allEvents = await db.SubathonEvents + List allEvents = await db.SubathonEvents .AsNoTracking() .Where(e => e.SubathonId == subathon.Id && @@ -1041,28 +973,30 @@ private static string GetSubTierKey(EventProjection e) )) .ToListAsync(); - var events = allEvents.Where(e => !e.IsSimulated).ToList(); - var simEvents = allEvents.Where(e => e.IsSimulated).ToList(); + List events = allEvents.Where(e => !e.IsSimulated).ToList(); + List simEvents = allEvents.Where(e => e.IsSimulated).ToList(); - static Dictionary ByEvent(List src) => src - .GroupBy(e => e.EventType!.Value) - .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount)); + static Dictionary ByEvent(List src) { + return src + .GroupBy(e => e.EventType!.Value) + .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount)); + } - static Dictionary> ByEventTier(List src) => src - .GroupBy(e => e.EventType!.Value) - .ToDictionary( - g => g.Key, - g => g.GroupBy(GetSubTierKey) - .ToDictionary(t => t.Key, t => t.Sum(e => e.Amount))); + static Dictionary> ByEventTier(List src) { + return src + .GroupBy(e => e.EventType!.Value) + .ToDictionary( + g => g.Key, + g => g.GroupBy(GetSubTierKey) + .ToDictionary(t => t.Key, t => t.Sum(e => e.Amount))); + } - return new SubscriptionTotals - { + return new SubscriptionTotals { SubTotal = events.Sum(e => e.Amount), SubTotalByEvent = ByEvent(events), SubTotalByEventTier = ByEventTier(events), - Simulated = new SubscriptionSimulatedTotals - { + Simulated = new SubscriptionSimulatedTotals { SubTotal = simEvents.Sum(e => e.Amount), SubTotalByEvent = ByEvent(simEvents), SubTotalByEventTier = ByEventTier(simEvents) @@ -1070,32 +1004,21 @@ static Dictionary> ByEventTier(List factory, ITimerService timerService, ILogger? logger = null) - : IDisposable, IAppService -{ - private readonly ILogger? _logger = logger; - + : IDisposable, IAppService { private const string KeyInterval = "prompt-interval"; private const string KeyDuration = "prompt-duration"; private const string KeyCooldown = "prompt-cooldown"; - + private readonly ILogger? _logger = logger; + private readonly Random _rng = new(); - private bool _subathonRunning = false; + private bool _subathonRunning; - public Task StartAsync(CancellationToken ct = default) - { + public Task StartAsync(CancellationToken ct = default) { SubathonEvents.SubathonDataUpdate += OnSubathonDataUpdate; SubathonEvents.SubathonEventProcessed += SubathonEventProcessed; SubathonEvents.PromptRunCancelRequested += OnCancelRequested; @@ -33,15 +30,13 @@ public Task StartAsync(CancellationToken ct = default) SubathonEvents.PromptSetEnabledChanged += OnPromptSetEnabledChanged; return Task.CompletedTask; } - - public Task StopAsync(CancellationToken ct = default) - { + + public Task StopAsync(CancellationToken ct = default) { Dispose(); return Task.CompletedTask; } - - public void Dispose() - { + + public void Dispose() { SubathonEvents.SubathonDataUpdate -= OnSubathonDataUpdate; SubathonEvents.SubathonEventProcessed -= SubathonEventProcessed; SubathonEvents.PromptRunCancelRequested -= OnCancelRequested; @@ -49,35 +44,29 @@ public void Dispose() SubathonEvents.PromptSetEnabledChanged -= OnPromptSetEnabledChanged; UnregisterAll(); } - - private void UnregisterAll() - { + + private void UnregisterAll() { timerService.Unregister(KeyInterval); timerService.Unregister(KeyDuration); timerService.Unregister(KeyCooldown); } - - private async void OnPromptSetEnabledChanged(bool enabled) - { - try - { - if (enabled && _subathonRunning) - { + + private async void OnPromptSetEnabledChanged(bool enabled) { + try { + if (enabled && _subathonRunning) { await TryStartSchedulerAsync(); return; } UnregisterAll(); - if (!enabled) - { - await using var db = await factory.CreateDbContextAsync(); - var run = await db.SubathonPromptRuns + if (!enabled) { + await using AppDbContext db = await factory.CreateDbContextAsync(); + SubathonPromptRun? run = await db.SubathonPromptRuns .Include(r => r.LinkedPrompt) .FirstOrDefaultAsync(r => r.Status == SubathonPromptRunStatus.Active); - if (run != null) - { + if (run != null) { run.Status = SubathonPromptRunStatus.Cancelled; run.EndedAt = DateTime.Now; db.SubathonPromptRuns.Update(run); @@ -86,116 +75,105 @@ private async void OnPromptSetEnabledChanged(bool enabled) } } } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "[PromptOrchestrator] Error handling prompt set enabled change"); } } - private void OnSubathonDataUpdate(SubathonData data, DateTime _) - { + + private void OnSubathonDataUpdate(SubathonData data, DateTime _) { bool shouldRun = data is { IsActive: true, IsPaused: false, IsLocked: false }; - - if (shouldRun && !_subathonRunning) - { + + if (shouldRun && !_subathonRunning) { _subathonRunning = true; Task.Run(async () => await TryStartSchedulerAsync()); } - else if (!shouldRun && _subathonRunning) - { + else if (!shouldRun && _subathonRunning) { _subathonRunning = false; timerService.Unregister(KeyInterval); } } - - private async Task TryStartSchedulerAsync() - { - await using var db = await factory.CreateDbContextAsync(); - - var set = await db.SubathonPromptSets + + private async Task TryStartSchedulerAsync() { + await using AppDbContext db = await factory.CreateDbContextAsync(); + + SubathonPromptSet? set = await db.SubathonPromptSets .Include(s => s.Prompts) .FirstOrDefaultAsync(s => s.IsActive); - + if (set is not { Enabled: true }) return; - - var activeRun = await db.SubathonPromptRuns + + SubathonPromptRun? activeRun = await db.SubathonPromptRuns .Include(r => r.LinkedPrompt) .FirstOrDefaultAsync(r => r.Status == SubathonPromptRunStatus.Active); - - if (activeRun != null) - { - if (activeRun.IsExpired) - { + + if (activeRun != null) { + if (activeRun.IsExpired) { ExpireRunAsync(db, activeRun, set); await db.SaveChangesAsync(); StartCooldownOrInterval(set); } - else - { - var remaining = activeRun.TimeRemaining(); + else { + TimeSpan remaining = activeRun.TimeRemaining(); timerService.Unregister(KeyInterval); timerService.Unregister(KeyCooldown); timerService.Register(KeyDuration, remaining, async _ => await OnDurationElapsedAsync(activeRun.Id)); - _logger?.LogInformation("[PromptOrchestrator] Resumed active prompt run {Id}, {Remaining} remaining", activeRun.Id, remaining); + _logger?.LogInformation("[PromptOrchestrator] Resumed active prompt run {Id}, {Remaining} remaining", + activeRun.Id, remaining); } + return; } - + StartIntervalTimer(set); } - - private void StartIntervalTimer(SubathonPromptSet set) - { + + private void StartIntervalTimer(SubathonPromptSet set) { timerService.Unregister(KeyInterval); timerService.Unregister(KeyCooldown); - - var interval = BuildInterval(set); + + TimeSpan interval = BuildInterval(set); _logger?.LogInformation("[PromptOrchestrator] Prompt interval scheduled in {Interval}", interval); timerService.Register(KeyInterval, interval, async _ => await OnIntervalElapsedAsync()); } - - private TimeSpan BuildInterval(SubathonPromptSet set) - { + + private TimeSpan BuildInterval(SubathonPromptSet set) { if (set.RandomOffset == TimeSpan.Zero) return set.Interval; double offsetSeconds = _rng.NextDouble() * set.RandomOffset.TotalSeconds * 2 - set.RandomOffset.TotalSeconds; - var result = set.Interval + TimeSpan.FromSeconds(offsetSeconds); + TimeSpan result = set.Interval + TimeSpan.FromSeconds(offsetSeconds); return result < TimeSpan.FromSeconds(1) ? TimeSpan.FromSeconds(1) : result; } - - private async Task OnIntervalElapsedAsync() - { + + private async Task OnIntervalElapsedAsync() { timerService.Unregister(KeyInterval); - + if (!_subathonRunning) return; - - await using var db = await factory.CreateDbContextAsync(); - var set = await db.SubathonPromptSets.Include(s => s.Prompts) + + await using AppDbContext db = await factory.CreateDbContextAsync(); + SubathonPromptSet? set = await db.SubathonPromptSets.Include(s => s.Prompts) .FirstOrDefaultAsync(s => s.IsActive); - + if (set == null || !set.Enabled) return; - - var pickable = set.PickablePrompts().ToList(); - if (pickable.Count == 0) - { + + List pickable = set.PickablePrompts().ToList(); + if (pickable.Count == 0) { _logger?.LogInformation("[PromptOrchestrator] No pickable prompts available, rescheduling interval"); StartIntervalTimer(set); return; } - - var chosen = pickable[_rng.Next(pickable.Count)]; + + SubathonPrompt chosen = pickable[_rng.Next(pickable.Count)]; await StartRunAsync(db, set, chosen); await db.SaveChangesAsync(); } - - private async Task StartRunAsync(AppDbContext db, SubathonPromptSet set, SubathonPrompt prompt) - { + + private async Task StartRunAsync(AppDbContext db, SubathonPromptSet set, SubathonPrompt prompt) { timerService.Unregister(KeyInterval); timerService.Unregister(KeyDuration); timerService.Unregister(KeyCooldown); - + long baseline = await ComputeBaselineAsync(db, prompt); - - var run = new SubathonPromptRun - { + + var run = new SubathonPromptRun { PromptId = prompt.Id, SetId = set.Id, StartedAt = DateTime.Now, @@ -204,22 +182,21 @@ private async Task StartRunAsync(AppDbContext db, SubathonPromptSet set, Subatho SnapshotTargetValue = prompt.Value, BaselineCount = baseline }; - + db.SubathonPromptRuns.Add(run); - _logger?.LogInformation("[PromptOrchestrator] Starting prompt run: {Text} (expires {Expires})", prompt.Text, run.ExpiresAt); - + _logger?.LogInformation("[PromptOrchestrator] Starting prompt run: {Text} (expires {Expires})", prompt.Text, + run.ExpiresAt); + timerService.Register(KeyDuration, prompt.CompletionDuration, async _ => await OnDurationElapsedAsync(run.Id)); - + SubathonEvents.RaisePromptRunStarted(run, prompt); } - - private async Task ComputeBaselineAsync(AppDbContext db, SubathonPrompt prompt) - { - var subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); + + private async Task ComputeBaselineAsync(AppDbContext db, SubathonPrompt prompt) { + SubathonData? subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); if (subathon == null) return 0; - - return prompt.Type switch - { + + return prompt.Type switch { SubathonPromptType.Points => subathon.Points, SubathonPromptType.Money => subathon.GetRoundedMoneySum(), SubathonPromptType.Follows => await CountEventsAsync(db, SubathonEventSubTypeHelper.FollowEventTypes), @@ -230,41 +207,37 @@ private async Task ComputeBaselineAsync(AppDbContext db, SubathonPrompt pr _ => 0 }; } - - private async void SubathonEventProcessed(SubathonEvent ev, bool processed) - { - try - { + + private async void SubathonEventProcessed(SubathonEvent ev, bool processed) { + try { if (!processed) return; - await using var db = await factory.CreateDbContextAsync(); - - var run = await db.SubathonPromptRuns + await using AppDbContext db = await factory.CreateDbContextAsync(); + + SubathonPromptRun? run = await db.SubathonPromptRuns .Include(r => r.LinkedPrompt) .FirstOrDefaultAsync(r => r.Status == SubathonPromptRunStatus.Active); if (run is null) return; if (run?.LinkedPrompt == null) return; if (run.IsExpired) return; - - var prompt = run.LinkedPrompt; + + SubathonPrompt? prompt = run.LinkedPrompt; if (!EventMatchesPrompt(ev, prompt)) return; - if (prompt.Type != SubathonPromptType.Money) - { + if (prompt.Type != SubathonPromptType.Money) { long delta = GetEventDelta(ev, prompt); if (delta == 0) return; } - await using var db2 = await factory.CreateDbContextAsync(); + await using AppDbContext db2 = await factory.CreateDbContextAsync(); long current = await GetCurrentCountAsync(db2, prompt); long progress = current - run.BaselineCount; - if (progress < run.SnapshotTargetValue) - { + if (progress < run.SnapshotTargetValue) { SubathonEvents.RaisePromptRunProgressUpdated(run, progress); return; } - + timerService.Unregister(KeyDuration); run.Status = SubathonPromptRunStatus.Completed; @@ -272,159 +245,141 @@ private async void SubathonEventProcessed(SubathonEvent ev, bool processed) db.SubathonPromptRuns.Update(run); await db.SaveChangesAsync(); - if (prompt is { IsInfinite: false, Quantity: > 0 }) - { - await using var dbQty = await factory.CreateDbContextAsync(); - var trackedPrompt = await dbQty.SubathonPrompts.FindAsync(prompt.Id); - if (trackedPrompt is { IsInfinite: false, Quantity: > 0 }) - { + if (prompt is { IsInfinite: false, Quantity: > 0 }) { + await using AppDbContext dbQty = await factory.CreateDbContextAsync(); + SubathonPrompt? trackedPrompt = await dbQty.SubathonPrompts.FindAsync(prompt.Id); + if (trackedPrompt is { IsInfinite: false, Quantity: > 0 }) { trackedPrompt.Quantity--; await dbQty.SaveChangesAsync(); prompt.Quantity = trackedPrompt.Quantity; } } - + _logger?.LogInformation("[PromptOrchestrator] Prompt run completed: {Text}", prompt.Text); SubathonEvents.RaisePromptRunUpdate(run, prompt); - - var set = await db.SubathonPromptSets.FindAsync(run.SetId); + + SubathonPromptSet? set = await db.SubathonPromptSets.FindAsync(run.SetId); if (set != null) StartCooldownOrInterval(set); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "[PromptOrchestrator] Error processing event for prompt run"); } } - - private async Task OnDurationElapsedAsync(Guid runId) - { + + private async Task OnDurationElapsedAsync(Guid runId) { timerService.Unregister(KeyDuration); - - await using var db = await factory.CreateDbContextAsync(); - - var run = await db.SubathonPromptRuns + + await using AppDbContext db = await factory.CreateDbContextAsync(); + + SubathonPromptRun? run = await db.SubathonPromptRuns .Include(r => r.LinkedPrompt) .FirstOrDefaultAsync(r => r.Id == runId); - + if (run == null || run.Status != SubathonPromptRunStatus.Active) return; - - var set = await db.SubathonPromptSets.FindAsync(run.SetId); - + + SubathonPromptSet? set = await db.SubathonPromptSets.FindAsync(run.SetId); + ExpireRunAsync(db, run, set); await db.SaveChangesAsync(); - + StartCooldownOrInterval(set); } - - private static void ExpireRunAsync(AppDbContext db, SubathonPromptRun run, SubathonPromptSet? set) - { + + private static void ExpireRunAsync(AppDbContext db, SubathonPromptRun run, SubathonPromptSet? set) { run.Status = SubathonPromptRunStatus.Expired; run.EndedAt = DateTime.Now; db.SubathonPromptRuns.Update(run); SubathonEvents.RaisePromptRunUpdate(run, run.LinkedPrompt); } - - - private void StartCooldownOrInterval(SubathonPromptSet? set) - { + + + private void StartCooldownOrInterval(SubathonPromptSet? set) { if (set is not { Enabled: true } || !_subathonRunning) return; - - if (set.Cooldown > TimeSpan.Zero) - { + + if (set.Cooldown > TimeSpan.Zero) { _logger?.LogInformation("[PromptOrchestrator] Prompt cooldown: {Cooldown}", set.Cooldown); - timerService.Register(KeyCooldown, set.Cooldown, async ct => - { + timerService.Register(KeyCooldown, set.Cooldown, async ct => { timerService.Unregister(KeyCooldown); - await using var db = await factory.CreateDbContextAsync(ct); - var refreshedSet = await db.SubathonPromptSets.Include(s => s.Prompts) - .FirstOrDefaultAsync(s => s.Id == set.Id, cancellationToken: ct); + await using AppDbContext db = await factory.CreateDbContextAsync(ct); + SubathonPromptSet? refreshedSet = await db.SubathonPromptSets.Include(s => s.Prompts) + .FirstOrDefaultAsync(s => s.Id == set.Id, ct); if (refreshedSet is { Enabled: true } && _subathonRunning) StartIntervalTimer(refreshedSet); }); } - else - { + else { StartIntervalTimer(set); } } - - private async void OnCancelRequested() - { - try - { + + private async void OnCancelRequested() { + try { timerService.Unregister(KeyDuration); - - await using var db = await factory.CreateDbContextAsync(); - var run = await db.SubathonPromptRuns + + await using AppDbContext db = await factory.CreateDbContextAsync(); + SubathonPromptRun? run = await db.SubathonPromptRuns .Include(r => r.LinkedPrompt) .FirstOrDefaultAsync(r => r.Status == SubathonPromptRunStatus.Active); - + if (run == null) return; - + run.Status = SubathonPromptRunStatus.Cancelled; run.EndedAt = DateTime.Now; db.SubathonPromptRuns.Update(run); await db.SaveChangesAsync(); - + _logger?.LogInformation("[PromptOrchestrator] Prompt run cancelled: {Id}", run.Id); SubathonEvents.RaisePromptRunUpdate(run, run.LinkedPrompt); - - var set = await db.SubathonPromptSets.FindAsync(run.SetId); + + SubathonPromptSet? set = await db.SubathonPromptSets.FindAsync(run.SetId); StartCooldownOrInterval(set); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "[PromptOrchestrator] Error cancelling prompt run"); } } - - private async void OnRunNowRequested(Guid promptId) - { - try - { - await using var db = await factory.CreateDbContextAsync(); - - var existing = await db.SubathonPromptRuns + + private async void OnRunNowRequested(Guid promptId) { + try { + await using AppDbContext db = await factory.CreateDbContextAsync(); + + SubathonPromptRun? existing = await db.SubathonPromptRuns .FirstOrDefaultAsync(r => r.Status == SubathonPromptRunStatus.Active); - if (existing != null) - { + if (existing != null) { timerService.Unregister(KeyDuration); existing.Status = SubathonPromptRunStatus.Cancelled; existing.EndedAt = DateTime.Now; db.SubathonPromptRuns.Update(existing); SubathonEvents.RaisePromptRunUpdate(existing, null); } - - var prompt = await db.SubathonPrompts.FindAsync(promptId); - var set = await db.SubathonPromptSets.Include(s => s.Prompts) + + SubathonPrompt? prompt = await db.SubathonPrompts.FindAsync(promptId); + SubathonPromptSet? set = await db.SubathonPromptSets.Include(s => s.Prompts) .FirstOrDefaultAsync(s => s.IsActive); - + if (prompt == null || set == null) return; - + timerService.Unregister(KeyInterval); timerService.Unregister(KeyCooldown); - + await StartRunAsync(db, set, prompt); await db.SaveChangesAsync(); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "[PromptOrchestrator] Error in RunNow for prompt {Id}", promptId); } } - private static bool EventMatchesPrompt(SubathonEvent ev, SubathonPrompt prompt) - { + + private static bool EventMatchesPrompt(SubathonEvent ev, SubathonPrompt prompt) { if (ev.EventType == null) return false; - - return prompt.Type switch - { + + return prompt.Type switch { SubathonPromptType.Points => ev.PointsValue > 0, SubathonPromptType.Money => ev.EventType.IsCurrencyDonation(), SubathonPromptType.Follows => ev.EventType.IsFollow(), SubathonPromptType.Tokens => ev.EventType.IsToken(), SubathonPromptType.Orders => ev.EventType.IsOrder(), - SubathonPromptType.Subs => prompt.SubType switch - { + SubathonPromptType.Subs => prompt.SubType switch { SubathonPromptSubType.NormalSubs => ev.EventType.IsSubscription() && !ev.EventType.IsGift(), SubathonPromptSubType.GiftSubs => ev.EventType.IsGift(), _ => ev.EventType.IsSubscription() @@ -433,9 +388,8 @@ private static bool EventMatchesPrompt(SubathonEvent ev, SubathonPrompt prompt) _ => false }; } - - private static bool MatchesEventType(SubathonEvent ev, SubathonPrompt prompt) - { + + private static bool MatchesEventType(SubathonEvent ev, SubathonPrompt prompt) { if (ev.EventType != prompt.FilterEventType) return false; if (prompt.FilterEventType is SubathonEventType.GoAffProOrder or SubathonEventType.JuniperMerchSale @@ -447,19 +401,16 @@ private static bool MatchesEventType(SubathonEvent ev, SubathonPrompt prompt) return true; } - - private static long GetEventDelta(SubathonEvent ev, SubathonPrompt prompt) - { - return prompt.Type switch - { + + private static long GetEventDelta(SubathonEvent ev, SubathonPrompt prompt) { + return prompt.Type switch { SubathonPromptType.Points => ev.PointsValue ?? 0, SubathonPromptType.Money => 0, SubathonPromptType.Follows => 1, - SubathonPromptType.Tokens => long.TryParse(ev.Value, out var t) ? t : 0, + SubathonPromptType.Tokens => long.TryParse(ev.Value, out long t) ? t : 0, SubathonPromptType.Orders => prompt.SubType == SubathonPromptSubType.Items ? ev.Amount : 1, SubathonPromptType.Subs => ev.Amount, - SubathonPromptType.Event => prompt.SubType switch - { + SubathonPromptType.Event => prompt.SubType switch { SubathonPromptSubType.Items => ev.Amount, SubathonPromptSubType.ByTier => ev.Amount, _ => 1 @@ -467,90 +418,81 @@ private static long GetEventDelta(SubathonEvent ev, SubathonPrompt prompt) _ => 0 }; } - - private static async Task CountEventsAsync(AppDbContext db, IEnumerable types) - { - var typeList = types.Cast().ToList(); + + private static async Task CountEventsAsync(AppDbContext db, IEnumerable types) { + List typeList = types.Cast().ToList(); return await db.SubathonEvents.AsNoTracking() .Where(e => e.ProcessedToSubathon && typeList.Contains(e.EventType)) .CountAsync(); } - - private static async Task CountTokensAsync(AppDbContext db) - { - var types = SubathonEventSubTypeHelper.TokenEventTypes.Cast().ToList(); - var rows = await db.SubathonEvents.AsNoTracking() + + private static async Task CountTokensAsync(AppDbContext db) { + List types = SubathonEventSubTypeHelper.TokenEventTypes.Cast().ToList(); + List rows = await db.SubathonEvents.AsNoTracking() .Where(e => e.ProcessedToSubathon && types.Contains(e.EventType)) .Select(e => e.Value) .ToListAsync(); - return rows.Sum(v => long.TryParse(v, out var n) ? n : 0); + return rows.Sum(v => long.TryParse(v, out long n) ? n : 0); } - - private static async Task CountOrdersAsync(AppDbContext db, SubathonPrompt prompt) - { - var types = SubathonEventSubTypeHelper.OrderEventTypes.Cast().ToList(); + + private static async Task CountOrdersAsync(AppDbContext db, SubathonPrompt prompt) { + List types = SubathonEventSubTypeHelper.OrderEventTypes.Cast().ToList(); if (prompt.SubType == SubathonPromptSubType.Items) - { return await db.SubathonEvents.AsNoTracking() .Where(e => e.ProcessedToSubathon && types.Contains(e.EventType)) .SumAsync(e => (long)e.Amount); - } return await db.SubathonEvents.AsNoTracking() .Where(e => e.ProcessedToSubathon && types.Contains(e.EventType)) .CountAsync(); } - - private static async Task CountSubsAsync(AppDbContext db, SubathonPrompt prompt) - { - var subTypes = SubathonEventSubTypeHelper.SubEventTypes.Cast() - .Where(e => prompt.SubType == SubathonPromptSubType.GiftSubs ? e.IsGift() : - (prompt.SubType != SubathonPromptSubType.NormalSubs || !e.IsGift())).ToList(); - var query = db.SubathonEvents.AsNoTracking(); + + private static async Task CountSubsAsync(AppDbContext db, SubathonPrompt prompt) { + List subTypes = SubathonEventSubTypeHelper.SubEventTypes.Cast() + .Where(e => prompt.SubType == SubathonPromptSubType.GiftSubs + ? e.IsGift() + : prompt.SubType != SubathonPromptSubType.NormalSubs || !e.IsGift()).ToList(); + IQueryable query = db.SubathonEvents.AsNoTracking(); query = query.Where(e => subTypes.Contains(e.EventType)); return await query.SumAsync(e => (long)e.Amount); } - - private static async Task CountEventTypeAsync(AppDbContext db, SubathonPrompt prompt) - { + + private static async Task CountEventTypeAsync(AppDbContext db, SubathonPrompt prompt) { if (prompt.FilterEventType == null) return 0; - - var filterType = prompt.FilterEventType; - var query = db.SubathonEvents.AsNoTracking() + + SubathonEventType? filterType = prompt.FilterEventType; + IQueryable query = db.SubathonEvents.AsNoTracking() .Where(e => e.ProcessedToSubathon && e.EventType == filterType); if (filterType is SubathonEventType.GoAffProOrder or SubathonEventType.JuniperMerchSale or SubathonEventType.MakeShipPledge or SubathonEventType.MakeShipSale - && !string.IsNullOrEmpty(prompt.FilterMeta)) - { - if (filterType == SubathonEventType.JuniperMerchSale && Guid.TryParse(prompt.FilterMeta, out var storeId)) - { - List productMetas = JuniperStoreRegistry.TryGetStore(storeId, out var store) + && !string.IsNullOrEmpty(prompt.FilterMeta)) { + if (filterType == SubathonEventType.JuniperMerchSale && + Guid.TryParse(prompt.FilterMeta, out Guid storeId)) { + List productMetas = JuniperStoreRegistry.TryGetStore(storeId, out JuniperStore? store) ? store.Products.Select(p => p.ProductId.ToString()).ToList() : []; query = query.Where(e => e.EventTypeMeta != null && productMetas.Contains(e.EventTypeMeta)); } - else - { + else { query = query.Where(e => e.EventTypeMeta == prompt.FilterMeta); } } - else if (prompt.SubType == SubathonPromptSubType.ByTier && !string.IsNullOrEmpty(prompt.FilterMeta)) + else if (prompt.SubType == SubathonPromptSubType.ByTier && !string.IsNullOrEmpty(prompt.FilterMeta)) { query = query.Where(e => e.Value == prompt.FilterMeta); - + } + if (prompt.SubType == SubathonPromptSubType.Items) return await query.SumAsync(e => (long)e.Amount); - + return await query.SumAsync(e => (long)e.Amount); } - - public static async Task GetCurrentCountAsync(AppDbContext db, SubathonPrompt prompt) - { - var subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); + + public static async Task GetCurrentCountAsync(AppDbContext db, SubathonPrompt prompt) { + SubathonData? subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); if (subathon == null) return 0; - - return prompt.Type switch - { + + return prompt.Type switch { SubathonPromptType.Points => subathon.Points, SubathonPromptType.Money => subathon.GetRoundedMoneySum(), SubathonPromptType.Follows => await CountEventsAsync(db, SubathonEventSubTypeHelper.FollowEventTypes), diff --git a/SubathonManager.Services/TelemetryService.cs b/SubathonManager.Services/TelemetryService.cs index dfaf5de5..87eb2cfe 100644 --- a/SubathonManager.Services/TelemetryService.cs +++ b/SubathonManager.Services/TelemetryService.cs @@ -7,60 +7,59 @@ namespace SubathonManager.Services; [ExcludeFromCodeCoverage] -public class TelemetryService(ILogger? logger, IConfig config, ITimerService? timerService = null): IDisposable, IAppService -{ +public class TelemetryService(ILogger? logger, IConfig config, ITimerService? timerService = null) + : IDisposable, IAppService { private readonly HttpClient _http = new(); private readonly string _telemetryUrl = "https://telemetry.subathonmanager.app"; - private static string? TelemetryKey => - "BUILD_TELEMETRY_KEY".StartsWith("BUILD") ? Environment.GetEnvironmentVariable("SM_TELEMETRY_SECRET") : "BUILD_TELEMETRY_KEY"; - private async Task TryPingAsync(CancellationToken ct = default) - { - if (!config.GetBool("Telemetry", "Enabled", false)) return; - - try - { + private static string? TelemetryKey => + "BUILD_TELEMETRY_KEY".StartsWith("BUILD") + ? Environment.GetEnvironmentVariable("SM_TELEMETRY_SECRET") + : "BUILD_TELEMETRY_KEY"; + + public Task StartAsync(CancellationToken ct = default) { + timerService?.Unregister("telemetry-ping"); + timerService?.Register("telemetry-ping", TimeSpan.FromHours(1), TryPingAsync); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken ct = default) { + timerService?.Unregister("telemetry-ping"); + return Task.CompletedTask; + } + + public void Dispose() { + timerService?.Unregister("telemetry-ping"); + } + + private async Task TryPingAsync(CancellationToken ct = default) { + if (!config.GetBool("Telemetry", "Enabled")) return; + + try { await Task.Delay(5000, ct); // offset a bit var connections = Utils.GetAllConnections().Where(x => x.Service != "Unknown") - .Select(c => new - { + .Select(c => new { source = c.Source.ToString(), service = c.Service, connected = c.Status }); - - var payload = new - { + + var payload = new { install_id = config.GetInstallId(), version = AppServices.AppVersion, connections }; - + var request = new HttpRequestMessage(HttpMethod.Post, _telemetryUrl); request.Headers.Add("X-Telemetry-Key", TelemetryKey); request.Content = JsonContent.Create(payload); - var response = await _http.SendAsync(request, ct); - logger?.LogDebug("Telemetry sent for install id {InstallId}. Response: {ResponseCode}", config.GetInstallId(), response.StatusCode); + HttpResponseMessage response = await _http.SendAsync(request, ct); + logger?.LogDebug("Telemetry sent for install id {InstallId}. Response: {ResponseCode}", + config.GetInstallId(), response.StatusCode); + } + catch { + /**/ } - catch { /**/ } - } - - public void Dispose() - { - timerService?.Unregister("telemetry-ping"); - } - - public Task StartAsync(CancellationToken ct = default) - { - timerService?.Unregister("telemetry-ping"); - timerService?.Register("telemetry-ping", TimeSpan.FromHours(1), TryPingAsync); - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken ct = default) - { - timerService?.Unregister("telemetry-ping"); - return Task.CompletedTask; } } \ No newline at end of file diff --git a/SubathonManager.Services/TimerService.cs b/SubathonManager.Services/TimerService.cs index 385d35f9..fc89c5c8 100644 --- a/SubathonManager.Services/TimerService.cs +++ b/SubathonManager.Services/TimerService.cs @@ -1,160 +1,143 @@ -using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; -using SubathonManager.Core.Events; +using Microsoft.Extensions.Logging; using SubathonManager.Core; -using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; namespace SubathonManager.Services; - [ExcludeFromCodeCoverage] -public class TimerService : IDisposable, IAppService, ITimerService -{ - private readonly TimeSpan _tickInterval = TimeSpan.FromSeconds(1); +public class TimerService : IDisposable, IAppService, ITimerService { private readonly CancellationTokenSource _cts = new(); + private readonly Lock _lock = new(); private readonly ILogger? _logger = AppServices.Provider.GetRequiredService>(); - + private readonly Dictionary _scheduledActions = []; - private readonly Lock _lock = new(); - + private readonly TimeSpan _tickInterval = TimeSpan.FromSeconds(1); + private PeriodicTimer? _periodicTimer; - - public async Task StartAsync(CancellationToken ct = default) - { + + public async Task StartAsync(CancellationToken ct = default) { _periodicTimer ??= new PeriodicTimer(_tickInterval); - try - { + try { if (!_cts.IsCancellationRequested) - { - while (await _periodicTimer.WaitForNextTickAsync(_cts.Token)) - { + while (await _periodicTimer.WaitForNextTickAsync(_cts.Token)) { await TickAsync(); if (_cts.IsCancellationRequested) break; } - } } - catch (OperationCanceledException ex) - { + catch (OperationCanceledException ex) { if (_cts.IsCancellationRequested) return; _logger?.LogError(ex, ex.Message); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, ex.Message); } } - public void Stop() - { + public Task StopAsync(CancellationToken ct = default) { + Stop(); + return Task.CompletedTask; + } + + public void Dispose() { + if (!_cts.IsCancellationRequested) + _cts.Cancel(); + _periodicTimer?.Dispose(); + _cts.Dispose(); + } + + public IDisposable Register(string key, TimeSpan interval, Action callback) { + return Register(key, interval, _ => { + callback(); + return Task.CompletedTask; + }); + } + + public IDisposable Register(string key, TimeSpan interval, Func callback) { + _logger?.LogInformation($"Registering {key} for interval: {interval}"); + var action = new ScheduledAction(key, interval, callback); + lock (_lock) { + _scheduledActions[key] = action; + } + + return new ActionRegistration(() => Unregister(key, action)); + } + + public void Unregister(string key) { + lock (_lock) { + if (_scheduledActions.Remove(key)) _logger?.LogInformation("Removed {Key} timed action", key); + } + } + + public void Stop() { if (_cts.IsCancellationRequested) return; _cts.Cancel(); - try - { + try { _periodicTimer?.Dispose(); } - catch { /**/ } - _periodicTimer = null; - } + catch { + /**/ + } - public Task StopAsync(CancellationToken ct = default) - { - Stop(); - return Task.CompletedTask; + _periodicTimer = null; } // private void Tick() // { // TimerEvents.RaiseTimerTickEvent(_tickInterval); // } - - private async Task TickAsync() - { + + private async Task TickAsync() { TimerEvents.RaiseTimerTickEvent(_tickInterval); ScheduledAction[] snapshot; - lock (_lock) snapshot = [.. _scheduledActions.Values]; + lock (_lock) { + snapshot = [.. _scheduledActions.Values]; + } - foreach (var action in snapshot) - { + foreach (ScheduledAction action in snapshot) { action.Accumulator += _tickInterval; if (action.Accumulator < action.Interval) continue; action.Accumulator = TimeSpan.Zero; action.IsRunning = true; - try - { + try { await action.Callback(_cts.Token); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "Scheduled action threw an exception."); } - finally - { + finally { action.IsRunning = false; } } } - - public IDisposable Register(string key, TimeSpan interval, Action callback) - => Register(key, interval, _ => { callback(); return Task.CompletedTask; }); - - public IDisposable Register(string key, TimeSpan interval, Func callback) - { - _logger?.LogInformation($"Registering {key} for interval: {interval}"); - var action = new ScheduledAction(key, interval, callback); - lock (_lock) _scheduledActions[key] = action; - return new ActionRegistration(() => Unregister(key, action)); - } - public void Unregister(string key) - { - lock (_lock) - { - if (_scheduledActions.Remove(key)) - { - _logger?.LogInformation("Removed {Key} timed action", key); - } - - } - } - - private void Unregister(string key, ScheduledAction expected) - { - lock (_lock) - { - if (_scheduledActions.TryGetValue(key, out var current) && ReferenceEquals(current, expected)) - { + private void Unregister(string key, ScheduledAction expected) { + lock (_lock) { + if (_scheduledActions.TryGetValue(key, out ScheduledAction? current) && + ReferenceEquals(current, expected)) { _scheduledActions.Remove(key); _logger?.LogInformation("Removed {Key} timed action", key); } } } - - public void Dispose() - { - if(!_cts.IsCancellationRequested) - _cts.Cancel(); - _periodicTimer?.Dispose(); - _cts.Dispose(); - } - - private sealed class ScheduledAction(string key, TimeSpan interval, Func callback) - { + + private sealed class ScheduledAction(string key, TimeSpan interval, Func callback) { public string Key { get; } = key; public TimeSpan Interval { get; } = interval; public Func Callback { get; } = callback; public TimeSpan Accumulator { get; set; } = TimeSpan.Zero; - public bool IsRunning { get; set; } = false; + public bool IsRunning { get; set; } } - private sealed class ActionRegistration(Action onDispose) : IDisposable - { + private sealed class ActionRegistration(Action onDispose) : IDisposable { private int _disposed; - public void Dispose() - { + + public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 0) onDispose(); } diff --git a/SubathonManager.Services/WheelSpinTriggerService.cs b/SubathonManager.Services/WheelSpinTriggerService.cs index 009b7416..8c3808f3 100644 --- a/SubathonManager.Services/WheelSpinTriggerService.cs +++ b/SubathonManager.Services/WheelSpinTriggerService.cs @@ -15,26 +15,26 @@ public class WheelSpinTriggerService( IDbContextFactory factory, CurrencyService currencyService, ILogger? logger = null) - : IDisposable, IAppService -{ - public Task StartAsync(CancellationToken cancellationToken = default) - { + : IDisposable, IAppService { + public Task StartAsync(CancellationToken cancellationToken = default) { SubathonEvents.SubathonEventProcessed += OnSubathonEventProcessed; return Task.CompletedTask; } - public Task StopAsync(CancellationToken cancellationToken = default) - { + public Task StopAsync(CancellationToken cancellationToken = default) { SubathonEvents.SubathonEventProcessed -= OnSubathonEventProcessed; return Task.CompletedTask; } - private async void OnSubathonEventProcessed(SubathonEvent ev, bool wasEffective) - { + public void Dispose() { + SubathonEvents.SubathonEventProcessed -= OnSubathonEventProcessed; + } + + private async void OnSubathonEventProcessed(SubathonEvent ev, bool wasEffective) { if (ev.EventType == null) return; if (ev.Command != SubathonCommandType.None) return; - var subType = ev.EventType.GetSubType(); + SubathonEventSubType subType = ev.EventType.GetSubType(); if (subType is SubathonEventSubType.Unknown or SubathonEventSubType.CommandLike or SubathonEventSubType.FollowLike or SubathonEventSubType.RaidLike or SubathonEventSubType.TrainLike or SubathonEventSubType.EventLike) return; @@ -42,22 +42,19 @@ or SubathonEventSubType.FollowLike or SubathonEventSubType.RaidLike if (subType == SubathonEventSubType.DonationLike && (string.IsNullOrEmpty(ev.Currency) || ev.Currency == "???")) return; - try - { + try { await ProcessTriggers(ev, subType); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogError(ex, "Error processing wheel spin triggers for event {EventType}: {Message}", ev.EventType, ex.Message); } } - private async Task ProcessTriggers(SubathonEvent ev, SubathonEventSubType subType) - { - await using var db = await factory.CreateDbContextAsync(); + private async Task ProcessTriggers(SubathonEvent ev, SubathonEventSubType subType) { + await using AppDbContext db = await factory.CreateDbContextAsync(); - var isSubLike = subType is SubathonEventSubType.SubLike or SubathonEventSubType.GiftSubLike; + bool isSubLike = subType is SubathonEventSubType.SubLike or SubathonEventSubType.GiftSubLike; List candidates = await db.WheelSpinTriggers .Where(t => t.IsEnabled && t.EventType == ev.EventType) @@ -67,37 +64,32 @@ private async Task ProcessTriggers(SubathonEvent ev, SubathonEventSubType subTyp // sub/membership events, filter by tier meta if (isSubLike) - { candidates = candidates .Where(t => string.IsNullOrEmpty(t.TierValue) || string.Equals(t.TierValue, ev.Value, StringComparison.OrdinalIgnoreCase)) .ToList(); - } - + if (ev.EventType is SubathonEventType.GoAffProOrder or SubathonEventType.JuniperMerchSale or SubathonEventType.MakeShipPledge or SubathonEventType.MakeShipSale) - { candidates = candidates .Where(t => OrderMetaFilter.Matches(ev.EventType, ev.EventTypeMeta, t.TierValue)) .OrderByDescending(t => OrderMetaFilter.Specificity(ev.EventType, ev.EventTypeMeta, t.TierValue)) .ToList(); - } - + if (candidates.Count == 0) return; // unique rules, there should only be one match; take first anyways // we do only apply for most specific at a time if we have generic then specific - var trigger = candidates[0]; + WheelSpinTrigger trigger = candidates[0]; int spinsToAdd = await CalculateSpins(ev, trigger, subType); if (spinsToAdd < 1) return; - var currentSpins = await StateValueHelper.GetAsync(factory, StateKeys.WheelSpinsOwed, 0); - var newSpins = currentSpins + spinsToAdd; + var currentSpins = await StateValueHelper.GetAsync(factory, StateKeys.WheelSpinsOwed); + int newSpins = currentSpins + spinsToAdd; await StateValueHelper.SetAsync(factory, StateKeys.WheelSpinsOwed, newSpins); - var history = new WheelSpinTriggerHistory - { + var history = new WheelSpinTriggerHistory { TriggerId = trigger.Id, TriggeredAt = DateTime.Now, TriggerUser = ev.User, @@ -118,15 +110,12 @@ private async Task ProcessTriggers(SubathonEvent ev, SubathonEventSubType subTyp ev.EventType, spinsToAdd, ev.User); } - private async Task CalculateSpins(SubathonEvent ev, WheelSpinTrigger trigger, SubathonEventSubType subType) - { - switch (subType) - { + private async Task CalculateSpins(SubathonEvent ev, WheelSpinTrigger trigger, SubathonEventSubType subType) { + switch (subType) { case SubathonEventSubType.SubLike: // single item return trigger.SpinsToAdd; - case SubathonEventSubType.GiftSubLike: - { + case SubathonEventSubType.GiftSubLike: { if (trigger.CountThreshold is null or <= 0) return trigger.SpinsToAdd; int giftCount = ev.Amount; @@ -134,18 +123,15 @@ private async Task CalculateSpins(SubathonEvent ev, WheelSpinTrigger trigge return multiplier * trigger.SpinsToAdd; } - case SubathonEventSubType.TokenLike: - { + case SubathonEventSubType.TokenLike: { if (trigger.CountThreshold is null or <= 0) return 0; if (!double.TryParse(ev.Value, out double tokenCount)) return 0; int multiplier = (int)tokenCount / trigger.CountThreshold.Value; return multiplier * trigger.SpinsToAdd; } - case SubathonEventSubType.OrderLike: - { - if (trigger.CountThreshold is > 0) - { + case SubathonEventSubType.OrderLike: { + if (trigger.CountThreshold is > 0) { int itemCount = ev.Amount; int multiplier = itemCount / trigger.CountThreshold.Value; return multiplier * trigger.SpinsToAdd; @@ -153,11 +139,10 @@ private async Task CalculateSpins(SubathonEvent ev, WheelSpinTrigger trigge if (trigger.MoneyThreshold is > 0 && !string.IsNullOrEmpty(trigger.Currency) && !string.IsNullOrEmpty(ev.Currency) - && !string.IsNullOrEmpty(ev.Value)) - { + && !string.IsNullOrEmpty(ev.Value)) { if (!double.TryParse(ev.Value, out double orderValue)) return 0; double converted = await currencyService.ConvertAsync(orderValue, ev.Currency, trigger.Currency); - int multiplier = (int)(converted / trigger.MoneyThreshold.Value); + var multiplier = (int)(converted / trigger.MoneyThreshold.Value); return multiplier * trigger.SpinsToAdd; } @@ -165,13 +150,12 @@ private async Task CalculateSpins(SubathonEvent ev, WheelSpinTrigger trigge return trigger.SpinsToAdd; } - case SubathonEventSubType.DonationLike: - { + case SubathonEventSubType.DonationLike: { if (trigger.MoneyThreshold is null or <= 0) return 0; if (string.IsNullOrEmpty(trigger.Currency) || string.IsNullOrEmpty(ev.Currency)) return 0; if (!double.TryParse(ev.Value, out double donationValue)) return 0; double converted = await currencyService.ConvertAsync(donationValue, ev.Currency, trigger.Currency); - int multiplier = (int)(converted / trigger.MoneyThreshold.Value); + var multiplier = (int)(converted / trigger.MoneyThreshold.Value); return multiplier * trigger.SpinsToAdd; } @@ -179,9 +163,4 @@ private async Task CalculateSpins(SubathonEvent ev, WheelSpinTrigger trigge return 0; } } - - public void Dispose() - { - SubathonEvents.SubathonEventProcessed -= OnSubathonEventProcessed; - } -} +} \ No newline at end of file diff --git a/SubathonManager.Tests/CoreUnitTests/AppServicesTests.cs b/SubathonManager.Tests/CoreUnitTests/AppServicesTests.cs index cb8bf12b..67c466b0 100644 --- a/SubathonManager.Tests/CoreUnitTests/AppServicesTests.cs +++ b/SubathonManager.Tests/CoreUnitTests/AppServicesTests.cs @@ -1,47 +1,41 @@ -using Moq; -using System.Reflection; +using System.Reflection; +using Microsoft.Extensions.Logging; +using Moq; -namespace SubathonManager.Tests.CoreUnitTests -{ - public class AppServicesTests - { - private readonly Mock _loggerMock = new(); +namespace SubathonManager.Tests.CoreUnitTests; - [Fact] - public void AppVersion_IsNotNullOrEmpty() - { - string version = AppServices.AppVersion; - Assert.False(string.IsNullOrWhiteSpace(version)); - } +public class AppServicesTests { + private readonly Mock _loggerMock = new(); - [Fact] - public void GetVersion_Returns_VersionInstance() - { - MethodInfo? mi = typeof(AppServices).GetMethod("GetVersion", BindingFlags.NonPublic | BindingFlags.Static); - Assert.NotNull(mi); - var result = mi!.Invoke(null, []); - Assert.IsType(result); - var ver = (Version)result!; - Assert.True(ver.Major >= 0); - Assert.True(ver.Minor >= 0); - Assert.True(ver.Build >= -1); - } + [Fact] + public void AppVersion_IsNotNullOrEmpty() { + string version = AppServices.AppVersion; + Assert.False(string.IsNullOrWhiteSpace(version)); + } - [Fact] - public async Task CheckForUpdate_ReturnsSafeTuple() - { - var logger = _loggerMock.Object; - var res = await AppServices.CheckForUpdate(logger); - Assert.IsType>(res); - } + [Fact] + public void GetVersion_Returns_VersionInstance() { + MethodInfo? mi = typeof(AppServices).GetMethod("GetVersion", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(mi); + object? result = mi!.Invoke(null, []); + Assert.IsType(result); + var ver = (Version)result!; + Assert.True(ver.Major >= 0); + Assert.True(ver.Minor >= 0); + Assert.True(ver.Build >= -1); + } - [Fact] - public async Task InstallUpdate_ReturnsFalse_WhenAssetIsNull() - { - var logger = _loggerMock.Object; - bool installed = await AppServices.InstallUpdate(null, logger); - Assert.False(installed); - } + [Fact] + public async Task CheckForUpdate_ReturnsSafeTuple() { + ILogger logger = _loggerMock.Object; + (bool, string?, string?) res = await AppServices.CheckForUpdate(logger); + Assert.IsType>(res); + } + [Fact] + public async Task InstallUpdate_ReturnsFalse_WhenAssetIsNull() { + ILogger logger = _loggerMock.Object; + bool installed = await AppServices.InstallUpdate(null, logger); + Assert.False(installed); } } \ No newline at end of file diff --git a/SubathonManager.Tests/CoreUnitTests/EnumValidationTests.cs b/SubathonManager.Tests/CoreUnitTests/EnumValidationTests.cs index 07a2df79..b9914463 100644 --- a/SubathonManager.Tests/CoreUnitTests/EnumValidationTests.cs +++ b/SubathonManager.Tests/CoreUnitTests/EnumValidationTests.cs @@ -2,137 +2,109 @@ namespace SubathonManager.Tests.CoreUnitTests; -public class EnumValidationTests -{ +public class EnumValidationTests { [Fact] - public void AssertEnumGroups() - { - var streamSources = Enum.GetValues().Where(x => + public void AssertEnumGroups() { + List streamSources = Enum.GetValues().Where(x => x.GetGroup() == SubathonSourceGroup.Stream).ToList(); Assert.True(streamSources.Any()); Assert.Contains(SubathonEventSource.Twitch, streamSources); Assert.Contains(SubathonEventSource.YouTube, streamSources); Assert.DoesNotContain(SubathonEventSource.GoAffPro, streamSources); - - var externalServices = Enum.GetValues().Where(x => + + List externalServices = Enum.GetValues().Where(x => x.GetGroup() == SubathonSourceGroup.ExternalService).ToList(); Assert.True(externalServices.Any()); Assert.Contains(SubathonEventSource.KoFi, externalServices); Assert.Contains(SubathonEventSource.GoAffPro, externalServices); Assert.DoesNotContain(SubathonEventSource.Twitch, externalServices); - - var extensionServices = Enum.GetValues().Where(x => + + List extensionServices = Enum.GetValues().Where(x => x.GetGroup() == SubathonSourceGroup.StreamExtension).ToList(); Assert.True(extensionServices.Any()); Assert.Contains(SubathonEventSource.Blerp, extensionServices); Assert.Contains(SubathonEventSource.StreamElements, extensionServices); Assert.DoesNotContain(SubathonEventSource.Picarto, extensionServices); - - var eventTypes = Enum.GetValues().GroupBy(x => x.GetSource()).ToList(); + + List> eventTypes = + Enum.GetValues().GroupBy(x => x.GetSource()).ToList(); Assert.True(eventTypes.Any()); - foreach (var group in eventTypes) - { - if (group.Key == SubathonEventSource.GoAffPro) - { - foreach (var subathonEventType in group) - { - var eventType = (SubathonEventType?) subathonEventType; + foreach (IGrouping group in eventTypes) { + if (group.Key == SubathonEventSource.GoAffPro) { + foreach (SubathonEventType subathonEventType in group) { + var eventType = (SubathonEventType?)subathonEventType; Assert.True(eventType.IsOrder()); if (subathonEventType.ToString().Contains("Sale")) - { Assert.EndsWith("Sale", subathonEventType.ToString()); - } else if (subathonEventType.ToString().Contains("Pledge")) - { Assert.EndsWith("Pledge", subathonEventType.ToString()); - } else Assert.EndsWith("Order", subathonEventType.ToString()); Assert.Equal(eventType.GetDescription(), eventType.GetLabel()); - if (subathonEventType == SubathonEventType.GoAffProOrder) - { + if (subathonEventType == SubathonEventType.GoAffProOrder) { Assert.True(subathonEventType.IsEnabled()); Assert.True(subathonEventType.GetLegacyGoAffProSiteId() <= 0); } - else - { + else { Assert.True(subathonEventType.IsDisabled()); Assert.True(subathonEventType.GetLegacyGoAffProSiteId() > 0); } } - var siteIds = group.Where(t => t != SubathonEventType.GoAffProOrder) + List siteIds = group.Where(t => t != SubathonEventType.GoAffProOrder) .Select(t => t.GetLegacyGoAffProSiteId()).ToList(); Assert.Equal(siteIds.Count, siteIds.Distinct().Count()); } if (group.Key.GetGroup() == SubathonSourceGroup.Stream || group.Key.GetGroup() == SubathonSourceGroup.ExternalService || - group.Key.GetGroup() == SubathonSourceGroup.StreamExtension) - { + group.Key.GetGroup() == SubathonSourceGroup.StreamExtension) { if (group.Key == SubathonEventSource.GoAffPro) continue; - if (group.Key == SubathonEventSource.Twitch) - { + if (group.Key == SubathonEventSource.Twitch) { Assert.True(group.Key.IsEnabled()); Assert.False(group.Key.IsDisabled()); } - foreach (var subathonEventType in group) - { - if (subathonEventType == SubathonEventType.TwitchSub) - { + + foreach (SubathonEventType subathonEventType in group) { + if (subathonEventType == SubathonEventType.TwitchSub) { Assert.True(group.Key.IsEnabled()); Assert.False(group.Key.IsDisabled()); } + Assert.True(subathonEventType.GetOrderNumber() > 0); - - var eventType = (SubathonEventType?) subathonEventType; + + var eventType = (SubathonEventType?)subathonEventType; if (group.Key.ToString().Contains("Juniper")) - { - Assert.Contains("Juniper",subathonEventType.ToString()); - } + Assert.Contains("Juniper", subathonEventType.ToString()); else - { Assert.Contains(group.Key.ToString(), subathonEventType.ToString()); - } if (eventType.IsSubscription() && !eventType.IsGift()) - { Assert.Contains(eventType.GetLabel(), "Subscription Membership"); - } - if (eventType.IsGift()) - { + if (eventType.IsGift()) { Assert.Contains("Gift", eventType.GetLabel()); Assert.Contains(eventType.GetLabel(), "Gift Subscription Gift Membership"); } if (eventType.IsOrder() && eventType.GetSource() != SubathonEventSource.Throne) - { Assert.True(subathonEventType.ToString().Contains("Order") || subathonEventType.ToString().Contains("Pledge") || subathonEventType.ToString().Contains("Sale")); - } - + if (eventType.IsOrder() && eventType.GetSource() == SubathonEventSource.Throne) - { Assert.Contains("Gift", subathonEventType.ToString()); - } if (eventType.IsEvent() && eventType.GetSource() == SubathonEventSource.Throne) - { Assert.Contains("Crowd", subathonEventType.ToString()); - } if (eventType.IsCurrencyDonation()) - { Assert.Contains(eventType.GetLabel(), "Charity Donation Tip SuperChat"); - } Assert.Equal(eventType.GetDescription(), $"{eventType.GetSource()} {eventType.GetLabel()}"); } - + Assert.Contains(group.Key.GetLabel(), $"{group.Key.GetGroup().GetLabel()} ||||| {group.Key}"); } } } - } \ No newline at end of file diff --git a/SubathonManager.Tests/CoreUnitTests/ResourcePathsTests.cs b/SubathonManager.Tests/CoreUnitTests/ResourcePathsTests.cs index cd79e72c..03c87726 100644 --- a/SubathonManager.Tests/CoreUnitTests/ResourcePathsTests.cs +++ b/SubathonManager.Tests/CoreUnitTests/ResourcePathsTests.cs @@ -2,9 +2,7 @@ namespace SubathonManager.Tests.CoreUnitTests; -public class ResourcePathsPureTests -{ - +public class ResourcePathsPureTests { [Theory] [InlineData("/resources/images/logo.png", true)] [InlineData("/RESOURCES/images/logo.png", true)] @@ -15,10 +13,11 @@ public class ResourcePathsPureTests [InlineData("", false)] [InlineData(" ", false)] [InlineData(null, false)] - public void IsResourceUrl_Branches(string? value, bool expected) - => Assert.Equal(expected, ResourcePaths.IsResourceUrl(value)); + public void IsResourceUrl_Branches(string? value, bool expected) { + Assert.Equal(expected, ResourcePaths.IsResourceUrl(value)); + } + - [Theory] [InlineData("/resources/images/logo.png", "images/logo.png")] [InlineData("\\resources\\images\\logo.png", "images/logo.png")] @@ -26,27 +25,31 @@ public void IsResourceUrl_Branches(string? value, bool expected) [InlineData("/resources/images/logo.png#frag", "images/logo.png")] [InlineData("/resources/images/logo.png?v=2#frag", "images/logo.png")] [InlineData("/resources//images/logo.png/", "images/logo.png")] - public void RelativeFromUrl_StripsPrefixQueryAndFragment(string url, string expected) - => Assert.Equal(expected, ResourcePaths.RelativeFromUrl(url)); + public void RelativeFromUrl_StripsPrefixQueryAndFragment(string url, string expected) { + Assert.Equal(expected, ResourcePaths.RelativeFromUrl(url)); + } [Theory] [InlineData("/resources/")] [InlineData("/resources/?x=1")] [InlineData("/notresources/logo.png")] [InlineData(null)] - public void RelativeFromUrl_ReturnsNull_WhenNoUsableRelative(string? url) - => Assert.Null(ResourcePaths.RelativeFromUrl(url)); + public void RelativeFromUrl_ReturnsNull_WhenNoUsableRelative(string? url) { + Assert.Null(ResourcePaths.RelativeFromUrl(url)); + } + - [Theory] [InlineData(null)] [InlineData("")] - public void FindReferences_EmptyInput_YieldsNothing(string? text) - => Assert.Empty(ResourcePaths.FindReferences(text)); + public void FindReferences_EmptyInput_YieldsNothing(string? text) { + Assert.Empty(ResourcePaths.FindReferences(text)); + } [Fact] - public void FindReferences_NoMatch_YieldsNothing() - => Assert.Empty(ResourcePaths.FindReferences("

nothing to see here

")); + public void FindReferences_NoMatch_YieldsNothing() { + Assert.Empty(ResourcePaths.FindReferences("

nothing to see here

")); + } [Theory] [InlineData("")] @@ -57,175 +60,167 @@ public void FindReferences_NoMatch_YieldsNothing() [InlineData("load(a,/resources/images/logo.png)")] [InlineData("x;/resources/images/logo.png")] [InlineData("[/resources/images/logo.png ")] - public void FindReferences_AcceptedDelimiters(string text) - => Assert.Equal(["images/logo.png"], ResourcePaths.FindReferences(text)); + public void FindReferences_AcceptedDelimiters(string text) { + Assert.Equal(["images/logo.png"], ResourcePaths.FindReferences(text)); + } [Fact] - public void FindReferences_RejectsMidWordMatch() - { + public void FindReferences_RejectsMidWordMatch() { Assert.Empty(ResourcePaths.FindReferences("https://cdn.example.com/resources/images/logo.png")); } [Fact] - public void FindReferences_StripsQueryAndFragment() - { - Assert.Equal(new[] {"audio/ding.mp3"}, + public void FindReferences_StripsQueryAndFragment() { + Assert.Equal(new[] { "audio/ding.mp3" }, ResourcePaths.FindReferences("