diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..b0ea8dbe --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.sh text eol=lf +external/linux/** text eol=lf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 781fb236..dfac118c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,7 +39,7 @@ jobs: dotnet-version: '9.0.x' - name: Cache NuGet packages - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} @@ -97,7 +97,7 @@ jobs: dotnet-version: '9.0.x' - name: Cache NuGet packages - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} @@ -250,6 +250,17 @@ jobs: exit 1 fi cp external/streamerbot/*.sb ./ || true + + - name: Package Stream Deck plugin + run: | + sdplugin=external/streamdeck/com.wolfwithsword.subathonmanager.sdPlugin + mkdir -p $sdplugin/images + for spec in pluginIcon:28 categoryIcon:28 actionIcon:20 keyIcon:72; do + name=${spec%%:*}; size=${spec##*:} + convert assets/icon.png -resize ${size}x${size} $sdplugin/images/${name}.png + convert assets/icon.png -resize $((size*2))x$((size*2)) $sdplugin/images/${name}@2x.png + done + (cd external/streamdeck && zip -r ../../SubathonManager_StreamDeck.streamDeckPlugin com.wolfwithsword.subathonmanager.sdPlugin) - name: Create GitHub Release @@ -260,6 +271,7 @@ jobs: files: | ${{ needs.build.outputs.filename }} *.sb + *.streamDeckPlugin env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -290,6 +302,7 @@ jobs: files: | ${{ needs.build.outputs.filename }} *.sb + *.streamDeckPlugin token: ${{ secrets.GITHUB_TOKEN }} notify: diff --git a/.gitignore b/.gitignore index e8722ed5..7b2d8393 100644 --- a/.gitignore +++ b/.gitignore @@ -215,4 +215,8 @@ bin obj TestResults/ -coverage-report/ \ No newline at end of file +coverage-report/ + +# Stream Deck plugin packaging stuff +*.streamDeckPlugin +external/streamdeck/**/images/ diff --git a/SubathonManager.Core/Config.cs b/SubathonManager.Core/Config.cs index 43c8d579..24769b5a 100644 --- a/SubathonManager.Core/Config.cs +++ b/SubathonManager.Core/Config.cs @@ -106,6 +106,7 @@ private void CreateDefault() 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}"; diff --git a/SubathonManager.Core/Enums/EnumExtensions.cs b/SubathonManager.Core/Enums/EnumExtensions.cs index acbc1086..2e7f7725 100644 --- a/SubathonManager.Core/Enums/EnumExtensions.cs +++ b/SubathonManager.Core/Enums/EnumExtensions.cs @@ -65,6 +65,8 @@ public class EventSourceMetaAttribute : EnumMetaAttribute public bool Visible { get; init; } = true; public SubathonEventSource TrueSource { get; init; } = SubathonEventSource.Unknown; public int SourceOrder { get; init; } = 99999; + + public bool IsExternalSource { get; init; } = false; } public class EventTypeMetaAttribute : EnumMetaAttribute @@ -95,15 +97,8 @@ public class EventTypeMetaAttribute : EnumMetaAttribute public class GoAffProTypeMetaAttribute : EventTypeMetaAttribute { public override string? Description => Label; - public GoAffProSource StoreSource { get; init; } = GoAffProSource.Unknown; -} - - -public class GoAffProSourceMetaAttribute : EnumMetaAttribute -{ - public SubathonEventType OrderEvent { get; init; } = SubathonEventType.Unknown; - - public int SiteId { get; init; } = -1; + // maintaining for legacy data + public int LegacySiteId { get; init; } = -1; } public class CommandMetaAttribute : EnumMetaAttribute diff --git a/SubathonManager.Core/Enums/GoAffProSource.cs b/SubathonManager.Core/Enums/GoAffProSource.cs deleted file mode 100644 index 1800848b..00000000 --- a/SubathonManager.Core/Enums/GoAffProSource.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -// ReSharper disable NullableWarningSuppressionIsUsed - -namespace SubathonManager.Core.Enums; - -// Eventually will move to a dynamic system -public enum GoAffProSource -{ - [GoAffProSourceMeta(Description="Unknown", Enabled=false)] - Unknown, - [GoAffProSourceMeta(Description="GamerSupps", SiteId=165328, OrderEvent = SubathonEventType.GamerSuppsOrder)] - GamerSupps, - [GoAffProSourceMeta(Description="UwUMarket", SiteId=132230, OrderEvent = SubathonEventType.UwUMarketOrder)] - UwUMarket, - [GoAffProSourceMeta(SiteId=7142837, OrderEvent = SubathonEventType.OrchidEightOrder, Description = "Orchid Eight", Label = "Orchid Eight")] - OrchidEight, - [GoAffProSourceMeta(Description="KatDragonz", SiteId=7160049, OrderEvent = SubathonEventType.KatDragonzOrder, Enabled=true)] - KatDragonz, - [GoAffProSourceMeta(Description="Cheeky Soap", SiteId=7138531, OrderEvent = SubathonEventType.CheekySoapOrder, Enabled=true)] - CheekySoap, - [GoAffProSourceMeta(Description="Advanced GG", SiteId=105752, OrderEvent = SubathonEventType.AdvancedGGOrder, Label="Advanced GG", Enabled=true)] - AdvancedGG, - [GoAffProSourceMeta(Description="Rogue Energy", SiteId=7014645, OrderEvent = SubathonEventType.RogueEnergyOrder, Label="Rogue Energy", Enabled=true)] - RogueEnergy, - [GoAffProSourceMeta(Description="Saucy Biz", SiteId=7118656, OrderEvent = SubathonEventType.SaucyBizOrder, Label="Saucy Biz", Enabled=true)] - SaucyBiz, - [GoAffProSourceMeta(Description="GFuel", SiteId=48808, OrderEvent=SubathonEventType.GFuelOrder, Label="GFuel", Enabled=true)] - GFuel, - [GoAffProSourceMeta(Description="Natura Pine", SiteId = 7132796, OrderEvent = SubathonEventType.NaturaPineOrder, Label="Natura Pine", Enabled=true)] - NaturaPine -} - -[ExcludeFromCodeCoverage] -public static class GoAffProSourceeHelper -{ - private static GoAffProSourceMetaAttribute? Meta(this GoAffProSource? value) - { - if (!value.HasValue) return null; - var meta = EnumMetaCache.Get(value); - return meta; - } - - private static readonly Lazy> SiteIdToSource = - new(() => - Enum.GetValues() - .Select(e => (Source: e, Meta: ((GoAffProSource?)e).Meta())) - .Where(x => x.Meta?.SiteId > 0 && x.Meta != null) - .ToDictionary(x => x.Meta!.SiteId, x => x.Source) - ); - - public static bool TryGetSource(int siteId, out GoAffProSource source) => - SiteIdToSource.Value.TryGetValue(siteId, out source); - - public static int GetSiteId(this GoAffProSource source) => - ((GoAffProSource?)source).Meta()?.SiteId ?? -1; - - public static bool TryGetSiteId(this GoAffProSource source, out int siteId) - { - siteId = ((GoAffProSource?)source).Meta()?.SiteId ?? -1; - return siteId != -1; - } - - public static SubathonEventType GetOrderEvent(this GoAffProSource source) => - ((GoAffProSource?)source).Meta()?.OrderEvent ?? SubathonEventType.Unknown; - -} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonCommandType.cs b/SubathonManager.Core/Enums/SubathonCommandType.cs index 66e6341e..889ff538 100644 --- a/SubathonManager.Core/Enums/SubathonCommandType.cs +++ b/SubathonManager.Core/Enums/SubathonCommandType.cs @@ -44,7 +44,8 @@ public enum SubathonCommandType AddSpins, [CommandMeta(Description="Subtract Wheel Spins", RequiresParameter = true)] SubtractSpins, - + [CommandMeta(Description="Spin the Wheel", IsControlType = true)] + SpinWheel, } [ExcludeFromCodeCoverage] diff --git a/SubathonManager.Core/Enums/SubathonEventSource.cs b/SubathonManager.Core/Enums/SubathonEventSource.cs index 704b9bee..4fe1fe82 100644 --- a/SubathonManager.Core/Enums/SubathonEventSource.cs +++ b/SubathonManager.Core/Enums/SubathonEventSource.cs @@ -22,9 +22,9 @@ public enum SubathonEventSource Unknown, // default [EventSourceMeta(Description = "StreamLabs", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=22, Order=21)] StreamLabs, - [EventSourceMeta(Description = "Generic External Services", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=1000, Order=99)] + [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)] + [EventSourceMeta(Description = "Blerp", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=81, Order=30, IsExternalSource = true)] Blerp, [EventSourceMeta(Description = "Picarto", SourceGroup = SubathonSourceGroup.Stream, SourceOrder=3, Order=12)] Picarto, @@ -32,11 +32,11 @@ public enum SubathonEventSource GoAffPro, [EventSourceMeta(Description = "Ko-Fi (Tunnel)", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=42, Visible=false, TrueSource=KoFi, Order=41)] KoFiTunnel, - [EventSourceMeta(Description = "Dev Tunnels", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=70, Visible=false, Order=120)] + [EventSourceMeta(Description = "Dev Tunnels", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder=904, Visible=false, Order=903)] DevTunnels, [EventSourceMeta(Description="FourthWall", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=62, Order=51)] FourthWall, - [EventSourceMeta(Description="OBS Websocket", SourceGroup = SubathonSourceGroup.Unknown, SourceOrder=999, Order=999, 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)] Throne, @@ -45,7 +45,15 @@ public enum SubathonEventSource [EventSourceMeta(Description="Wheel Spin", Visible = false, Order = 990, SourceGroup = SubathonSourceGroup.WheelSpin)] WheelSpin, [EventSourceMeta(Description="Tangia", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=82, Order=31)] - Tangia + Tangia, + [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)] + StreamerBot, + [EventSourceMeta(Description="Pally.GG", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=64, Order=53)] + PallyGG, + [EventSourceMeta(Description="TreatStream", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=24, Order=23)] + TreatStream, } [ExcludeFromCodeCoverage] @@ -59,6 +67,9 @@ public static class SubathonEventSourceHelper 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; diff --git a/SubathonManager.Core/Enums/SubathonEventType.cs b/SubathonManager.Core/Enums/SubathonEventType.cs index b4fc7d53..d0e9f3d4 100644 --- a/SubathonManager.Core/Enums/SubathonEventType.cs +++ b/SubathonManager.Core/Enums/SubathonEventType.cs @@ -90,20 +90,21 @@ public enum SubathonEventType [EventTypeMeta(Label = "Kudos Tip", Source = SubathonEventSource.Picarto, IsToken = true, Order = 3)] PicartoTip, + [Obsolete] [GoAffProTypeMeta(Label = "GamerSupps Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 1, - StoreSource = GoAffProSource.GamerSupps, Enabled = true)] + LegacySiteId = 165328, Enabled = false)] GamerSuppsOrder, - + [Obsolete] [GoAffProTypeMeta(Label = "UwUMarket Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 2, - StoreSource = GoAffProSource.UwUMarket, Enabled = true)] + LegacySiteId = 132230, Enabled = false)] UwUMarketOrder, [GoAffProTypeMeta(Label = "Orchid Eight Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 3, - StoreSource = GoAffProSource.OrchidEight, Enabled = true)] + LegacySiteId = 7142837, Enabled = false)] OrchidEightOrder, - + [Obsolete] [GoAffProTypeMeta(Label = "KatDragonz Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 4, - StoreSource = GoAffProSource.KatDragonz, Enabled = true)] + LegacySiteId = 7160049, Enabled = false)] KatDragonzOrder, [EventTypeMeta(Label = "Redirect/Raid", Source = SubathonEventSource.YouTube, IsRaid = true, Order = 5)] @@ -133,13 +134,13 @@ 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, - StoreSource = GoAffProSource.CheekySoap, Enabled = true)] + LegacySiteId = 7138531, Enabled = false)] CheekySoapOrder, - + [Obsolete] [GoAffProTypeMeta(Label = "Advanced GG Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 6, - StoreSource = GoAffProSource.AdvancedGG, Enabled = true)] + LegacySiteId = 105752, Enabled = false)] AdvancedGGOrder, [EventTypeMeta(Label = "Gift Purchase", Source = SubathonEventSource.Throne, Order = 1, @@ -156,22 +157,25 @@ 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, - StoreSource = GoAffProSource.RogueEnergy, Enabled = true)] + LegacySiteId = 7014645, Enabled = false)] RogueEnergyOrder, - + [Obsolete] [GoAffProTypeMeta(Label = "Saucy Biz Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 8, - StoreSource = GoAffProSource.SaucyBiz, Enabled = true)] + LegacySiteId = 7118656, Enabled = false)] SaucyBizOrder, - // PLACEHOLDER FOR FUTURE - [GoAffProTypeMeta(Label="GoAffPro Order", Source=SubathonEventSource.GoAffPro, IsOrder=true, Order=1, Enabled=false)] - GoAffProOrder, + // dynamic GoAffPro order type, meta is site id + [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, - StoreSource = GoAffProSource.GFuel, Enabled = true)] + LegacySiteId = 48808, Enabled = false)] GFuelOrder, + [Obsolete] [GoAffProTypeMeta(Label = "Natura Pine Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 10, - StoreSource = GoAffProSource.NaturaPine, Enabled = true)] + LegacySiteId = 7132796, Enabled = false)] NaturaPineOrder, [EventTypeMeta(Label = "Donation", Source = SubathonEventSource.TipeeeStream, IsCurrencyDonation = true, Order = 1)] @@ -179,6 +183,14 @@ public enum SubathonEventType [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, + Order = 1)] + // Pally.gg is USD only :/ + PallyGGDonation, + + [EventTypeMeta(Label = "Order", Source = SubathonEventSource.TreatStream, IsOrder = true, Order = 1)] + TreatStreamOrder, + // any new must be added after the last } @@ -257,13 +269,23 @@ public static SubathonEventSubType GetSubType(this SubathonEventType? eventType) return SubathonEventSubType.Unknown; } - public static string? GetTypeTrueSource(this SubathonEventType eventType) => GetTypeTrueSource((SubathonEventType?)eventType); - - public static string? GetTypeTrueSource(this SubathonEventType? eventType) + public static string? GetTypeTrueSource(this SubathonEventType eventType, string? meta = null) => 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) return eventType.GoAffProMeta()?.StoreSource.ToString(); + if (eventType.GetSource() == SubathonEventSource.GoAffPro) + { + var siteId = eventType == SubathonEventType.GoAffProOrder && meta != null && int.TryParse(meta, out var parsed) + ? parsed + : eventType.GoAffProMeta()?.LegacySiteId ?? -1; + return GoAffProStoreRegistry.TryGetBySiteId(siteId, out var store) + ? store.InternalName + : nameof(SubathonEventSource.GoAffPro); + } return eventType.GetSource().ToString(); } - + + public static int GetLegacyGoAffProSiteId(this SubathonEventType eventType) => + ((SubathonEventType?)eventType).GoAffProMeta()?.LegacySiteId ?? -1; } diff --git a/SubathonManager.Core/Enums/SubathonSourceGroup.cs b/SubathonManager.Core/Enums/SubathonSourceGroup.cs index f8e9b732..5e845824 100644 --- a/SubathonManager.Core/Enums/SubathonSourceGroup.cs +++ b/SubathonManager.Core/Enums/SubathonSourceGroup.cs @@ -20,4 +20,6 @@ public enum SubathonSourceGroup UseSource, [EnumMeta(Description="Wheel Spin", Label="Wheel Spin")] WheelSpin, + [EnumMeta(Description="External Software", Label="External Software")] + ExternalSoftware, } diff --git a/SubathonManager.Core/Events/SettingsEvents.cs b/SubathonManager.Core/Events/SettingsEvents.cs index 144b86d1..5d24deff 100644 --- a/SubathonManager.Core/Events/SettingsEvents.cs +++ b/SubathonManager.Core/Events/SettingsEvents.cs @@ -1,14 +1,16 @@ using System.Diagnostics.CodeAnalysis; - +using SubathonManager.Core.Enums; + namespace SubathonManager.Core.Events; - + [ExcludeFromCodeCoverage] 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) { SettingsUnsavedChanges?.Invoke(hasPendingChanges); @@ -23,4 +25,9 @@ public static void RaiseHotLinkToDevTunnelsRequest() { HotLinkToDevTunnelsRequested?.Invoke(); } -} \ No newline at end of file + + public static void RaiseHotLinkToSourceRequest(SubathonEventSource source, string? detail = null) + { + HotLinkToSourceRequested?.Invoke(source, detail); + } +} diff --git a/SubathonManager.Core/Events/WheelEvents.cs b/SubathonManager.Core/Events/WheelEvents.cs index 0358d650..e3cffac0 100644 --- a/SubathonManager.Core/Events/WheelEvents.cs +++ b/SubathonManager.Core/Events/WheelEvents.cs @@ -14,6 +14,7 @@ public static class WheelEvents public static event Action? WheelSpinTriggerFired; public static event Action? WheelSpinTriggersChanged; + public static event Action? WheelSpinRequested; public static void RaiseSpinsOwedUpdateFromEvent(int amount) { @@ -37,4 +38,7 @@ public static void RaiseWheelSpinTriggerFired(WheelSpinTrigger trigger, WheelSpi public static void RaiseWheelSpinTriggersChanged() => WheelSpinTriggersChanged?.Invoke(); + + public static void RaiseWheelSpinRequested() + => WheelSpinRequested?.Invoke(); } diff --git a/SubathonManager.Core/FeatureFlags.cs b/SubathonManager.Core/FeatureFlags.cs new file mode 100644 index 00000000..34677cab --- /dev/null +++ b/SubathonManager.Core/FeatureFlags.cs @@ -0,0 +1,6 @@ +namespace SubathonManager.Core; + +public static class FeatureFlags +{ + public static readonly bool KoFiStreamerBotSetupEnabled = false; +} diff --git a/SubathonManager.Core/GoAffProConfigMigration.cs b/SubathonManager.Core/GoAffProConfigMigration.cs new file mode 100644 index 00000000..a1535b60 --- /dev/null +++ b/SubathonManager.Core/GoAffProConfigMigration.cs @@ -0,0 +1,31 @@ +using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; + +namespace SubathonManager.Core; + +public static class GoAffProConfigMigration +{ + public static bool Run(IConfig config) + { + bool migrated = false; + var discord = config.GetSection("Discord"); + if (discord == null) return false; + + foreach (var legacyType in Enum.GetValues() + .Where(t => t.GetSource() == SubathonEventSource.GoAffPro + && t.GetLegacyGoAffProSiteId() > 0)) + { + var oldKey = $"Events.Log.{legacyType}"; + if (!discord.ContainsKey(oldKey)) continue; + + var newKey = $"Events.Log.GoAffProOrder.{legacyType.GetLegacyGoAffProSiteId()}"; + if (string.IsNullOrWhiteSpace(config.Get("Discord", newKey, string.Empty))) + config.Set("Discord", newKey, discord[oldKey]); + discord.RemoveKey(oldKey); + migrated = true; + } + + if (migrated) config.Save(); + return migrated; + } +} diff --git a/SubathonManager.Core/Models/SubathonValue.cs b/SubathonManager.Core/Models/SubathonValue.cs index 941b2f97..fc59b8cb 100644 --- a/SubathonManager.Core/Models/SubathonValue.cs +++ b/SubathonManager.Core/Models/SubathonValue.cs @@ -29,7 +29,9 @@ public SubathonValueDto ToObject() Source = ((SubathonEventType?)EventType).GetSource(), Meta = Meta, Seconds = Seconds, - Points = Points + Points = Points, + Key = GoAffProOrderHelper.GetOrderKey(EventType, Meta), + Label = GoAffProOrderHelper.GetOrderEventDisplayLabel(EventType, Meta) }; } @@ -72,6 +74,9 @@ public class SubathonValueDto public double? Seconds { get; set; } public double? Points { get; set; } + public string? Key { get; set; } + public string? Label { get; set; } + public override string ToString() { return $"{Source} {EventType} [{Meta}]: {Seconds}s, {Points}pts"; diff --git a/SubathonManager.Core/Objects/IntegrationConnection.cs b/SubathonManager.Core/Objects/IntegrationConnection.cs index b5bcc76b..10a0fdfa 100644 --- a/SubathonManager.Core/Objects/IntegrationConnection.cs +++ b/SubathonManager.Core/Objects/IntegrationConnection.cs @@ -7,7 +7,9 @@ public class IntegrationConnection public SubathonEventSource Source { get; init; } public string Service { get; init; } = ""; public string Name { get; init; } = ""; + public string Detail { get; init; } = ""; public bool Status { get; init; } + public bool Configured { get; init; } = true; public override string ToString() => $"[{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 1cb9669a..5a2348ad 100644 --- a/SubathonManager.Core/Objects/OAuthCallback.cs +++ b/SubathonManager.Core/Objects/OAuthCallback.cs @@ -9,6 +9,8 @@ public class OAuthCallback public string? AccessToken { get; set; } public string? RefreshToken { get; set; } public string? Error { get; set; } - + public string? Code { get; set; } + public string? ExpiresIn { get; set; } + public string? ClientId { get; set; } } \ No newline at end of file diff --git a/SubathonManager.Core/Objects/SubathonTotals.cs b/SubathonManager.Core/Objects/SubathonTotals.cs index 2a54d9a1..279b5cc3 100644 --- a/SubathonManager.Core/Objects/SubathonTotals.cs +++ b/SubathonManager.Core/Objects/SubathonTotals.cs @@ -13,12 +13,13 @@ public class SubathonTotals public long TokenLikeTotal { get; init; } = 0; public Dictionary TokenLikeByEvent { get; init; } = new(); - public Dictionary OrderCountByType { get; init; } = new(); - public Dictionary OrderItemsCountByType { get; init; } = new(); - + // keyed by order key (enum name, or store InternalEventName for GoAffPro) + public Dictionary OrderCountByType { get; init; } = new(); + public Dictionary OrderItemsCountByType { get; init; } = new(); + public int FollowLikeTotal { get; init; } = 0; public Dictionary FollowLikeByEvent { get; init; } = new(); - + public SubathonSimulatedTotals Simulated { get; init; } = new(); } @@ -30,9 +31,10 @@ public class SubathonSimulatedTotals public long TokenLikeTotal { get; init; } = 0; public Dictionary TokenLikeByEvent { get; init; } = new(); - public Dictionary OrderCountByType { get; init; } = new(); - public Dictionary OrderItemsCountByType { get; init; } = new(); - + // keyed by order key (enum name, or store InternalEventName for GoAffPro) + public Dictionary OrderCountByType { get; init; } = new(); + public Dictionary OrderItemsCountByType { get; init; } = new(); + public int FollowLikeTotal { get; init; } = 0; public Dictionary FollowLikeByEvent { get; init; } = new(); } \ No newline at end of file diff --git a/SubathonManager.Core/OrderEventHelper.cs b/SubathonManager.Core/OrderEventHelper.cs index 35698b2b..22193a23 100644 --- a/SubathonManager.Core/OrderEventHelper.cs +++ b/SubathonManager.Core/OrderEventHelper.cs @@ -1,183 +1,201 @@ -// using System.Diagnostics.CodeAnalysis; -// using SubathonManager.Core.Enums; -// using SubathonManager.Core.Models; -// -// namespace SubathonManager.Core; -// -// public static class OrderEventHelper -// { -// public static List GetOrderEventOptions() -// { -// var options = new List(); -// -// foreach (var t in SubathonEventSubTypeHelper.OrderEventTypes -// .Where(e => e != SubathonEventType.GoAffProOrder && e.IsEnabled())) -// options.Add(new OrderEventOption(t)); -// -// foreach (var store in GoAffProStoreRegistry.All().Where(s => s.Enabled)) -// options.Add(new OrderEventOption(store)); -// -// return options; -// } -// } -// -// public static class GoAffProStoreRegistry -// { -// private static readonly Dictionary BySiteId = new(); -// private static readonly Dictionary ByInternalName = new(); -// private static readonly object Lock = new(); -// -// public static event Action? StoreDiscovered; -// -// public static void Initialize(IEnumerable stores) -// { -// lock (Lock) -// { -// BySiteId.Clear(); -// ByInternalName.Clear(); -// foreach (var store in stores) -// Register(store); -// } -// } -// -// 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 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 GoAffProStore GetOrProvision(int siteId, string fallbackName = "") -// { -// lock (Lock) -// { -// if (BySiteId.TryGetValue(siteId, out var existing)) -// return existing; -// -// var store = new GoAffProStore -// { -// SiteId = siteId, -// StoreName = string.IsNullOrWhiteSpace(fallbackName) -// ? $"Unknown Store ({siteId})" -// : fallbackName, -// EventName = string.IsNullOrWhiteSpace(fallbackName) -// ? $"Unknown Store ({siteId}) Order" -// : $"{fallbackName} Order", -// Enabled = true -// }; -// -// Register(store); -// StoreDiscovered?.Invoke(store); -// return store; -// } -// } -// } -// -// public static class GoAffProOrderHelper -// { -// public static bool TryParseMeta(string meta, out int siteId) => -// int.TryParse(meta, out siteId); -// -// public static bool TryGetStore(string meta, [NotNullWhen(true)] out GoAffProStore? store) -// { -// store = null; -// return TryParseMeta(meta, out var siteId) && GoAffProStoreRegistry.TryGetBySiteId(siteId, out store); -// } -// -// public static string GetOrderLabel(string meta) -// { -// if (!TryGetStore(meta, out var store)) -// return $"GoAffPro Order ({meta})"; -// return store.EventName; -// } -// -// public static string GetLabel(string meta) -// { -// if (!TryGetStore(meta, out var store)) -// return $"GoAffPro Store ({meta})"; -// return store.StoreName; -// } -// -// public static string GetOrderKey(SubathonEventType eventType, string? meta = null) -// { -// if (eventType != SubathonEventType.GoAffProOrder) -// return eventType.ToString(); -// -// if (string.IsNullOrEmpty(meta)) -// return "GoAffProOrder"; -// -// if (TryGetStore(meta, out var store)) -// return store.InternalEventName; -// -// return $"GoAffProOrder_{meta}"; -// } -// -// public static bool TryGetStoreByOrderKey(string key, [NotNullWhen(true)] out GoAffProStore? store) -// { -// store = GoAffProStoreRegistry.All() -// .FirstOrDefault(s => s.InternalEventName == key); -// return store != null; -// } -// -// public static string GetOrderEventDisplayLabel(SubathonEventType eventType, string? meta = null) -// { -// if (eventType != SubathonEventType.GoAffProOrder) -// return eventType.GetLabel(); -// return string.IsNullOrEmpty(meta) -// ? "GoAffPro Order" -// : GetOrderLabel(meta); -// } -// -// public static string GetOrderEventDisplayDescription(SubathonEventType eventType, string? meta = null) -// { -// if (eventType != SubathonEventType.GoAffProOrder) -// return eventType.GetDescription(); -// return string.IsNullOrEmpty(meta) -// ? "GoAffPro Order" -// : GetOrderLabel(meta); -// } -// } -// -// public class OrderEventOption -// { -// public SubathonEventType EventType { get; } -// public string? Meta { get; } -// public string Key { get; } -// -// public string Label { get; } -// -// public OrderEventOption(SubathonEventType eventType) -// { -// EventType = eventType; -// Meta = null; -// Key = eventType.ToString(); -// Label = eventType.GetLabel(); -// } -// -// public OrderEventOption(GoAffProStore store) -// { -// EventType = SubathonEventType.GoAffProOrder; -// Meta = store.SiteId.ToString(); -// Key = store.InternalEventName; -// Label = store.EventName; -// } -// -// public override string ToString() => Label; -// } \ No newline at end of file +using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; + +namespace SubathonManager.Core; + +public static class OrderEventHelper +{ + public static List GetOrderEventOptions() + { + var options = new List(); + + foreach (var t in SubathonEventSubTypeHelper.OrderEventTypes + .Where(e => e != SubathonEventType.GoAffProOrder && e.IsEnabled())) + options.Add(new OrderEventOption(t)); + + foreach (var store in GoAffProStoreRegistry.All().Where(s => s.Enabled)) + options.Add(new OrderEventOption(store)); + + return options; + } +} + +public static class GoAffProStoreRegistry +{ + private static readonly Dictionary BySiteId = new(); + private static readonly Dictionary ByInternalName = new(); + private static readonly HashSet ActiveSiteIds = new(); + private static readonly object Lock = new(); + + public static event Action? StoreDiscovered; + + public static void Initialize(IEnumerable stores) + { + lock (Lock) + { + BySiteId.Clear(); + ByInternalName.Clear(); + foreach (var store in stores) + Register(store); + } + } + + 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 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 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 GoAffProStore GetOrProvision(int siteId, string fallbackName = "") + { + lock (Lock) + { + if (BySiteId.TryGetValue(siteId, out var existing)) + return existing; + + var store = new GoAffProStore + { + SiteId = siteId, + StoreName = string.IsNullOrWhiteSpace(fallbackName) + ? $"Unknown Store ({siteId})" + : fallbackName, + EventName = string.IsNullOrWhiteSpace(fallbackName) + ? $"Unknown Store ({siteId}) Order" + : $"{fallbackName} Order", + Enabled = true + }; + + Register(store); + StoreDiscovered?.Invoke(store); + return store; + } + } +} + +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) + { + store = null; + return TryParseMeta(meta, out var siteId) && GoAffProStoreRegistry.TryGetBySiteId(siteId, out store); + } + + public static string GetOrderLabel(string? meta) + { + if (!TryGetStore(meta, out var store)) + return $"GoAffPro Order ({meta})"; + return store.EventName; + } + + public static string GetLabel(string? meta) + { + if (!TryGetStore(meta, out var store)) + return $"GoAffPro Store ({meta})"; + return store.StoreName; + } + + 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)) + return store.InternalEventName; + + return $"GoAffProOrder_{meta}"; + } + + 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) + { + if (eventType != SubathonEventType.GoAffProOrder) + return eventType.GetLabel(); + return string.IsNullOrEmpty(meta) + ? "GoAffPro Order" + : GetOrderLabel(meta); + } + + 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); + } +} + +public class OrderEventOption +{ + public SubathonEventType EventType { get; } + public string? Meta { get; } + public string Key { get; } + + public string Label { get; } + + public OrderEventOption(SubathonEventType eventType) + { + EventType = eventType; + Meta = null; + Key = eventType.ToString(); + Label = ((SubathonEventType?)eventType).GetLabel(); + } + + public OrderEventOption(GoAffProStore store) + { + EventType = SubathonEventType.GoAffProOrder; + Meta = store.SiteId.ToString(); + Key = store.InternalEventName; + Label = store.EventName; + } + + public override string ToString() => Label; +} diff --git a/SubathonManager.Core/Security/AesFileSecureStorage.cs b/SubathonManager.Core/Security/AesFileSecureStorage.cs new file mode 100644 index 00000000..57e491c4 --- /dev/null +++ b/SubathonManager.Core/Security/AesFileSecureStorage.cs @@ -0,0 +1,176 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using SubathonManager.Core.Security.Interfaces; + +namespace SubathonManager.Core.Security; + +[ExcludeFromCodeCoverage] +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 string _keyPath; + private readonly Lock _lock = new(); + private readonly byte[] _key; + private Dictionary _store; + private ILogger? _logger; + + public AesFileSecureStorage(ILogger? logger, string? filePath = null, string? keyPath = null) + { + _logger = logger; + _filePath = filePath + ?? Path.GetFullPath(Path.Combine(string.Empty + , "data/secure_store.bin")); + _keyPath = keyPath + ?? Path.ChangeExtension(_filePath, ".key"); + + _key = LoadOrCreateKey(); + _store = Load(); + } + + public bool Set(string key, string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + bool hasUpdated = false; + if (string.IsNullOrWhiteSpace(value)) + { + Delete(key); + return false; + } + if (string.Equals(GetOrDefault(key), value)) return false; + lock (_lock) + { + _store[key] = value; + Flush(); + hasUpdated = true; + } + + if (hasUpdated) + { + _logger?.LogInformation("[SecureStorage] Saved {Key}", key); + } + return hasUpdated; + } + + public string? Get(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + lock (_lock) + return _store.GetValueOrDefault(key); + } + + public string? GetOrDefault(string key, string defaultValue = "") + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + lock (_lock) + return _store.GetValueOrDefault(key, defaultValue); + } + + public bool Delete(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(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) + return _store.ContainsKey(key); + } + + private byte[] LoadOrCreateKey() + { + if (File.Exists(_keyPath)) + { + var 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); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.WriteAllBytes(_keyPath, key); + RestrictToOwner(_keyPath); + return key; + } + + private Dictionary Load() + { + if (!File.Exists(_filePath)) + return new Dictionary(); + + try + { + var 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); + 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(); + } + catch (CryptographicException ex) + { + throw new InvalidOperationException( + "Secure store could not be decrypted. " + + "If you moved the app between machines or lost secure_store.key, " + + "delete secure_store.bin and re-authenticate all services.", + ex); + } + } + + private void Flush() + { + var json = JsonSerializer.Serialize(_store); + var plaintext = Encoding.UTF8.GetBytes(json); + + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + var tag = new byte[TagSize]; + var ciphertext = new byte[plaintext.Length]; + + using var aes = new AesGcm(_key, TagSize); + aes.Encrypt(nonce, plaintext, ciphertext, tag); + + var raw = new byte[NonceSize + TagSize + ciphertext.Length]; + nonce.CopyTo(raw, 0); + tag.CopyTo(raw, NonceSize); + ciphertext.CopyTo(raw, NonceSize + TagSize); + + var tmp = _filePath + ".tmp"; + File.WriteAllBytes(tmp, raw); + RestrictToOwner(tmp); + File.Move(tmp, _filePath, overwrite: true); + } + + private static void RestrictToOwner(string path) + { + if (OperatingSystem.IsWindows()) return; + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } +} diff --git a/SubathonManager.Core/Security/StorageKeys.cs b/SubathonManager.Core/Security/StorageKeys.cs index 45efebfe..8c5bf543 100644 --- a/SubathonManager.Core/Security/StorageKeys.cs +++ b/SubathonManager.Core/Security/StorageKeys.cs @@ -21,4 +21,11 @@ public static class StorageKeys public const string TipeeeStreamApiKey = "SM.TipeeeStream.ApiKey"; public const string TangiaEventKey = "SM.Tangia.EventKey"; + + public const string PallyApiKey = "SM.PallyGG.ApiKey"; + + public const string TreatStreamAccessToken = "SM.TreatStream.AccessToken"; + public const string TreatStreamRefreshToken = "SM.TreatStream.RefreshToken"; + public const string TreatStreamTokenExpiry = "SM.TreatStream.TokenExpiry"; + public const string TreatStreamClientId = "SM.TreatStream.ClientId"; } \ No newline at end of file diff --git a/SubathonManager.Core/Utils.cs b/SubathonManager.Core/Utils.cs index 50cd2701..9e7695db 100644 --- a/SubathonManager.Core/Utils.cs +++ b/SubathonManager.Core/Utils.cs @@ -31,7 +31,8 @@ public static IntegrationConnection GetConnection(SubathonEventSource source, st Source = source, Service = service, Name = "", - Status = false + Status = false, + Configured = false }; UpdateConnection(conn); @@ -350,16 +351,16 @@ 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); - // } + 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); + } return config.GetBool( ev.EventType.GetSource().ToString(), $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", - false); + ev.EventType.GetSource() != SubathonEventSource.GoAffPro); } } \ No newline at end of file diff --git a/SubathonManager.Data/DbContext.cs b/SubathonManager.Data/DbContext.cs index 59602351..28e798bf 100644 --- a/SubathonManager.Data/DbContext.cs +++ b/SubathonManager.Data/DbContext.cs @@ -253,23 +253,27 @@ public static async Task> GetSubathonCurrencyEvents(AppDbCon bool includeBits = Utils.DonationSettings.TryGetValue("BitsLikeAsDonation", out bool bitslike) && bitslike ; List orderTypesToInclude = new List(); - foreach (var goAffProSource in Enum.GetValues().Where(ga => ga != GoAffProSource.Unknown && !ga.IsDisabled())) - { - bool asDonation = Utils.DonationSettings.TryGetValue($"{goAffProSource}", out bool donation) && donation; - if (asDonation && Enum.TryParse($"{goAffProSource}Order", out SubathonEventType eventType)) - orderTypesToInclude.Add(eventType); - } foreach (var orderEvent in Enum.GetValues().Where(et => - ((SubathonEventType?)et).IsOrder() && !et.IsDisabled())) + ((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); } - - events = events.Where(e => e.EventType != null && + + // 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.IsOrder() && orderTypesToInclude.Contains((SubathonEventType)e.EventType)) || + (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; } @@ -303,15 +307,12 @@ public static async Task ActiveEventsToCsv(AppDbContext db) commonMeta = val; } - // if (e.EventType == SubathonEventType.GoAffProOrder) - // { - // GoAffProStoreRegistry.TryGetBySiteId(int.Parse(e.EventTypeMeta!), out var store); - // if (store != null) - // { - // commonMeta = store.InternalName; - // } - // } - + if (e.EventType == SubathonEventType.GoAffProOrder + && GoAffProOrderHelper.TryGetStore(e.EventTypeMeta, out var store)) + { + commonMeta = store.InternalName; + } + sb.AppendLine(string.Join(",", e.Id, Utils.EscapeCsv(e.Source.ToString()), @@ -438,19 +439,11 @@ public static void SeedDefaultValues(AppDbContext db) new () { EventType = SubathonEventType.FourthWallOrder, Seconds = 12}, new () { EventType = SubathonEventType.ThroneGiftContribution, Seconds = 12}, new () { EventType = SubathonEventType.ThroneGiftPurchase, Seconds = 12}, - - new () { EventType = SubathonEventType.GamerSuppsOrder, Seconds = 12 }, - new () { EventType = SubathonEventType.UwUMarketOrder, Seconds = 12 }, - new () { EventType = SubathonEventType.OrchidEightOrder, Seconds = 12 }, - new () { EventType = SubathonEventType.KatDragonzOrder, Seconds = 12 }, - new () { EventType = SubathonEventType.CheekySoapOrder, Seconds = 12 }, - new () { EventType = SubathonEventType.SaucyBizOrder, Seconds = 12 }, - new () { EventType = SubathonEventType.AdvancedGGOrder, Seconds = 12 }, - new () { EventType = SubathonEventType.RogueEnergyOrder, Seconds = 12 }, - new () { EventType = SubathonEventType.GFuelOrder, Seconds = 12 }, - new () { EventType = SubathonEventType.NaturaPineOrder, 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 }; foreach (var def in defaults) @@ -516,27 +509,75 @@ private static void SeedKnownGoAffProStores(AppDbContext db) 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"}, }; foreach (var def in defaults) { - if (db.GoAffProStores.Any(store => store.SiteId == def.SiteId && (store.StoreName != def.StoreName || store.EventName != def.EventName))) + var existing = db.GoAffProStores.FirstOrDefault(store => store.SiteId == def.SiteId); + if (existing == null) { - db.GoAffProStores.Update(def); + db.GoAffProStores.Add(def); } - else if (!db.GoAffProStores.Any(store => store.SiteId == def.SiteId)) + else if (existing.StoreName != def.StoreName || existing.EventName != def.EventName) { - db.GoAffProStores.Add(def); + existing.StoreName = def.StoreName; + existing.EventName = def.EventName; } + } + + 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 + }); + } + } + } + + 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(); - // if (!db.SubathonValues.Any(sv => sv.EventType == SubathonEventType.GoAffProOrder - // && sv.Meta == def.SiteId.ToString())) - // { - // db.SubathonValues.Add( new () - // { - // EventType = SubathonEventType.GoAffProOrder, Seconds = 12, Meta = def.SiteId.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); } } @@ -557,39 +598,8 @@ private static void MigrateLegacyData(AppDbContext db) s.SetProperty(x => x.ReversedTime, false)); } - // int newGoAffProOrderInt = (int)SubathonEventType.GoAffProOrder; - // Dictionary LegacyEventTypeToMeta = new() - // { - // [(int)SubathonEventType.GamerSuppsOrder] = "165328", - // [(int)SubathonEventType.UwUMarketOrder] = "132230", - // [(int)SubathonEventType.OrchidEightOrder] = "7142837", - // [(int)SubathonEventType.KatDragonzOrder] = "7160049", - // [(int)SubathonEventType.CheekySoapOrder] = "7138531", - // [(int)SubathonEventType.AdvancedGGOrder] = "105752", - // [(int)SubathonEventType.RogueEnergyOrder] = "7014645", - // [(int)SubathonEventType.SaucyBizOrder] = "7118656", - // }; - // - // foreach (var (oldTypeInt, meta) in LegacyEventTypeToMeta) - // { - // // SubathonValue rows - // db.Database.ExecuteSqlRaw( - // "UPDATE SubathonValues SET EventType = {0}, Meta = {1} WHERE EventType = {2} AND Meta = ''", - // newGoAffProOrderInt, meta, oldTypeInt); - // - // // SubathonEvent rows - // db.Database.ExecuteSqlRaw( - // "UPDATE SubathonEvents SET EventType = {0}, EventTypeMeta = {1} WHERE EventType = {2}", - // newGoAffProOrderInt, meta, oldTypeInt); - // } - // - // foreach (var subathonEventType in Enum.GetValues().Where(x => ((SubathonEventType?)x).IsSubscription())) - // { - // db.Database.ExecuteSqlRaw( - // "UPDATE SubathonEvents SET EventTypeMeta = Value WHERE EventType = {0}", - // (int)subathonEventType); - // } - + MigrateLegacyGoAffProData(db); + // var fontTypes = WidgetVariableTypeHelper.FontVariables.ToList(); // var widgetsMissingFontTypes = db.Widgets // .Where(w => !fontTypes.All(ft => w.JsVariables.Any(v => v.Type == ft))) diff --git a/SubathonManager.Data/SubathonManager.Data.csproj b/SubathonManager.Data/SubathonManager.Data.csproj index 7ba668e8..59e46bf4 100644 --- a/SubathonManager.Data/SubathonManager.Data.csproj +++ b/SubathonManager.Data/SubathonManager.Data.csproj @@ -19,6 +19,8 @@ all + + diff --git a/SubathonManager.Data/SubathonValueConfigHelper.cs b/SubathonManager.Data/SubathonValueConfigHelper.cs index 64db2a63..b112929f 100644 --- a/SubathonManager.Data/SubathonValueConfigHelper.cs +++ b/SubathonManager.Data/SubathonValueConfigHelper.cs @@ -93,14 +93,15 @@ public async Task PatchFromJsonAsync(string json) foreach (var dto in incoming) { var meta = dto.Meta; - // if (dto is { Source: SubathonEventSource.GoAffPro, EventType: SubathonEventType.GoAffProOrder } - // && !int.TryParse(dto.Meta, out var siteid)) - // { - // GoAffProStoreRegistry.TryGetByInternalName(dto.Meta, out var store); - // if (store == null) continue; - // meta = store.SiteId.ToString(); - // } - + if (dto is { Source: SubathonEventSource.GoAffPro, EventType: SubathonEventType.GoAffProOrder } + && !int.TryParse(dto.Meta, out _)) + { + if (!GoAffProStoreRegistry.TryGetByInternalName(dto.Meta, out var store) + && !GoAffProOrderHelper.TryGetStoreByOrderKey(dto.Meta, out store)) + continue; + meta = store.SiteId.ToString(); + } + var match = dbValues.FirstOrDefault(v => v.EventType == dto.EventType && v.Meta == meta && diff --git a/SubathonManager.Integration/DevTunnelsService.cs b/SubathonManager.Integration/DevTunnelsService.cs index 87978b9b..33929339 100644 --- a/SubathonManager.Integration/DevTunnelsService.cs +++ b/SubathonManager.Integration/DevTunnelsService.cs @@ -357,7 +357,8 @@ private void BroadcastCliStatus(DevTunnelCliProbeResult? probe) Source = SubathonEventSource.DevTunnels, Service = "Cli", Name = probe?.IsInstalled == true ? probe.Version?.ToString() ?? "" : "", - Status = probe?.IsInstalled ?? false + Status = probe?.IsInstalled ?? false, + Configured = probe?.IsInstalled ?? false }); } @@ -368,7 +369,9 @@ private void BroadcastLoginStatus(DevTunnelLoginStatus? login) Source = SubathonEventSource.DevTunnels, Service = "Login", Name = login?.Username ?? "", - Status = login?.IsLoggedIn ?? false + Detail = login?.Provider?.ToString() ?? "", + Status = login?.IsLoggedIn ?? false, + Configured = login?.IsLoggedIn ?? false }); } @@ -379,8 +382,9 @@ private void BroadcastTunnelStatus(bool running, string? url, bool starting = fa { Source = SubathonEventSource.DevTunnels, Service = "Tunnel", - Name = starting ? "(starting…)" : stopping ? "(stopping…)" : (url ?? ""), - Status = running + Name = starting ? "(starting...)" : stopping ? "(stopping...)" : (url ?? ""), + Status = running, + Configured = running }); } diff --git a/SubathonManager.Integration/ExternalEventService.cs b/SubathonManager.Integration/ExternalEventService.cs index a5d6f664..47b5335c 100644 --- a/SubathonManager.Integration/ExternalEventService.cs +++ b/SubathonManager.Integration/ExternalEventService.cs @@ -19,13 +19,22 @@ public static bool ProcessExternalCommand(Dictionary data) if (elemCmd.ValueKind == JsonValueKind.String && Enum.TryParse (elemCmd.GetString(), ignoreCase: 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()) + { + source = parsedSrc; + } + data.TryGetValue("user", out JsonElement elemUser); - string user = string.IsNullOrWhiteSpace(elemUser.GetString()) ? "EXTERNAL" : elemUser.GetString()!; - + string fallbackUser = "EXTERNAL"; + string user = string.IsNullOrWhiteSpace(elemUser.GetString()) ? fallbackUser : elemUser.GetString()!; + data.TryGetValue("message", out JsonElement elemMsg); string msg = elemMsg.ValueKind == JsonValueKind.String ? elemMsg.GetString()! : ""; - - return CommandService.ChatCommandRequest(SubathonEventSource.External, msg, user, true, + + return CommandService.ChatCommandRequest(source, msg, user, true, false, false, DateTime.Now, null, cmd); } @@ -99,10 +108,23 @@ public static bool ProcessExternalOrder(Dictionary data) string typeStr = elemType.GetString()!; Enum.TryParse(typeStr, ignoreCase: true, out var type); + + string? goAffProMeta = null; + if (type != SubathonEventType.GoAffProOrder && GoAffProOrderHelper.TryGetStoreByOrderKey(typeStr, out var keyStore)) + { + type = SubathonEventType.GoAffProOrder; + goAffProMeta = keyStore.SiteId.ToString(); + } + else if (type.GetLegacyGoAffProSiteId() > 0) + { + goAffProMeta = type.GetLegacyGoAffProSiteId().ToString(); + type = SubathonEventType.GoAffProOrder; + } + data.TryGetValue("user", out JsonElement elemUser); string user = string.IsNullOrWhiteSpace(elemUser.GetString()) ? "EXTERNAL" : elemUser.GetString()!; - if (!((SubathonEventType?)type).IsOrder()) return false; + if (!((SubathonEventType?)type).IsOrder()) return false; if (type.GetSource() == SubathonEventSource.KoFi && !string.Equals(user, "SYSTEM")) { if (Utils.GetConnection(SubathonEventSource.KoFiTunnel, @@ -142,12 +164,16 @@ public static bool ProcessExternalOrder(Dictionary data) if (type != SubathonEventType.KoFiCommissionOrder) { string section = $"{type.GetSource()}"; - if (type.GetSource() == SubathonEventSource.GoAffPro) + string modeKey = $"{type}"; + if (type == SubathonEventType.GoAffProOrder) { - section = $"{type}".Replace("Order", ""); + section = nameof(SubathonEventSource.GoAffPro); + modeKey = GoAffProOrderHelper.TryGetStore(goAffProMeta, out var store) + ? store.InternalName + : $"{type}"; } var config = AppServices.Provider.GetRequiredService(); - var mode = config.GetOrderTypeMode(section, $"{type}", OrderTypeModes.Dollar); + var mode = config.GetOrderTypeMode(section, modeKey, OrderTypeModes.Dollar); currency = mode switch { @@ -174,6 +200,7 @@ public static bool ProcessExternalOrder(Dictionary data) Value = orderVal, Source = user == "SYSTEM" ? SubathonEventSource.Simulated : ((SubathonEventType?)type).GetSource(), EventType = type, + EventTypeMeta = goAffProMeta, Amount = amt, SecondaryValue = $"{value}|{currency}" }; diff --git a/SubathonManager.Integration/FourthWallService.cs b/SubathonManager.Integration/FourthWallService.cs index 454b9ab9..6124edd4 100644 --- a/SubathonManager.Integration/FourthWallService.cs +++ b/SubathonManager.Integration/FourthWallService.cs @@ -148,7 +148,7 @@ public async Task Initialize(CancellationToken ct = default) if (!hasWh) { - string? fullUrl = !string.IsNullOrWhiteSpace(tunnelConn.Name) && tunnelConn.Name != "(starting…)" + string? fullUrl = !string.IsNullOrWhiteSpace(tunnelConn.Name) && tunnelConn.Name != "(starting...)" ? tunnelConn.Name.TrimEnd('/') + WebhookPath : null; WebhookConfigurationCreateRequest req = new WebhookConfigurationCreateRequest @@ -303,7 +303,7 @@ private void OnTunnelUpdated(IntegrationConnection connection) private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { - string? fullUrl = !string.IsNullOrWhiteSpace(tunnelBaseUrl) && tunnelBaseUrl != "(starting…)" + string? fullUrl = !string.IsNullOrWhiteSpace(tunnelBaseUrl) && tunnelBaseUrl != "(starting...)" ? tunnelBaseUrl.TrimEnd('/') + WebhookPath : null; @@ -312,7 +312,8 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) Name = fullUrl ?? "", Status = enabled && fullUrl != null, Source = SubathonEventSource.FourthWall, - Service = nameof(SubathonEventSource.FourthWall) + Service = nameof(SubathonEventSource.FourthWall), + Configured = enabled }); if (fullUrl != null) diff --git a/SubathonManager.Integration/GoAffProService.cs b/SubathonManager.Integration/GoAffProService.cs index 5e2f07bb..68c83994 100644 --- a/SubathonManager.Integration/GoAffProService.cs +++ b/SubathonManager.Integration/GoAffProService.cs @@ -99,21 +99,19 @@ public async Task StartAsync(CancellationToken ct = default) foreach (var site in sitesResponse.Sites.Where(site => site is { Id: not null, Status: UserSite_status.Approved })) { - if (!GoAffProSourceeHelper.TryGetSource(site.Id!.Value, out GoAffProSource source)) - { - logger?.LogInformation("[GoAffPro] Site {Id} ({Name}) detected on account, but not integrated. Please create an integration request if you would like it supported", site.Id!.Value, site.Name); - continue; - } - if (source == GoAffProSource.Unknown || source.IsDisabled()) continue; + // dynamically provision any store on the account we haven't seen before + var 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 { Name = currency, Status = true, Source = SubathonEventSource.GoAffPro, - Service = source.ToString() + Service = store.InternalName }; IntegrationEvents.RaiseConnectionUpdate(conn); } @@ -137,13 +135,13 @@ public async Task StartAsync(CancellationToken ct = default) }, _detectorCts.Token); } - public void SimulateOrder(decimal total, int itemCount, decimal commissionTotal, GoAffProSource affilStore, string currency = "USD") + public void SimulateOrder(decimal total, int itemCount, decimal commissionTotal, GoAffProStore? affilStore, string currency = "USD") { string id = Guid.NewGuid().ToString(); // id is meant to be a long but w/e - + UserOrderFeedItem order = new UserOrderFeedItem(); - int idInt = affilStore.TryGetSiteId(out var idParse) ? idParse : int.MaxValue; + 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.Number = "SIMULATED"; @@ -193,15 +191,15 @@ private void HandleOrder(UserOrderFeedItem order) } var site = order.SiteId!.Integer; - if (site == null || !GoAffProSourceeHelper.TryGetSource((int)site, out GoAffProSource source)) return; - if (source == GoAffProSource.Unknown || source.IsDisabled()) return; - + if (site == null || !GoAffProStoreRegistry.TryGetBySiteId((int)site, out var 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, $"{source}.Enabled", true); + var enabled = config.GetBool(_configSection, $"{store.InternalName}.Enabled", true); if (!enabled) return; - + var sourceMode = config.GetOrderTypeMode(_configSection, - $"{source}", OrderTypeModes.Dollar); + store.InternalName, OrderTypeModes.Dollar); ev.Currency = sourceMode switch { @@ -232,12 +230,12 @@ private void HandleOrder(UserOrderFeedItem order) } ev.SecondaryValue = $"{order.Commission}|{order.Currency}"; - ev.EventType = source.GetOrderEvent(); - if (ev.EventType == SubathonEventType.Unknown) return; + ev.EventType = SubathonEventType.GoAffProOrder; + ev.EventTypeMeta = store.SiteId.ToString(); - ev.User = $"New {source}"; + ev.User = $"New {store.InternalName}"; if (ev.Source == SubathonEventSource.Simulated) - ev.User = $"SYSTEM {source}"; + ev.User = $"SYSTEM {store.InternalName}"; SubathonEvents.RaiseSubathonEventCreated(ev); } @@ -251,24 +249,27 @@ public Task StopAsync(CancellationToken ct = default) { timerService?.Unregister("goaffpro-auth-check"); - foreach (var integ in Enum.GetNames()) - { + foreach (var store in GoAffProStoreRegistry.All()) + { + GoAffProStoreRegistry.MarkActiveOnAccount(store.SiteId, false); IntegrationConnection conn = new IntegrationConnection { Name = "", Status = false, Source = SubathonEventSource.GoAffPro, - Service = integ + Service = store.InternalName, + Configured = false }; IntegrationEvents.RaiseConnectionUpdate(conn); - } - + } + IntegrationConnection connection = new IntegrationConnection { Name = "", Status = false, Source = SubathonEventSource.GoAffPro, - Service = nameof(SubathonEventSource.GoAffPro) + Service = nameof(SubathonEventSource.GoAffPro), + Configured = !string.IsNullOrWhiteSpace(Email) && !string.IsNullOrWhiteSpace(Password) }; IntegrationEvents.RaiseConnectionUpdate(connection); diff --git a/SubathonManager.Integration/KoFiService.cs b/SubathonManager.Integration/KoFiService.cs index 5fd405f7..558e57c9 100644 --- a/SubathonManager.Integration/KoFiService.cs +++ b/SubathonManager.Integration/KoFiService.cs @@ -81,7 +81,7 @@ 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…)" + string? fullUrl = !string.IsNullOrWhiteSpace(tunnelBaseUrl) && tunnelBaseUrl != "(starting...)" ? tunnelBaseUrl.TrimEnd('/') + WebhookPath : null; @@ -92,7 +92,8 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) Name = fullUrl ?? "", Status = enabled && fullUrl != null, Source = SubathonEventSource.KoFiTunnel, - Service = nameof(SubathonEventSource.KoFiTunnel) + Service = nameof(SubathonEventSource.KoFiTunnel), + Configured = enabled }); if (fullUrl != null) diff --git a/SubathonManager.Integration/OBSService.cs b/SubathonManager.Integration/OBSService.cs index d47d43d6..0b372c08 100644 --- a/SubathonManager.Integration/OBSService.cs +++ b/SubathonManager.Integration/OBSService.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; using OBSWebsocketDotNet; using OBSWebsocketDotNet.Communication; using SubathonManager.Core; @@ -13,6 +14,18 @@ namespace SubathonManager.Integration; +public sealed record ObsBrowserSourceCard( + string SceneName, + string ScenePath, + int SceneItemId, + string SourceName, + string Url, + int Width, + int Height, + bool Visible, + bool? SrgbOff, + Guid? RouteId); + [ExcludeFromCodeCoverage] public class OBSService : IAppService { @@ -25,6 +38,30 @@ public class OBSService : IAppService public bool Connected => _obs.IsConnected; + 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"; + + public bool HelperScriptActive { get; private set; } + + public string? HelperScriptVersion { get; private set; } + + public bool HelperScriptOutdated => + HelperScriptActive + && ExpectedHelperScriptVersion != null + && !string.Equals(HelperScriptVersion, ExpectedHelperScriptVersion, StringComparison.Ordinal); + + private static readonly Lazy ExpectedScriptVersionLazy = new(ReadEmbeddedScriptVersion); + public static string? ExpectedHelperScriptVersion => ExpectedScriptVersionLazy.Value; + + public event Action? HelperScriptStatusChanged; + + public event Action? BrowserSourcesChanged; + + public static string ScriptPath => Path.GetFullPath(Path.Combine("obs", ScriptFileName)); + public OBSService(ILogger? logger, IConfig config, ISecureStorage secureStorage) { _logger = logger; @@ -55,11 +92,14 @@ public Task StartAsync(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 void TryConnect() { var host = _config.Get("OBS", "Host", "localhost")!; @@ -68,6 +108,7 @@ public void TryConnect() if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(port)) return; + _stopRequested = false; try { var url = $"ws://{host}:{port}"; @@ -82,6 +123,15 @@ public void TryConnect() _logger?.LogDebug(ex, "[OBSService] Connection attempt failed"); } } + + _ = Task.Run(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) @@ -97,10 +147,19 @@ private void OnConnected(object? sender, EventArgs e) Name = "WebSocket", Status = true }); + + _ = Task.Run(CheckHelperScriptAsync); } private void OnDisconnected(object? sender, ObsDisconnectionInfo e) { + if (HelperScriptActive) + { + HelperScriptActive = false; + HelperScriptVersion = null; + HelperScriptStatusChanged?.Invoke(false); + } + BroadcastHelperScriptStatus(); IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { @@ -191,6 +250,389 @@ public bool SaveConfig(string host, string port, string password, bool forceSave _secureStorage.GetOrDefault(StorageKeys.OBSWebSocketPassword, string.Empty)! ); } + + private async Task CheckHelperScriptAsync() + { + EnsureScriptFileOnDisk(); + await Task.Delay(250); + if (!_obs.IsConnected) return; + + bool active = false; + string? version = null; + try + { + var response = _obs.SendRequest("GetHotkeyList"); + var hotkeys = response?["hotkeys"] as JArray; + var 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 (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", + version, ExpectedHelperScriptVersion); + else if (version == null && ExpectedHelperScriptVersion != null) + _logger?.LogWarning( + "[OBSService] Helper script detected but predates version reporting (v{Expected} available). Reload it in OBS Tools -> Scripts", + ExpectedHelperScriptVersion); + else + _logger?.LogInformation("[OBSService] Helper script detected (v{Version})", version); + } + 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); + _logger?.LogDebug("[OBSService] Hotkeys reported by OBS: {Hotkeys}", + hotkeys != null ? string.Join(", ", hotkeys.Select(h => h?.ToString())) : ""); + } + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "[OBSService] Helper script check failed"); + } + + HelperScriptActive = active; + HelperScriptVersion = active ? version : null; + HelperScriptStatusChanged?.Invoke(active); + BroadcastHelperScriptStatus(); + } + + private void BroadcastHelperScriptStatus() + { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.OBS, + Service = "HelperScript", + Name = "Helper Script", + Detail = HelperScriptVersion != null ? $"v{HelperScriptVersion}" : "", + Status = HelperScriptActive + }); + } + + public void RecheckHelperScript() + { + if (!_obs.IsConnected) return; + _ = Task.Run(CheckHelperScriptAsync); + } + + private static string? ReadEmbeddedScriptContent() + { + using var 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(); + if (content == null) return null; + var match = System.Text.RegularExpressions.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) + { + _logger?.LogWarning("[OBSService] Embedded helper script resource not found"); + return; + } + + var path = ScriptPath; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + if (!File.Exists(path) || File.ReadAllText(path) != content) + File.WriteAllText(path, content); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "[OBSService] Failed to write helper script to disk"); + } + } + + public async Task TryApplyHelperTweaksAsync() + { + if (!_obs.IsConnected) return; + if (!HelperScriptActive) + { + await CheckHelperScriptAsync(); + } + if (!HelperScriptActive) + { + _logger?.LogDebug("[OBSService] Skipping helper tweaks - script not loaded in OBS"); + return; + } + + try + { + var request = new JObject + { + ["hotkeyName"] = HelperHotkeyName + }; + _obs.SendRequest("TriggerHotkeyByName", request); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "[OBSService] TriggerHotkeyByName for helper script failed"); + } + } + + public async Task> GetOverlayBrowserSourcesAsync(string serverPort) + { + var cards = new List(); + if (!_obs.IsConnected) return cards; + + if (HelperScriptActive) + { + await TryApplyHelperTweaksAsync(); + await Task.Delay(300); + } + + bool adoptedAny = CollectBrowserSourceCards(serverPort, cards); + + if (adoptedAny && HelperScriptActive) + { + await TryApplyHelperTweaksAsync(); + await Task.Delay(300); + cards.Clear(); + CollectBrowserSourceCards(serverPort, cards); + } + + return cards; + } + + private bool CollectBrowserSourceCards(string serverPort, List cards) + { + bool adoptedAny = false; + var settingsCache = new Dictionary(); + var seenItems = new HashSet(); + + foreach (var sceneName in GetScenes()) + { + adoptedAny |= CollectFromScene(sceneName, sceneName, isGroup: 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; + JArray? items; + try + { + var response = _obs.SendRequest(isGroup ? "GetGroupSceneItemList" : "GetSceneItemList", + new JObject { ["sceneName"] = sceneName }); + items = response?["sceneItems"] as JArray; + } + 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) + { + var groupName = item["sourceName"]?.ToString(); + if (!string.IsNullOrEmpty(groupName)) + adoptedAny |= CollectFromScene(groupName, $"{scenePath} / {groupName}", isGroup: true, + serverPort, cards, settingsCache, seenItems); + continue; + } + + var inputKind = item?["inputKind"]?.ToString() ?? ""; + if (!inputKind.StartsWith("browser_source")) continue; + + var sourceName = item?["sourceName"]?.ToString(); + if (string.IsNullOrEmpty(sourceName)) continue; + 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", + new JObject { ["inputName"] = sourceName }); + settings = response?["inputSettings"] as JObject; + } + catch { settings = null; } + settingsCache[sourceName] = settings; + } + if (settings == null) continue; + + var url = settings["url"]?.ToString() ?? ""; + bool managed = settings[ManagedMarkerKey]?.Value() ?? false; + var routeId = TryParseRouteId(url, serverPort, out bool urlMatchesServer); + + if (!managed && !urlMatchesServer) continue; + + if (!managed && urlMatchesServer) + { + try + { + _obs.SendRequest("SetInputSettings", new Newtonsoft.Json.Linq.JObject + { + ["inputName"] = sourceName, + ["inputSettings"] = new JObject { [ManagedMarkerKey] = true }, + ["overlay"] = true + }); + adoptedAny = true; + } + catch (Exception ex) + { + _logger?.LogDebug(ex, "[OBSService] Failed to adopt source '{Source}'", sourceName); + } + } + + bool? srgbOff = null; + 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; + } + + int width = settings["width"]?.Value() ?? 800; + int height = settings["height"]?.Value() ?? 600; + + if (!seenItems.Add($"{sceneName}|{itemId}")) continue; + + cards.Add(new ObsBrowserSourceCard(sceneName, scenePath, itemId, sourceName, url, + width, height, visible, srgbOff, routeId)); + } + + return adoptedAny; + } + + 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.Port.ToString() != serverPort) return null; + + var 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; + } + + public void SetSceneItemVisible(string sceneName, int sceneItemId, bool visible) + { + if (!_obs.IsConnected) return; + try + { + _obs.SendRequest("SetSceneItemEnabled", new Newtonsoft.Json.Linq.JObject + { + ["sceneName"] = sceneName, + ["sceneItemId"] = sceneItemId, + ["sceneItemEnabled"] = visible + }); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "[OBSService] SetSceneItemEnabled failed for '{Scene}'/{Id}", sceneName, sceneItemId); + } + } + + public void RefreshBrowserSource(string sourceName) + { + if (!_obs.IsConnected) return; + try + { + _obs.SendRequest("PressInputPropertiesButton", new JObject + { + ["inputName"] = sourceName, + ["propertyName"] = "refreshnocache" + }); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "[OBSService] RefreshBrowserSource failed for '{Source}'", sourceName); + } + } + + public void RemoveBrowserSource(string sourceName) + { + if (!_obs.IsConnected) return; + try + { + _obs.SendRequest("RemoveInput", new JObject { ["inputName"] = sourceName }); + BrowserSourcesChanged?.Invoke(); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "[OBSService] RemoveInput failed for '{Source}'", sourceName); + } + } + + public void SetBrowserSourceSize(string sourceName, int width, int height) + { + if (!_obs.IsConnected) return; + try + { + _obs.SendRequest("SetInputSettings", new JObject + { + ["inputName"] = sourceName, + ["inputSettings"] = new JObject + { + ["width"] = width, + ["height"] = height + }, + ["overlay"] = true + }); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "[OBSService] SetBrowserSourceSize failed for '{Source}'", sourceName); + } + } + + public async Task RequestBlendMethodAsync(string sourceName, string sceneName, int sceneItemId, bool srgbOff) + { + if (!_obs.IsConnected) return; + try + { + _obs.SendRequest("SetInputSettings", new JObject + { + ["inputName"] = sourceName, + ["inputSettings"] = new JObject + { + [ManagedMarkerKey] = true, + ["subathon_blend_request"] = new JObject + { + ["method"] = srgbOff ? "srgb_off" : "default", + ["scene"] = sceneName, + ["item"] = sceneItemId + } + }, + ["overlay"] = true + }); + await TryApplyHelperTweaksAsync(); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "[OBSService] RequestBlendMethod failed for '{Source}'", sourceName); + } + } public async Task AddBrowserSource( string sourceName, string url, int width, int height, @@ -205,7 +647,7 @@ public async Task AddBrowserSource( while (existingInputs.Any(i => i.InputName == finalName)) finalName = $"{sourceName} ({count++})"; - var settings = new Newtonsoft.Json.Linq.JObject + var settings = new JObject { ["url"] = url, ["width"] = width, @@ -214,7 +656,9 @@ public async Task AddBrowserSource( ["restart_when_active"] = false, ["shutdown"] = false, ["is_local_file"] = false, - ["css"] = "body { background-color: rgba(0, 0, 0, 0); margin: 0px auto; overflow: hidden; }" + ["css"] = "body { background-color: rgba(0, 0, 0, 0); margin: 0px auto; overflow: hidden; }", + [ManagedMarkerKey] = true, + ["subathon_blend_request"] = new JObject { ["method"] = "srgb_off" } }; _obs.CreateInput(sceneName, finalName, "browser_source", settings, true); @@ -229,11 +673,11 @@ public async Task AddBrowserSource( { var videoSettings = _obs.GetVideoSettings(); - var transformRequest = new Newtonsoft.Json.Linq.JObject + var transformRequest = new JObject { ["sceneName"] = sceneName, ["sceneItemId"] = item.ItemId, - ["sceneItemTransform"] = new Newtonsoft.Json.Linq.JObject + ["sceneItemTransform"] = new JObject { ["boundsType"] = "OBS_BOUNDS_SCALE_INNER", ["boundsWidth"] = (double)videoSettings.BaseWidth, @@ -251,7 +695,8 @@ public async Task AddBrowserSource( _logger?.LogWarning(ex, "[OBS] SetSceneItemTransform failed"); } } + + await TryApplyHelperTweaksAsync(); + BrowserSourcesChanged?.Invoke(); } - - } \ No newline at end of file diff --git a/SubathonManager.Integration/PallyService.cs b/SubathonManager.Integration/PallyService.cs new file mode 100644 index 00000000..9255b73d --- /dev/null +++ b/SubathonManager.Integration/PallyService.cs @@ -0,0 +1,286 @@ +using System.Net.WebSockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +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; + +namespace SubathonManager.Integration; + +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 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 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); + + public PallyService(ILogger? logger, IConfig config, ISecureStorage secureStorage) + { + _logger = logger; + _config = config; + _secureStorage = secureStorage; + } + + public Task StartAsync(CancellationToken ct = default) + { + BroadcastStatus(false); + if (!Enabled || string.IsNullOrWhiteSpace(ApiKey)) + { + _logger?.LogInformation("[PallyGG] Not configured or disabled. Integration inactive."); + return Task.CompletedTask; + } + + _cts = new CancellationTokenSource(); + _runTask = Task.Run(() => RunAsync(_cts.Token), ct); + return Task.CompletedTask; + } + + 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 + { + _socket?.Dispose(); + _socket = null; + Connected = false; + BroadcastStatus(false); + } + } + + public async Task RestartAsync() + { + await StopAsync(); + await StartAsync(); + } + + public bool IsKeyEmpty() => string.IsNullOrWhiteSpace(ApiKey); + + 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() + { + string room = Room; + string channel = string.IsNullOrWhiteSpace(room) + ? "channel=firehose" + : $"channel=activity-feed&room={Uri.EscapeDataString(room)}"; + return new Uri($"{BaseUrl}?auth={Uri.EscapeDataString(ApiKey ?? "")}&{channel}"); + } + + private async Task RunAsync(CancellationToken token) + { + var reconnect = new Utils.ServiceReconnectState(TimeSpan.FromSeconds(2), 100, TimeSpan.FromMinutes(5)); + while (!token.IsCancellationRequested) + { + try + { + _socket?.Dispose(); + _socket = new ClientWebSocket(); + await _socket.ConnectAsync(BuildUri(), token); + + Connected = true; + reconnect.Reset(); + BroadcastStatus(true); + _logger?.LogInformation("[PallyGG] Connected ({Channel})", + string.IsNullOrWhiteSpace(Room) ? "firehose - all pages" : $"room {Room}"); + + await ListenAsync(_socket, token); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger?.LogWarning("[PallyGG] Connection error: {Message}", ex.Message); + } + + if (Connected) + { + Connected = false; + BroadcastStatus(false); + } + + if (token.IsCancellationRequested) break; + + reconnect.Retries++; + 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); + break; + } + + _logger?.LogDebug("[PallyGG] Reconnecting in {Delay}s", reconnect.Backoff.TotalSeconds); + try { await Task.Delay(reconnect.Backoff, token); } + catch (OperationCanceledException) { break; } + reconnect.Backoff = TimeSpan.FromMilliseconds(Math.Min( + reconnect.Backoff.TotalMilliseconds * 2, reconnect.MaxBackoff.TotalMilliseconds)); + } + + Connected = false; + BroadcastStatus(false); + } + + private async Task ListenAsync(ClientWebSocket socket, CancellationToken token) + { + using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(token); + var pingTask = Task.Run(async () => + { + // keepalive + 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"); + 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); + break; + } + + messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); + if (!result.EndOfMessage) continue; + + string message = messageBuilder.ToString(); + messageBuilder.Clear(); + ProcessMessage(message); + } + } + finally + { + await pingCts.CancelAsync(); + try { await pingTask; } catch { /**/ } + } + } + + 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) + || typeElem.GetString() != "campaigntip.notify") return false; + if (!root.TryGetProperty("payload", out var payload) + || !payload.TryGetProperty("campaignTip", out var tip)) return false; + + if (!tip.TryGetProperty("grossAmountInCents", out var 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.IsNullOrWhiteSpace(nameElem.GetString()) + ? nameElem.GetString()! + : "Anonymous"; + + var subathonEvent = new SubathonEvent + { + User = simulated ? "SYSTEM" : user, + Currency = "USD", // USD only + Value = amount.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture), + Source = simulated ? SubathonEventSource.Simulated : SubathonEventSource.PallyGG, + EventType = SubathonEventType.PallyGGDonation + }; + + if (payload.TryGetProperty("page", out var page) + && page.TryGetProperty("slug", out var slugElem)) + subathonEvent.EventTypeMeta = slugElem.GetString(); + + if (!simulated && tip.TryGetProperty("id", out var idElem) + && !string.IsNullOrWhiteSpace(idElem.GetString())) + subathonEvent.Id = GuidFromString($"pallygg|{idElem.GetString()}"); + + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); + return true; + } + 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)); + 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 + { + User = "SYSTEM", + Currency = "USD", + Value = val.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture), + Source = SubathonEventSource.Simulated, + EventType = SubathonEventType.PallyGGDonation + }); + } + + private void BroadcastStatus(bool status) + { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.PallyGG, + Service = "Socket", + Name = Room, + Status = status, + Configured = Enabled && !string.IsNullOrWhiteSpace(ApiKey) + }); + } + + public void Dispose() + { + _cts?.Cancel(); + _socket?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/SubathonManager.Integration/PicartoService.cs b/SubathonManager.Integration/PicartoService.cs index dc360a0b..067bb6ea 100644 --- a/SubathonManager.Integration/PicartoService.cs +++ b/SubathonManager.Integration/PicartoService.cs @@ -63,25 +63,28 @@ public async Task StartupServiceAsync(CancellationToken ct = default) Opts.Channel = string.Empty; _picartoUsername = _config.Get("Picarto", "Username", string.Empty)!; + bool configured = !string.IsNullOrWhiteSpace(_picartoUsername); - if (string.IsNullOrWhiteSpace(_picartoUsername)) - return; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Picarto, Service = "Chat", Name = _picartoUsername, - Status = false - }); + Status = false, + Configured = configured + }); IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Picarto, Service = "Alerts", Name = _picartoUsername, - Status = false + Status = false, + Configured = configured }); - + + if (!configured) + return; + _logger?.LogInformation("Picarto Service Starting for " + _picartoUsername); Opts.Channel = _picartoUsername; diff --git a/SubathonManager.Integration/StreamElementsService.cs b/SubathonManager.Integration/StreamElementsService.cs index 19f08bb2..9dc9b7d9 100644 --- a/SubathonManager.Integration/StreamElementsService.cs +++ b/SubathonManager.Integration/StreamElementsService.cs @@ -258,7 +258,8 @@ public void Disconnect() Source = SubathonEventSource.StreamElements, Service = "Socket", Name = "User", - Status = false + Status = false, + Configured = !IsTokenEmpty() }); } } diff --git a/SubathonManager.Integration/StreamLabsService.cs b/SubathonManager.Integration/StreamLabsService.cs index 18833a50..c2c82900 100644 --- a/SubathonManager.Integration/StreamLabsService.cs +++ b/SubathonManager.Integration/StreamLabsService.cs @@ -58,7 +58,8 @@ public async Task InitClientAsync() Source = SubathonEventSource.StreamLabs, Service = "Socket", Name = "User", - Status = false + Status = false, + Configured = !IsTokenEmpty() }); if (string.IsNullOrWhiteSpace(SecretToken)) @@ -144,13 +145,14 @@ public async Task DisconnectAsync() if (_client == null) return; _client.OnDonation -= OnDonation; - await _client.DisconnectAsync(); + await _client.DisconnectAsync(); IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.StreamLabs, Service = "Socket", Name = "User", - Status = Connected + Status = Connected, + Configured = !IsTokenEmpty() }); _logger?.LogInformation("StreamLabsService Disconnected"); } diff --git a/SubathonManager.Integration/SubathonManager.Integration.csproj b/SubathonManager.Integration/SubathonManager.Integration.csproj index 50afa132..916546a4 100644 --- a/SubathonManager.Integration/SubathonManager.Integration.csproj +++ b/SubathonManager.Integration/SubathonManager.Integration.csproj @@ -10,21 +10,22 @@ - - - - - - + + + + + + - - - - + + + + + @@ -33,4 +34,9 @@ + + + + diff --git a/SubathonManager.Integration/TangiaService.cs b/SubathonManager.Integration/TangiaService.cs index ac5f44d9..11e178fd 100644 --- a/SubathonManager.Integration/TangiaService.cs +++ b/SubathonManager.Integration/TangiaService.cs @@ -213,7 +213,8 @@ private void BroadcastStatus(bool connected) Source = SubathonEventSource.Tangia, Service = nameof(SubathonEventSource.Tangia), Name = "", - Status = connected + Status = connected, + Configured = !string.IsNullOrWhiteSpace(EventKey) }); } diff --git a/SubathonManager.Integration/ThroneService.cs b/SubathonManager.Integration/ThroneService.cs index 47a30ed2..d85e3bf2 100644 --- a/SubathonManager.Integration/ThroneService.cs +++ b/SubathonManager.Integration/ThroneService.cs @@ -89,7 +89,7 @@ private void OnTunnelUpdated(IntegrationConnection connection) private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { - string? fullUrl = !string.IsNullOrWhiteSpace(tunnelBaseUrl) && tunnelBaseUrl != "(starting…)" + string? fullUrl = !string.IsNullOrWhiteSpace(tunnelBaseUrl) && tunnelBaseUrl != "(starting...)" ? tunnelBaseUrl.TrimEnd('/') + WebhookPath : null; @@ -98,7 +98,8 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) Name = fullUrl ?? "", Status = enabled && fullUrl != null, Source = SubathonEventSource.Throne, - Service = nameof(SubathonEventSource.Throne) + Service = nameof(SubathonEventSource.Throne), + Configured = enabled }); if (fullUrl != null) diff --git a/SubathonManager.Integration/TipeeeStreamService.cs b/SubathonManager.Integration/TipeeeStreamService.cs index 70d95676..bd96f00d 100644 --- a/SubathonManager.Integration/TipeeeStreamService.cs +++ b/SubathonManager.Integration/TipeeeStreamService.cs @@ -644,7 +644,8 @@ private void BroadcastStatus(bool connected) Source = SubathonEventSource.TipeeeStream, Service = nameof(SubathonEventSource.TipeeeStream), Name = connected ? _username ?? "User" : "", - Status = connected + Status = connected, + Configured = HasTokens() }); } diff --git a/SubathonManager.Integration/TreatStreamService.cs b/SubathonManager.Integration/TreatStreamService.cs new file mode 100644 index 00000000..79e3220e --- /dev/null +++ b/SubathonManager.Integration/TreatStreamService.cs @@ -0,0 +1,549 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using SocketIOClient; +using SocketIO.Core; +using SocketIOType = SocketIOClient.SocketIO; +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; + +namespace SubathonManager.Integration; + +public class TreatStreamService(ILogger? logger, IHttpClientFactory httpClientFactory, + ISecureStorage secureStorage, ITimerService timerService) : IAppService +{ + internal string SocketUrl = "https://nodeapi.treatstream.com/"; + internal readonly string _oAuthUrl = "https://oauth.subathonmanager.app/auth/treatstream/login"; + internal readonly string _refreshUrl = "https://oauth.subathonmanager.app/auth/treatstream/refresh"; + + internal readonly string _socketTokenUrl = "https://treatstream.com/Oauth2/Authorize/socketToken"; + + internal Action OpenBrowser = + url => Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); + + 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)); + + public async Task StartAsync(CancellationToken ct = default) + { + Utils.PendingOAuthCallback = null; + + if (!HasTokens()) + { + logger?.LogInformation("[TreatStream] Not configured. Integration disabled."); + BroadcastStatus(false); + return; + } + + 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) + { + RevokeTokens(); + await StartOAuthFlowAsync(ct); + if (!HasTokens()) + { + BroadcastStatus(false); + return; + } + } + } + + RegisterRefreshTimer(); + await ConnectSocketAsync(ct); + } + + [ExcludeFromCodeCoverage] + public async Task ConnectAsync(CancellationToken ct = default) + { + await StopAsync(ct); + await StartOAuthFlowAsync(ct); + 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; + } + } + + internal void StoreExpiry(string? expiresIn) + { + double seconds = double.TryParse(expiresIn, NumberStyles.Any, CultureInfo.InvariantCulture, out var 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() + { + if (string.IsNullOrWhiteSpace(AccessToken)) return false; + var expires = TokenExpiry; + if (expires == null) return false; + return DateTime.UtcNow >= expires.Value.AddHours(-1); + } + + [ExcludeFromCodeCoverage] + 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.FromMinutes(5).Ticks, TimeSpan.FromHours(24).Ticks)); + + _refreshTimerHandle = timerService.Register( + $"{nameof(TreatStreamService)}.TokenRefresh", + interval, + async (token) => + { + if (!CheckExpiry()) return; + var ok = await RefreshAccessTokenAsync(token); + if (ok) + { + RegisterRefreshTimer(); + } + else + { + logger?.LogWarning("[TreatStream] Scheduled token refresh failed - disconnecting"); + BroadcastStatus(false); + await DisconnectAsync(); + } + }); + } + + [ExcludeFromCodeCoverage] + 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)); + using var body = new FormUrlEncodedContent( + [ + new KeyValuePair("refresh_token", RefreshToken) + ]); + + var 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); + 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; + + if (string.IsNullOrWhiteSpace(newAccess)) return false; + + secureStorage.Set(StorageKeys.TreatStreamAccessToken, newAccess); + secureStorage.Set(StorageKeys.TreatStreamRefreshToken, newRefresh ?? RefreshToken!); + if (!string.IsNullOrWhiteSpace(clientId)) + secureStorage.Set(StorageKeys.TreatStreamClientId, clientId); + StoreExpiry(expiresIn); + logger?.LogDebug("[TreatStream] Tokens refreshed successfully"); + return true; + } + catch (Exception ex) + { + logger?.LogWarning(ex, "[TreatStream] Token refresh error"); + return false; + } + } + + 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)) + { + secureStorage.Set(StorageKeys.TreatStreamAccessToken, cb.AccessToken); + secureStorage.Set(StorageKeys.TreatStreamRefreshToken, cb.RefreshToken); + if (!string.IsNullOrWhiteSpace(cb.ClientId)) + secureStorage.Set(StorageKeys.TreatStreamClientId, cb.ClientId); + StoreExpiry(cb.ExpiresIn); + logger?.LogInformation("[TreatStream] OAuth tokens stored"); + } + else + { + logger?.LogWarning("[TreatStream] OAuth flow did not produce tokens"); + } + } + + 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") + { + Utils.PendingOAuthCallback = null; + 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 void RevokeTokens() + { + secureStorage.Delete(StorageKeys.TreatStreamAccessToken); + secureStorage.Delete(StorageKeys.TreatStreamRefreshToken); + secureStorage.Delete(StorageKeys.TreatStreamTokenExpiry); + secureStorage.Delete(StorageKeys.TreatStreamClientId); + } + + [ExcludeFromCodeCoverage] + 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)); + 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) + { + 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) + ? tok.ToString() + : null; + } + 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)) + { + logger?.LogWarning("[TreatStream] Could not retrieve socket token"); + BroadcastStatus(false); + _ = Task.Run(() => ReconnectWithBackoffAsync(_socketCts?.Token ?? ct), ct); + return; + } + + await DisconnectAsync(); + + _socketCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _hasAuthError = false; + _connected = false; + + _socket = new SocketIOType(SocketUrl, new SocketIOOptions + { + Query = new List> + { + new("token", socketToken) + }, + EIO = EngineIO.V3, + Transport = SocketIOClient.Transport.TransportProtocol.WebSocket + }); + + _socket.OnConnected += OnSocketConnected; + _socket.OnDisconnected += OnSocketDisconnected; + _socket.On("realTimeTreat", OnRealTimeTreat); + + try + { + await _socket.ConnectAsync(ct); + } + 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) + { + logger?.LogInformation("[TreatStream] Socket connected"); + _connected = true; + _hasAuthError = false; + _reconnectState.Reset(); + _reconnectState.Cts?.Cancel(); + BroadcastStatus(true); + } + + [ExcludeFromCodeCoverage] + private void OnSocketDisconnected(object? sender, string reason) + { + logger?.LogWarning("[TreatStream] Socket disconnected: {Reason}", reason); + _connected = false; + BroadcastStatus(false); + + if (_hasAuthError || _socketCts?.IsCancellationRequested == true) return; + _ = Task.Run(() => ReconnectWithBackoffAsync(_socketCts?.Token ?? CancellationToken.None)); + } + + [ExcludeFromCodeCoverage] + private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) + { + if (!await _reconnectState.Lock.WaitAsync(0, ct)) return; + + try + { + _reconnectState.Cts?.Cancel(); + _reconnectState.Cts = new CancellationTokenSource(); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _reconnectState.Cts.Token); + var token = linked.Token; + + 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()); + logger?.LogError(msg); + return; + } + + _reconnectState.Retries++; + var delay = _reconnectState.Backoff; + logger?.LogWarning("[TreatStream] Reconnect attempt {Attempt}/{Max} in {Delay}s", + _reconnectState.Retries, _reconnectState.MaxRetries, delay.TotalSeconds); + + try + { + await Task.Delay(delay, token); + + if (CheckExpiry()) await RefreshAccessTokenAsync(token); + var socketToken = await FetchSocketTokenAsync(token); + if (!string.IsNullOrWhiteSpace(socketToken) && !_connected && _socket != null) + { + await DisconnectSocketOnlyAsync(); + _socket = new SocketIOType(SocketUrl, new SocketIOOptions + { + Query = new List> { new("token", socketToken) }, + EIO = EngineIO.V3, + Transport = SocketIOClient.Transport.TransportProtocol.WebSocket + }); + _socket.OnConnected += OnSocketConnected; + _socket.OnDisconnected += OnSocketDisconnected; + _socket.On("realTimeTreat", OnRealTimeTreat); + await _socket.ConnectAsync(token); + } + } + catch (OperationCanceledException) { return; } + catch (Exception ex) + { + logger?.LogWarning(ex, "[TreatStream] Reconnect attempt failed"); + } + + _reconnectState.Backoff = TimeSpan.FromMilliseconds( + Math.Min( + _reconnectState.Backoff.TotalMilliseconds * 2, + _reconnectState.MaxBackoff.TotalMilliseconds)); + } + } + finally + { + _reconnectState.Lock.Release(); + } + } + + [ExcludeFromCodeCoverage] + private async Task DisconnectSocketOnlyAsync() + { + var s = _socket; + _socket = null; + if (s == null) return; + try + { + s.OnConnected -= OnSocketConnected; + s.OnDisconnected -= OnSocketDisconnected; + s.Off("realTimeTreat"); + await s.DisconnectAsync(); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "[TreatStream] Disconnect error"); + } + finally + { + s.Dispose(); + } + } + + private async Task DisconnectAsync() + { + _socketCts?.Cancel(); + _connected = false; + _reconnectState.Cts?.Cancel(); + await DisconnectSocketOnlyAsync(); + } + + [ExcludeFromCodeCoverage] + private void OnRealTimeTreat(SocketIOResponse response) + { + try + { + ProcessTreatJson(response.GetValue(0).GetRawText()); + } + 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; + if (root.ValueKind != JsonValueKind.Object) return false; + + string? title = root.TryGetProperty("title", out var titleEl) ? titleEl.GetString() : null; + if (string.IsNullOrWhiteSpace(title)) return false; + + string sender = root.TryGetProperty("sender", out var senderEl) + && !string.IsNullOrWhiteSpace(senderEl.GetString()) + ? senderEl.GetString()! + : "TreatStream"; + + string dateCreated = root.TryGetProperty("date_created", out var dateEl) + ? dateEl.GetString() ?? "" : ""; + + bool isSystem = simulated || sender == "SYSTEM"; + + var subathonEvent = new SubathonEvent + { + Id = Utils.CreateGuidFromUniqueString($"treatstream|{title}|{dateCreated}"), + Source = isSystem ? SubathonEventSource.Simulated : SubathonEventSource.TreatStream, + EventType = SubathonEventType.TreatStreamOrder, + User = isSystem ? "SYSTEM" : sender, + Currency = "item", + Amount = 1, + Value = title, + TertiaryValue = title, + EventTimestamp = DateTimeOffset.TryParse(dateCreated, CultureInfo.InvariantCulture, + DateTimeStyles.None, out var ts) + ? ts.LocalDateTime + : DateTime.Now.ToLocalTime() + }; + + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); + return true; + } + catch (JsonException ex) + { + logger?.LogWarning(ex, "[TreatStream] Invalid treat JSON"); + return false; + } + } + + public static void SimulateTreat(string title = "Fancy Treat") + { + if (string.IsNullOrWhiteSpace(title)) title = "Fancy Treat"; + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent + { + Source = SubathonEventSource.Simulated, + EventType = SubathonEventType.TreatStreamOrder, + User = "SYSTEM", + Currency = "item", + Amount = 1, + Value = title, + TertiaryValue = title, + EventTimestamp = DateTime.Now.ToLocalTime() + }); + } + + private void BroadcastStatus(bool connected) + { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.TreatStream, + Service = "Socket", + Name = connected ? "TreatStream" : "", + Status = connected, + Configured = HasTokens() + }); + } +} diff --git a/SubathonManager.Integration/TwitchService.cs b/SubathonManager.Integration/TwitchService.cs index 90daabb4..e20c9f03 100644 --- a/SubathonManager.Integration/TwitchService.cs +++ b/SubathonManager.Integration/TwitchService.cs @@ -205,7 +205,8 @@ private async Task InitializeApiAsync() Source = SubathonEventSource.Twitch, Service = "API", Name = "", - Status = false + Status = false, + Configured = HasTokenFile() }); } } @@ -218,20 +219,14 @@ private async Task InitializeChatAsync() var credentials = new ConnectionCredentials(UserName, $"oauth:{AccessToken}"); _chat = new TwitchClient(); + _chat.OnMessageReceived += HandleMessageCmdReceived; + _chat.OnDisconnected += HandleChatDisconnect; + _chat.OnReconnected += HandleChatReconnect; + _chat.OnConnected += HandleChatConnect; + try { _chat.Initialize(credentials, channel: UserName); - await Task.Run(async () => - { - await Task.Delay(1000); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { - Source = SubathonEventSource.Twitch, - Service = "Chat", - Name = UserName!, - Status = true - }); - }); logger?.LogDebug("[Twitch] Authenticated Chat as {UserName}", UserName); } catch (Exception ex) @@ -242,18 +237,28 @@ await Task.Run(async () => Source = SubathonEventSource.Twitch, Service = "Chat", Name = UserName!, - Status = false + Status = false, + Configured = HasTokenFile() }); } + + await _chat.ConnectAsync(); + } - _chat.OnMessageReceived += HandleMessageCmdReceived; - _chat.OnDisconnected += HandleChatDisconnect; - _chat.OnReconnected += HandleChatReconnect; - await Task.Run(() => _chat.Connect()); + private Task HandleChatConnect(object? sender, OnConnectedEventArgs e) + { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection + { + Source = SubathonEventSource.Twitch, + Service = "Chat", + Name = UserName!, + Status = true + }); + return Task.CompletedTask; } [ExcludeFromCodeCoverage] - private void HandleChatDisconnect(object? _, TwitchLib.Communication.Events.OnDisconnectedEventArgs args) + private Task HandleChatDisconnect(object? sender, OnDisconnectedArgs onDisconnectedArgs) { if ((DateTime.Now - _lastChatDisconnectLog).TotalSeconds > 60) { @@ -264,10 +269,12 @@ private void HandleChatDisconnect(object? _, TwitchLib.Communication.Events.OnDi Source = SubathonEventSource.Twitch, Service = "Chat", Name = UserName!, - Status = false + Status = false, + Configured = HasTokenFile() }); } - Task.Run(TryReconnectChatAsync); + _ = Task.Run(TryReconnectChatAsync); + return Task.CompletedTask; } @@ -314,7 +321,7 @@ private async Task TryReconnectChatAsync() return; } - _chat.Reconnect(); + await _chat.ReconnectAsync(); } catch (OperationCanceledException) { @@ -339,7 +346,7 @@ private async Task TryReconnectChatAsync() [ExcludeFromCodeCoverage] - private void HandleChatReconnect(object? _, TwitchLib.Communication.Events.OnReconnectedEventArgs args) + private Task HandleChatReconnect(object? _, OnConnectedEventArgs onConnectedEventArgs) { logger?.LogInformation("Twitch Chat Reconnected"); _chatReconnect.Cts?.Cancel(); @@ -351,19 +358,20 @@ private void HandleChatReconnect(object? _, TwitchLib.Communication.Events.OnRec Name = UserName!, Status = true }); + return Task.CompletedTask; } - private void HandleMessageCmdReceived(object? s, OnMessageReceivedArgs e) + private Task HandleMessageCmdReceived(object? s, OnMessageReceivedArgs e) { if (!e.ChatMessage.Channel.Equals(Login, StringComparison.InvariantCultureIgnoreCase) && !e.ChatMessage.Channel.Equals(UserName, StringComparison.InvariantCultureIgnoreCase)) - return; + return Task.CompletedTask; string message = e.ChatMessage.Message; - bool isMod = e.ChatMessage.IsModerator; + bool isMod = e.ChatMessage.UserDetail.IsModerator; bool isBroadcaster = e.ChatMessage.IsBroadcaster; - bool isVip = e.ChatMessage.IsVip; + bool isVip = e.ChatMessage.UserDetail.IsVip; if (!string.IsNullOrWhiteSpace(message) && message.StartsWith('!')) { @@ -375,11 +383,13 @@ private void HandleMessageCmdReceived(object? s, OnMessageReceivedArgs e) { BlerpChatService.ParseMessage(e.ChatMessage.Message, SubathonEventSource.Twitch); } + + return Task.CompletedTask; } private async Task InitializeEventSubAsync() { - _chatReconnect.Reset(); + _eventSubReconnect.Reset(); _eventSub = new EventSubWebsocketClient(); _eventSub.WebsocketConnected += HandleEventSubConnect; @@ -477,7 +487,8 @@ private async Task HandleEventSubConnect(object? s, WebsocketConnectedArgs e) Source = SubathonEventSource.Twitch, Service = "EventSub", Name = UserName!, - Status = IsEventSubConnected() + Status = IsEventSubConnected(), + Configured = HasTokenFile() }); } @@ -520,7 +531,8 @@ private Task HandleEventSubDisconnect(object? s, WebsocketDisconnectedArgs e) Source = SubathonEventSource.Twitch, Service = "EventSub", Name = UserName!, - Status = _isConnected + Status = _isConnected, + Configured = HasTokenFile() }); _ = Task.Run(TryReconnectEventSubAsync); return Task.CompletedTask; @@ -899,7 +911,7 @@ public async Task StopAsync(CancellationToken ct = default) { // api has no disconnect? OnTeardown(); - _chat?.Disconnect(); + if (_chat != null) await _chat.DisconnectAsync(); if (_eventSub != null) await _eventSub.DisconnectAsync(); IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection @@ -907,21 +919,24 @@ public async Task StopAsync(CancellationToken ct = default) Source = SubathonEventSource.Twitch, Service = "API", Name = UserName ?? "", - Status = false - }); + Status = false, + Configured = HasTokenFile() + }); IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "EventSub", Name = UserName ?? "", - Status = false - }); + Status = false, + Configured = HasTokenFile() + }); IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "Chat", Name = UserName ?? "", - Status = false + Status = false, + Configured = HasTokenFile() }); } @@ -1075,6 +1090,7 @@ private void OnTeardown() _chat.OnMessageReceived -= HandleMessageCmdReceived; _chat.OnDisconnected -= HandleChatDisconnect; _chat.OnReconnected -= HandleChatReconnect; + _chat.OnConnected -= HandleChatConnect; } if (_eventSub != null) diff --git a/SubathonManager.Integration/YouTubeService.cs b/SubathonManager.Integration/YouTubeService.cs index 8787ca03..755df046 100644 --- a/SubathonManager.Integration/YouTubeService.cs +++ b/SubathonManager.Integration/YouTubeService.cs @@ -81,16 +81,19 @@ 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 { Source = SubathonEventSource.YouTube, Service = "Chat", Name = "None", - Status = Running + Status = Running, + Configured = configured }); - _ytHandle = handle ?? _config.Get("YouTube", "Handle")!; - if (string.IsNullOrWhiteSpace(_ytHandle) || _ytHandle.Trim() == "@") + if (!configured) { _logger?.LogInformation("YouTube Service not connected to any channel. Not running."); return Running; diff --git a/SubathonManager.Server/WebServer.Api.cs b/SubathonManager.Server/WebServer.Api.cs index 86a700a6..098ed685 100644 --- a/SubathonManager.Server/WebServer.Api.cs +++ b/SubathonManager.Server/WebServer.Api.cs @@ -22,6 +22,8 @@ private void SetupApiRoutes() _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 )); @@ -238,6 +240,23 @@ internal async Task HandleAmountsRequestAsync(IHttpContext ctx) await ctx.WriteResponse(200, json); } + internal static object[] BuildCommandCatalog() => + Enum.GetValues() + .Where(c => c is not (SubathonCommandType.None or SubathonCommandType.Unknown)) + .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) + { + string json = JsonSerializer.Serialize(new { commands = BuildCommandCatalog() }); + await ctx.WriteResponse(200, json, addCors: true, contentType: "application/json"); + } + private async Task HandleValuesRequestAsync(IHttpContext ctx) { var json = await _valueHelper.GetAllAsJsonAsync(); diff --git a/SubathonManager.Server/WebServer.WebSocket.cs b/SubathonManager.Server/WebServer.WebSocket.cs index 23ff2195..6e0aa742 100644 --- a/SubathonManager.Server/WebServer.WebSocket.cs +++ b/SubathonManager.Server/WebServer.WebSocket.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Models; using SubathonManager.Core.Events; @@ -279,17 +280,14 @@ private object SubathonTotalsToObject(SubathonTotals totals) private object SubathonEventToObject(SubathonEvent subathonEvent) { - var trueSource = subathonEvent.EventType.GetTypeTrueSource(); + var trueSource = subathonEvent.EventType.GetTypeTrueSource(subathonEvent.EventTypeMeta); var eventType = subathonEvent.EventType.ToString(); - // if (subathonEvent.EventType == SubathonEventType.GoAffProOrder) - // { - // GoAffProStoreRegistry.TryGetBySiteId(int.Parse(subathonEvent.EventTypeMeta!), out var store); - // if (store != null) - // { - // trueSource = store.InternalName; - // eventType = store.InternalEventName; - // } - // } + if (subathonEvent.EventType == SubathonEventType.GoAffProOrder + && GoAffProOrderHelper.TryGetStore(subathonEvent.EventTypeMeta, out var store)) + { + trueSource = store.InternalName; + eventType = store.InternalEventName; + } object data = new { type = "event", @@ -495,22 +493,41 @@ private async Task Listen(IWebSocketClient socket) switch (clientMessageType) { - // received type *may* not equal socket type, we want to be able to reuse sockets like this - // set type of socket is just primary type for events case WebsocketClientMessageType.Command: { + if (json.RootElement.TryGetProperty("request", out JsonElement reqElem) + && 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) || seType == SubathonEventType.Unknown) continue; if (seType != SubathonEventType.Command) continue; - + Dictionary data = json.RootElement .EnumerateObject() .ToDictionary(p => p.Name, p => p.Value); - - ExternalEventService.ProcessExternalCommand(data); + Console.WriteLine(data); + bool success = ExternalEventService.ProcessExternalCommand(data); + data.TryGetValue("command", out JsonElement cmdElem); + data.TryGetValue("context", out JsonElement ctxElem); + await SelectSendAsync(socket, new + { + type = "command_ack", + ws_type = nameof(WebsocketClientMessageType.Command), + command = cmdElem.ValueKind == JsonValueKind.String ? cmdElem.GetString() : null, + context = ctxElem.ValueKind == JsonValueKind.String ? ctxElem.GetString() : null, + success + }); break; } case WebsocketClientMessageType.IntegrationSource: diff --git a/SubathonManager.Services/CommandService.cs b/SubathonManager.Services/CommandService.cs index 1f857433..429e74a0 100644 --- a/SubathonManager.Services/CommandService.cs +++ b/SubathonManager.Services/CommandService.cs @@ -22,7 +22,8 @@ public static bool ChatCommandRequest(SubathonEventSource source, string message SubathonCommandType cmdOverride = SubathonCommandType.None) { timestamp ??= DateTime.Now; - if (source == SubathonEventSource.External) + + if (source.IsExternalSource()) message = $"EXTERNAL {message}"; SubathonCommandType command = ValidateCommand(message, cmdOverride); if (command == SubathonCommandType.Unknown) return false; @@ -41,7 +42,7 @@ public static bool ChatCommandRequest(SubathonEventSource source, string message bool validParams = ValidateParameters(subathonEvent, message); if (!validParams) return false; - bool validUser = source == SubathonEventSource.External || + bool validUser = source.IsExternalSource() || ValidateUser(subathonEvent, user, isBroadcaster, isModerator, isVip); if (!validUser) return false; diff --git a/SubathonManager.Services/DiscordWebhookService.cs b/SubathonManager.Services/DiscordWebhookService.cs index b95c6b5a..7f87b1a3 100644 --- a/SubathonManager.Services/DiscordWebhookService.cs +++ b/SubathonManager.Services/DiscordWebhookService.cs @@ -36,6 +36,7 @@ public class DiscordWebhookService : IDisposable, IAppService private Task? _backgroundTriggerTask; private List _auditEventTypes = new(); + private readonly HashSet _auditGoAffProMetas = new(); private bool _doSimulatedEvents = false; private bool _doRemoteValuePatches = false; private bool _doWheelSpinEvents = false; @@ -85,20 +86,35 @@ public void LoadFromConfig() _auditEventTypes.Clear(); foreach (SubathonEventType type in Enum.GetValues()) { + if (type.GetLegacyGoAffProSiteId() > 0) continue; // if (_config.GetBool("Discord", $"Events.Log.{type}", false)) _auditEventTypes.Add(type); } + _auditGoAffProMetas.Clear(); + foreach (var store in GoAffProStoreRegistry.All()) + { + if (_config.GetBool("Discord", $"Events.Log.GoAffProOrder.{store.SiteId}", false)) + _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); } + 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; - if (!_auditEventTypes.Contains(subathonEvent.EventType ?? SubathonEventType.Unknown)) return; + if (!IsAuditedEventType(subathonEvent)) return; if (subathonEvent.Source == SubathonEventSource.Simulated && !_doSimulatedEvents) return; // only queue events we care about based on settings // so not-logged ones don't clog the queue downstream @@ -188,7 +204,7 @@ private void OnSubathonEventDeleted(List? subathonEvents) { SubathonEvent subathonEvent = subathonEvents.Single().ShallowClone(); if (string.IsNullOrEmpty(_eventWebhookUrl)) return; - if (!_auditEventTypes.Contains(subathonEvent.EventType ?? SubathonEventType.Unknown)) return; + if (!IsAuditedEventType(subathonEvent)) return; if (subathonEvent.Source == SubathonEventSource.Simulated && !_doSimulatedEvents) return; @@ -288,7 +304,7 @@ private async Task FlushQueueAsync() { var embeds = batch.Select(e => new { - title=$"{e.EventType}{(e.Value.EndsWith(" [DELETED]") ? " - Deleted" : (e.EventType == SubathonEventType.Command ? $" - {e.Command}" : + 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)), @@ -564,7 +580,7 @@ private async Task FlushTriggerQueueAsync() { var embeds = batch.Select(e => new { - title = $"WheelSpin Trigger - {e.Trigger.EventType.GetSource()} {e.Trigger.EventType.GetLabel()}", + title = $"WheelSpin Trigger - {e.Trigger.EventType.GetSource()} {TriggerEventLabel(e.Trigger)}", description = BuildTriggerLogDescription(e), color = 0x9B59B6, timestamp = e.History.TriggeredAt.ToUniversalTime().ToString("o"), @@ -583,10 +599,15 @@ private async Task FlushTriggerQueueAsync() } } + private static string TriggerEventLabel(WheelSpinTrigger trigger) => + trigger.EventType == SubathonEventType.GoAffProOrder + ? GoAffProOrderHelper.GetOrderEventDisplayLabel(trigger.EventType, trigger.TierValue) + : trigger.EventType.GetLabel(); + private static string BuildTriggerLogDescription(TriggerLogEntry e) { var sb = new StringBuilder(); - sb.AppendLine($"**Event:** {e.Trigger.EventType.GetLabel()} ({e.Trigger.EventType.GetSource()})"); + sb.AppendLine($"**Event:** {TriggerEventLabel(e.Trigger)} ({e.Trigger.EventType.GetSource()})"); sb.AppendLine($"**User:** {e.History.TriggerUser ?? "Unknown"}"); sb.AppendLine($"**Source:** {e.History.TriggerSource}"); sb.AppendLine($"**Spins Added:** +{e.History.SpinsAdded}"); diff --git a/SubathonManager.Services/EventService.cs b/SubathonManager.Services/EventService.cs index 6f28e0bd..6a8b2ec2 100644 --- a/SubathonManager.Services/EventService.cs +++ b/SubathonManager.Services/EventService.cs @@ -304,18 +304,16 @@ private async Task LoopAsync() if (affected > 0 || ev.EventType == SubathonEventType.DonationAdjustment || (processPointsIfLocked && subathon.IsLocked && ev.EventType.IsCurrencyDonation() && _currencyService.IsValidCurrency(ev.Currency)) || - (ev.EventType.IsOrder() && _config.GetBool(ev.EventType.GetSource().ToString(), - $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", ev.EventType.GetSource() != SubathonEventSource.GoAffPro) /////////////////////////////////// + (Utils.IsCommissionAsDonation(_config, ev) && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && ev.SecondaryValue.Contains('|'))) { ev.ProcessedToSubathon = true; (bool asDono, double modifier) = Utils.GetAltCurrencyUseAsDonation(_config, ev.EventType); - + var lockVal = 0; if (subathon.IsLocked && processPointsIfLocked) lockVal = 1; - - if (ev.EventType.IsOrder() && _config.GetBool(ev.EventType.GetSource().ToString(), - $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", ev.EventType.GetSource() != SubathonEventSource.GoAffPro) /////////////////////////////////// + + if (Utils.IsCommissionAsDonation(_config, ev) && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && ev.SecondaryValue.Contains('|')) { var value = ev.SecondaryValue.Split('|')[0]; @@ -462,6 +460,9 @@ await db.Database.ExecuteSqlRawAsync( await StateValueHelper.SetAsync(_factory, StateKeys.WheelSpinsOwed, int.Max(0, spins2 - ev.Amount)); WheelEvents.RaiseSpinsOwedUpdateFromEvent(int.Max(0, spins2 - ev.Amount)); break; + case SubathonCommandType.SpinWheel: + WheelEvents.RaiseWheelSpinRequested(); + break; case SubathonCommandType.AddMoney: ev.EventType = SubathonEventType.DonationAdjustment; break; @@ -656,8 +657,7 @@ public async Task DeleteSubathonEvent(AppDbContext db, SubathonEvent ev) } } - if (ev.EventType.IsOrder() && ev.ProcessedToSubathon && _config.GetBool(ev.EventType.GetSource().ToString(), - $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", ev.EventType.GetSource() != SubathonEventSource.GoAffPro) /////////////////////////////////// + if (ev.ProcessedToSubathon && Utils.IsCommissionAsDonation(_config, ev) && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && ev.SecondaryValue.Contains('|')) { var value = ev.SecondaryValue.Split('|')[0]; @@ -776,9 +776,7 @@ public async Task UndoSimulatedEvents(AppDbContext db, List event moneyToRemove += await _currencyService.ConvertAsync(double.Parse(ev.Value), ev.Currency!, subathon.Currency!); } - else if (ev.EventType.IsOrder() && _config.GetBool(ev.EventType.GetSource().ToString(), - $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", - ev.EventType.GetSource() != SubathonEventSource.GoAffPro) /////////////////////////////////// + else if (Utils.IsCommissionAsDonation(_config, ev) && !string.IsNullOrWhiteSpace(ev.SecondaryValue) && ev.SecondaryValue.Contains('|')) { @@ -944,9 +942,9 @@ static SubathonSimulatedTotals BuildSimulated(List src) .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 => e.EventType!.Value).ToDictionary(g => g.Key, g => g.Count()), + OrderCountByType = orderLikeSim.GroupBy(e => GoAffProOrderHelper.GetOrderKey(e.EventType, e.EventTypeMeta)).ToDictionary(g => g.Key, g => g.Count()), OrderItemsCountByType = orderLikeSim - .GroupBy(e => e.EventType!.Value) + .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()), @@ -996,11 +994,11 @@ static SubathonSimulatedTotals BuildSimulated(List src) g => g.Sum(e => long.TryParse(e.Value, out var v) ? v : 0)), OrderCountByType = orderLike - .GroupBy(e => e.EventType!.Value) + .GroupBy(e => GoAffProOrderHelper.GetOrderKey(e.EventType, e.EventTypeMeta)) .ToDictionary(g => g.Key, g => g.Count()), - + OrderItemsCountByType = orderLike - .GroupBy(e => e.EventType!.Value) + .GroupBy(e => GoAffProOrderHelper.GetOrderKey(e.EventType, e.EventTypeMeta)) .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount)), FollowLikeTotal = followLike.Count, diff --git a/SubathonManager.Services/PromptOrchestratorService.cs b/SubathonManager.Services/PromptOrchestratorService.cs index e9901f64..1b674bb3 100644 --- a/SubathonManager.Services/PromptOrchestratorService.cs +++ b/SubathonManager.Services/PromptOrchestratorService.cs @@ -429,10 +429,13 @@ private static bool EventMatchesPrompt(SubathonEvent ev, SubathonPrompt prompt) private static bool MatchesEventType(SubathonEvent ev, SubathonPrompt prompt) { if (ev.EventType != prompt.FilterEventType) return false; - + + if (prompt.FilterEventType == SubathonEventType.GoAffProOrder) + return string.IsNullOrEmpty(prompt.FilterMeta) || ev.EventTypeMeta == prompt.FilterMeta; + if (prompt.SubType == SubathonPromptSubType.ByTier && !string.IsNullOrEmpty(prompt.FilterMeta)) return ev.Value == prompt.FilterMeta; - + return true; } @@ -506,8 +509,10 @@ private static async Task CountEventTypeAsync(AppDbContext db, SubathonPro var filterType = prompt.FilterEventType; var query = db.SubathonEvents.AsNoTracking() .Where(e => e.ProcessedToSubathon && e.EventType == filterType); - - if (prompt.SubType == SubathonPromptSubType.ByTier && !string.IsNullOrEmpty(prompt.FilterMeta)) + + if (filterType == SubathonEventType.GoAffProOrder && !string.IsNullOrEmpty(prompt.FilterMeta)) + query = query.Where(e => e.EventTypeMeta == 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) diff --git a/SubathonManager.Services/WheelSpinTriggerService.cs b/SubathonManager.Services/WheelSpinTriggerService.cs index 85d543d7..b8f64c6c 100644 --- a/SubathonManager.Services/WheelSpinTriggerService.cs +++ b/SubathonManager.Services/WheelSpinTriggerService.cs @@ -72,6 +72,16 @@ private async Task ProcessTriggers(SubathonEvent ev, SubathonEventSubType subTyp string.Equals(t.TierValue, ev.Value, StringComparison.OrdinalIgnoreCase)) .ToList(); } + + if (ev.EventType == SubathonEventType.GoAffProOrder) + { + candidates = candidates + .Where(t => string.IsNullOrEmpty(t.TierValue) || + string.Equals(t.TierValue, ev.EventTypeMeta, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(t => !string.IsNullOrEmpty(t.TierValue)) + .ToList(); + } + if (candidates.Count == 0) return; // unique rules, there should only be one match; take first anyways diff --git a/SubathonManager.Tests/CoreUnitTests/EnumValidationTests.cs b/SubathonManager.Tests/CoreUnitTests/EnumValidationTests.cs index 13c179b9..a9dd89fe 100644 --- a/SubathonManager.Tests/CoreUnitTests/EnumValidationTests.cs +++ b/SubathonManager.Tests/CoreUnitTests/EnumValidationTests.cs @@ -37,15 +37,24 @@ public void AssertEnumGroups() foreach (var subathonEventType in group) { var eventType = (SubathonEventType?) subathonEventType; - if (eventType == SubathonEventType.GoAffProOrder) continue; - Assert.StartsWith(subathonEventType.GetTypeTrueSource() ?? "ERROR_ERROR_ERROR", subathonEventType.ToString()); - Assert.True(Enum.TryParse(subathonEventType.GetTypeTrueSource(), true, out GoAffProSource gapSource)); - Assert.True(gapSource.GetSiteId() > 0); - Assert.True(gapSource.GetOrderEvent() == subathonEventType); Assert.True(eventType.IsOrder()); Assert.EndsWith("Order", subathonEventType.ToString()); Assert.Equal(eventType.GetDescription(), eventType.GetLabel()); + if (subathonEventType == SubathonEventType.GoAffProOrder) + { + Assert.True(subathonEventType.IsEnabled()); + Assert.True(subathonEventType.GetLegacyGoAffProSiteId() <= 0); + } + else + { + Assert.True(subathonEventType.IsDisabled()); + Assert.True(subathonEventType.GetLegacyGoAffProSiteId() > 0); + } } + + var 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 || diff --git a/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs index c192dec2..e712045a 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs @@ -68,6 +68,78 @@ public void ProcessExternalCommand_ShouldRaiseEvent_WhenValidCommand() Assert.Equal(SubathonEventType.Command, ev.EventType); } + [Fact] + public void ProcessExternalCommand_ShouldUseStreamDeckSource_WhenProvided() + { + var json = """ + { + "command": "Pause", + "user": "EXTERNAL", + "source": "StreamDeck", + "message": "" + } + """; + + var data = JsonSerializer.Deserialize>(json)!; + + bool result = false; + SubathonEvent? ev = CaptureEvent(() => result = ExternalEventService.ProcessExternalCommand(data)); + + Assert.True(result); + Assert.NotNull(ev); + Assert.Equal("EXTERNAL", ev!.User); + Assert.Equal(SubathonEventSource.StreamDeck, ev.Source); + Assert.Equal(SubathonCommandType.Pause, ev.Command); + Assert.Equal(SubathonEventType.Command, ev.EventType); + } + + [Fact] + public void ProcessExternalCommand_ShouldUseStreamerBotSource_WhenProvided() + { + var json = """ + { + "command": "AddPoints", + "user": "EXTERNAL", + "source": "StreamerBot", + "message": "5" + } + """; + + var data = JsonSerializer.Deserialize>(json)!; + + bool result = false; + SubathonEvent? ev = CaptureEvent(() => result = ExternalEventService.ProcessExternalCommand(data)); + + Assert.True(result); + Assert.NotNull(ev); + Assert.Equal("EXTERNAL", ev!.User); + Assert.Equal("AddPoints 5", ev.Value); + Assert.Equal(SubathonEventSource.StreamerBot, ev.Source); + Assert.Equal(SubathonCommandType.AddPoints, ev.Command); + } + + [Fact] + public void ProcessExternalCommand_ShouldIgnoreUnknownSource() + { + var json = """ + { + "command": "Pause", + "user": "Tester", + "source": "Twitch", + "message": "" + } + """; + + var data = JsonSerializer.Deserialize>(json)!; + + bool result = false; + SubathonEvent? ev = CaptureEvent(() => result = ExternalEventService.ProcessExternalCommand(data)); + + Assert.True(result); + Assert.NotNull(ev); + Assert.Equal(SubathonEventSource.External, ev!.Source); + } + [Fact] public void ProcessExternalCommand_ShouldRaiseEvent_WhenValidCommandWithParam() { diff --git a/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs index 5d58ecf5..da1be383 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs @@ -14,6 +14,17 @@ namespace SubathonManager.Tests.IntegrationUnitTests; [Collection("SharedEventBusTests")] public class GoAffProServiceTests { + private static readonly GoAffProStore GamerSupps = new() + { SiteId = 165328, StoreName = "GamerSupps", EventName = "GamerSupps Order" }; + private static readonly GoAffProStore UwUMarket = new() + { SiteId = 132230, StoreName = "UwUMarket", EventName = "UwUMarket Order" }; + + static GoAffProServiceTests() + { + GoAffProStoreRegistry.Register(GamerSupps); + GoAffProStoreRegistry.Register(UwUMarket); + } + private static SubathonEvent? CaptureEvent(Action trigger) => EventUtil.SubathonEventCapture.CaptureRequired(trigger); @@ -47,21 +58,22 @@ void EventCaptureHandler(IntegrationConnection conn) [Theory] - [InlineData(GoAffProSource.UwUMarket, 10.99, 2.99, 1, - OrderTypeModes.Dollar, "USD", "2.99|USD", "10.99", SubathonEventType.UwUMarketOrder)] - [InlineData(GoAffProSource.GamerSupps, 20.99, 6.99, 3, - OrderTypeModes.Item, "items", "6.99|USD", "3", SubathonEventType.GamerSuppsOrder)] - [InlineData(GoAffProSource.GamerSupps, 20.99, 6.99, 3, - OrderTypeModes.Order, "order", "6.99|USD", "New", SubathonEventType.GamerSuppsOrder)] - public void SimulateOrder_RaisesEvent(GoAffProSource store, decimal total, decimal commission, int quantity, - OrderTypeModes type, string expectedCurrency, string expectedSecondaryValue, string expectedValue, SubathonEventType expectedEventType) + [InlineData(132230, 10.99, 2.99, 1, + OrderTypeModes.Dollar, "USD", "2.99|USD", "10.99")] + [InlineData(165328, 20.99, 6.99, 3, + OrderTypeModes.Item, "items", "6.99|USD", "3")] + [InlineData(165328, 20.99, 6.99, 3, + OrderTypeModes.Order, "order", "6.99|USD", "New")] + public void SimulateOrder_RaisesEvent(int siteId, decimal total, decimal commission, int quantity, + OrderTypeModes type, string expectedCurrency, string expectedSecondaryValue, string expectedValue) { + Assert.True(GoAffProStoreRegistry.TryGetBySiteId(siteId, out var store)); var logger = new Mock>(); Dictionary<(string, string), string> values = new() { - { ("GoAffPro", $"{store}.Enabled"), "True" }, - { ("GoAffPro", $"{store}.Mode"), $"{type}" }, - { ("GoAffPro", $"{store}.CommissionAsDonation"), "true" }, + { ("GoAffPro", $"{store.InternalName}.Enabled"), "True" }, + { ("GoAffPro", $"{store.InternalName}.Mode"), $"{type}" }, + { ("GoAffPro", $"{store.InternalName}.CommissionAsDonation"), "true" }, }; var storage = new InMemorySecureStorage(new() @@ -70,38 +82,43 @@ public void SimulateOrder_RaisesEvent(GoAffProSource store, decimal total, decim [StorageKeys.GoAffProPassword] = "", }); GoAffProService service = new GoAffProService(logger.Object, MockConfig.MakeMockConfig(values), storage); - + typeof(SubathonEvents) .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); var ev = CaptureEvent(() => service.SimulateOrder(total, quantity, commission, store)); Assert.NotNull(ev); - Assert.Equal(expectedEventType, ev.EventType); + Assert.Equal(SubathonEventType.GoAffProOrder, ev.EventType); + Assert.Equal(siteId.ToString(), ev.EventTypeMeta); Assert.Equal(SubathonEventSubType.OrderLike, ev.EventType.GetSubType()); Assert.Equal(expectedCurrency, ev.Currency); Assert.Equal(expectedValue, ev.Value); Assert.Equal(expectedSecondaryValue, ev.SecondaryValue); - Assert.Equal($"SYSTEM {store}", ev.User); + Assert.Equal($"SYSTEM {store.InternalName}", ev.User); } - - + + [Theory] - [InlineData(GoAffProSource.Unknown, 10.99, 2.99, 1, - OrderTypeModes.Dollar, "True")] - [InlineData(GoAffProSource.UwUMarket, 10.99, 2.99, 1, + [InlineData(null, 10.99, 2.99, 1, + OrderTypeModes.Dollar, "True")] // unknown store + [InlineData(132230, 10.99, 2.99, 1, OrderTypeModes.Dollar, "False")] - [InlineData(GoAffProSource.GamerSupps, 10.99, 2.99, 1, + [InlineData(165328, 10.99, 2.99, 1, OrderTypeModes.Item, "False")] - public void SimulateOrder_BadPath_DoesNotRaiseEvent(GoAffProSource store, decimal total, decimal commission, int quantity, + public void SimulateOrder_BadPath_DoesNotRaiseEvent(int? siteId, decimal total, decimal commission, int quantity, OrderTypeModes type, string enabledString) { + GoAffProStore? store = null; + if (siteId.HasValue) + Assert.True(GoAffProStoreRegistry.TryGetBySiteId(siteId.Value, out store)); + var storeKey = store?.InternalName ?? "Unknown"; var logger = new Mock>(); Dictionary<(string, string), string> values = new() { - { ("GoAffPro", $"{store}.Enabled"), enabledString}, - { ("GoAffPro", $"{store}.Mode"), $"{type}" }, - { ("GoAffPro", $"{store}.CommissionAsDonation"), "true" }, + { ("GoAffPro", $"{storeKey}.Enabled"), enabledString}, + { ("GoAffPro", $"{storeKey}.Mode"), $"{type}" }, + { ("GoAffPro", $"{storeKey}.CommissionAsDonation"), "true" }, }; var storage = new InMemorySecureStorage(new() @@ -110,7 +127,7 @@ public void SimulateOrder_BadPath_DoesNotRaiseEvent(GoAffProSource store, decima [StorageKeys.GoAffProPassword] = "", }); GoAffProService service = new GoAffProService(logger.Object, MockConfig.MakeMockConfig(values), storage); - + typeof(SubathonEvents) .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); diff --git a/SubathonManager.Tests/IntegrationUnitTests/PallyServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/PallyServiceTests.cs new file mode 100644 index 00000000..0cb98546 --- /dev/null +++ b/SubathonManager.Tests/IntegrationUnitTests/PallyServiceTests.cs @@ -0,0 +1,180 @@ +using Microsoft.Extensions.Logging; +using Moq; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Core.Security; +using SubathonManager.Integration; +using SubathonManager.Tests.Utility; + +namespace SubathonManager.Tests.IntegrationUnitTests; + +[Collection("SharedEventBusTests")] +public class PallyServiceTests +{ + private static SubathonEvent? CaptureEvent(Action trigger) => + EventUtil.SubathonEventCapture.CaptureRequired(trigger); + + private static PallyService MakeService(string? apiKey = null, string room = "", bool enabled = true) + { + var logger = new Mock>(); + var storage = new InMemorySecureStorage(apiKey != null + ? new Dictionary { [StorageKeys.PallyApiKey] = apiKey } + : null); + IConfig config = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> + { + { ("PallyGG", "Room"), room }, + { ("PallyGG", "Enabled"), enabled.ToString() } + }); + return new PallyService(logger.Object, config, storage); + } + + private const string TipMessage = """ + { + "type": "campaigntip.notify", + "payload": { + "campaignTip": { + "createdAt": "2024-03-13T18:02:33.743Z", + "displayName": "Someone", + "grossAmountInCents": 500, + "id": "b1w2pjwjtb9fx0v1se9ex4n2", + "message": "", + "netAmountInCents": 500, + "processingFeeInCents": 0, + "updatedAt": "2024-03-13T18:02:33.743Z" + }, + "page": { + "id": "1627451579049x550722173620715500", + "slug": "pally", + "title": "PallyGG.gg's Team Page", + "url": "https://pally.gg/p/pally" + } + } + } + """; + + [Fact] + public void ProcessMessage_ValidTip_RaisesEvent() + { + var service = MakeService("key"); + bool result = false; + var ev = CaptureEvent(() => result = service.ProcessMessage(TipMessage)); + + Assert.True(result); + Assert.NotNull(ev); + Assert.Equal(SubathonEventSource.PallyGG, ev!.Source); + Assert.Equal(SubathonEventType.PallyGGDonation, ev.EventType); + Assert.Equal("USD", ev.Currency); + Assert.Equal("5.00", ev.Value); + Assert.Equal("Someone", ev.User); + Assert.Equal("pally", ev.EventTypeMeta); + } + + [Fact] + public void ProcessMessage_SameTip_HasDeterministicId() + { + var service = MakeService("key"); + var ev1 = CaptureEvent(() => service.ProcessMessage(TipMessage)); + var ev2 = CaptureEvent(() => service.ProcessMessage(TipMessage)); + + Assert.NotNull(ev1); + Assert.NotNull(ev2); + Assert.NotEqual(Guid.Empty, ev1!.Id); + Assert.Equal(ev1.Id, ev2!.Id); + } + + [Fact] + public void ProcessMessage_MissingDisplayName_UsesAnonymous() + { + var service = MakeService("key"); + var message = TipMessage.Replace("\"displayName\": \"Someone\",", "\"displayName\": \"\","); + var ev = CaptureEvent(() => service.ProcessMessage(message)); + + Assert.NotNull(ev); + Assert.Equal("Anonymous", ev!.User); + } + + [Fact] + public void ProcessMessage_Simulated_UsesSystemAndSimulatedSource() + { + var service = MakeService("key"); + var ev = CaptureEvent(() => service.ProcessMessage(TipMessage, simulated: true)); + + Assert.NotNull(ev); + Assert.Equal("SYSTEM", ev!.User); + Assert.Equal(SubathonEventSource.Simulated, ev.Source); + } + + [Theory] + [InlineData("pong")] + [InlineData("")] + [InlineData("not json at all")] + [InlineData("{\"type\":\"something.else\",\"payload\":{}}")] + [InlineData("{\"type\":\"campaigntip.notify\",\"payload\":{}}")] + public void ProcessMessage_InvalidOrIrrelevant_ReturnsFalse(string message) + { + var service = MakeService("key"); + Assert.False(service.ProcessMessage(message)); + } + + [Fact] + public void ProcessMessage_ZeroAmount_ReturnsFalse() + { + var service = MakeService("key"); + var message = TipMessage.Replace("\"grossAmountInCents\": 500,", "\"grossAmountInCents\": 0,"); + Assert.False(service.ProcessMessage(message)); + } + + [Fact] + public void BuildUri_NoRoom_UsesFirehose() + { + var service = MakeService("my-key", room: ""); + var uri = service.BuildUri(); + + Assert.StartsWith("wss://events.pally.gg", uri.ToString()); + Assert.Contains("auth=my-key", uri.Query); + Assert.Contains("channel=firehose", uri.Query); + Assert.DoesNotContain("room=", uri.Query); + } + + [Fact] + public void BuildUri_WithRoom_UsesActivityFeed() + { + var service = MakeService("my-key", room: "my-page"); + var uri = service.BuildUri(); + + Assert.Contains("channel=activity-feed", uri.Query); + Assert.Contains("room=my-page", uri.Query); + } + + [Fact] + public void SimulateTip_RaisesSimulatedUsdEvent() + { + var ev = CaptureEvent(() => PallyService.SimulateTip("12.34")); + + Assert.NotNull(ev); + Assert.Equal("SYSTEM", ev!.User); + Assert.Equal(SubathonEventSource.Simulated, ev.Source); + Assert.Equal(SubathonEventType.PallyGGDonation, ev.EventType); + Assert.Equal("USD", ev.Currency); + Assert.Equal("12.34", ev.Value); + } + + [Fact] + public async Task StartAsync_NoApiKey_StaysDisconnected() + { + var service = MakeService(apiKey: null); + await service.StartAsync(TestContext.Current.CancellationToken); + Assert.False(service.Connected); + await service.StopAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task StartAsync_Disabled_StaysDisconnected() + { + var service = MakeService(apiKey: "key", enabled: false); + await service.StartAsync(TestContext.Current.CancellationToken); + Assert.False(service.Connected); + await service.StopAsync(TestContext.Current.CancellationToken); + } +} diff --git a/SubathonManager.Tests/IntegrationUnitTests/TreatStreamServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/TreatStreamServiceTests.cs new file mode 100644 index 00000000..06d1aaaf --- /dev/null +++ b/SubathonManager.Tests/IntegrationUnitTests/TreatStreamServiceTests.cs @@ -0,0 +1,158 @@ +using Microsoft.Extensions.Logging; +using Moq; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Core.Security; +using SubathonManager.Core.Security.Interfaces; +using SubathonManager.Integration; +using SubathonManager.Tests.Utility; + +namespace SubathonManager.Tests.IntegrationUnitTests; + +[Collection("SharedEventBusTests")] +public class TreatStreamServiceTests +{ + private static SubathonEvent? CaptureEvent(Action trigger) => + EventUtil.SubathonEventCapture.CaptureRequired(trigger); + + private static (TreatStreamService Service, ISecureStorage Storage) MakeService( + string? accessToken = null, string? refreshToken = null) + { + var logger = new Mock>(); + var seed = new Dictionary(); + if (accessToken != null) seed[StorageKeys.TreatStreamAccessToken] = accessToken; + if (refreshToken != null) seed[StorageKeys.TreatStreamRefreshToken] = refreshToken; + var storage = new InMemorySecureStorage(seed.Count > 0 ? seed : null); + var httpFactory = new Mock().Object; + var timer = new Mock().Object; + return (new TreatStreamService(logger.Object, httpFactory, storage, timer), storage); + } + + private const string TreatJson = """ + { + "message": "enjoy!", + "sender": "GenerousViewer", + "receiver": "Streamer", + "title": "Large Pizza", + "sender_type": "user", + "receiver_type": "streamer", + "date_created": "2026-07-08 12:34:56" + } + """; + + [Fact] + public void ProcessTreatJson_ValidTreat_RaisesOrderEvent() + { + var (service, _) = MakeService(); + bool result = false; + var ev = CaptureEvent(() => result = service.ProcessTreatJson(TreatJson)); + + Assert.True(result); + Assert.NotNull(ev); + Assert.Equal(SubathonEventSource.TreatStream, ev!.Source); + Assert.Equal(SubathonEventType.TreatStreamOrder, ev.EventType); + Assert.Equal("GenerousViewer", ev.User); + Assert.Equal("item", ev.Currency); + Assert.Equal(1, ev.Amount); + Assert.Equal("Large Pizza", ev.Value); + Assert.Equal("Large Pizza", ev.TertiaryValue); + } + + [Fact] + public void ProcessTreatJson_SameTreat_HasDeterministicId() + { + var (service, _) = MakeService(); + var ev1 = CaptureEvent(() => service.ProcessTreatJson(TreatJson)); + var ev2 = CaptureEvent(() => service.ProcessTreatJson(TreatJson)); + + Assert.NotNull(ev1); + Assert.NotNull(ev2); + Assert.NotEqual(Guid.Empty, ev1!.Id); + Assert.Equal(ev1.Id, ev2!.Id); + } + + [Fact] + public void ProcessTreatJson_DifferentDate_HasDifferentId() + { + var (service, _) = MakeService(); + var ev1 = CaptureEvent(() => service.ProcessTreatJson(TreatJson)); + var ev2 = CaptureEvent(() => service.ProcessTreatJson( + TreatJson.Replace("12:34:56", "12:34:57"))); + + Assert.NotEqual(ev1!.Id, ev2!.Id); + } + + [Fact] + public void ProcessTreatJson_SystemSender_IsSimulated() + { + var (service, _) = MakeService(); + var ev = CaptureEvent(() => service.ProcessTreatJson( + TreatJson.Replace("GenerousViewer", "SYSTEM"))); + + Assert.NotNull(ev); + Assert.Equal(SubathonEventSource.Simulated, ev!.Source); + Assert.Equal("SYSTEM", ev.User); + } + + [Theory] + [InlineData("not json")] + [InlineData("{}")] + [InlineData("{\"sender\":\"a\",\"title\":\"\"}")] + [InlineData("[1,2,3]")] + public void ProcessTreatJson_Invalid_ReturnsFalse(string json) + { + var (service, _) = MakeService(); + Assert.False(service.ProcessTreatJson(json)); + } + + [Fact] + public void SimulateTreat_RaisesSimulatedEvent() + { + var ev = CaptureEvent(() => TreatStreamService.SimulateTreat("Cupcake")); + + Assert.NotNull(ev); + Assert.Equal(SubathonEventSource.Simulated, ev!.Source); + Assert.Equal(SubathonEventType.TreatStreamOrder, ev.EventType); + Assert.Equal("SYSTEM", ev.User); + Assert.Equal("item", ev.Currency); + Assert.Equal(1, ev.Amount); + Assert.Equal("Cupcake", ev.Value); + } + + [Fact] + public void StoreExpiry_UsesExpiresIn() + { + var (service, storage) = MakeService("token", "refresh"); + service.StoreExpiry("3600"); + + var raw = storage.GetOrDefault(StorageKeys.TreatStreamTokenExpiry, ""); + Assert.True(DateTime.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, + out var expiry)); + Assert.InRange(expiry, DateTime.UtcNow.AddMinutes(55), DateTime.UtcNow.AddMinutes(65)); + } + + [Fact] + public void StoreExpiry_MissingExpiresIn_DefaultsTo15Days() + { + // api says 30, we do half for safety + var (service, storage) = MakeService("token", "refresh"); + service.StoreExpiry(null); + + var raw = storage.GetOrDefault(StorageKeys.TreatStreamTokenExpiry, ""); + Assert.True(DateTime.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, + out var expiry)); + Assert.InRange(expiry, DateTime.UtcNow.AddDays(14), DateTime.UtcNow.AddDays(16)); + } + + [Fact] + public async Task StartAsync_NoTokens_StaysDisconnected() + { + var (service, _) = MakeService(); + await service.StartAsync(TestContext.Current.CancellationToken); + Assert.False(service.HasTokens()); + await service.StopAsync(TestContext.Current.CancellationToken); + } +} diff --git a/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs index faed1271..cd489db7 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs @@ -3,7 +3,6 @@ using TwitchLib.EventSub.Core.EventArgs.Channel; using TwitchLib.EventSub.Websockets.Core.Models; using TwitchLib.Client.Enums; -using System.Drawing; using System.Net; using System.Net.WebSockets; using System.Text.Json; @@ -521,45 +520,40 @@ ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcast if (isMod) type = UserType.Moderator; if (isBroadcaster) type = UserType.Broadcaster; + var details = UserDetails.Subscriber; + if (isMod) details |= UserDetails.Moderator; + if (isVip) details |= UserDetails.Vip; + return new ChatMessage( botUsername: "", userId: "123456789", userName: userName, displayName: displayName, - colorHex: "#ffffff", - color: Color.Black, + hexColor: "#ffffff", emoteSet: new("", ""), message: message, userType: type, channel: "teststreamer", id: Guid.NewGuid().ToString(), - isSubscriber: true, subscribedMonthCount: 4, roomId: "098765432", - isTurbo: false, - isModerator: isMod, isMe: false, isBroadcaster: isBroadcaster, - isVip: isVip, - isPartner: false, - isStaff: false, noisy: Noisy.NotSet, rawIrcMessage: "", emoteReplacedMessage: "", badges: [], cheerBadge: new CheerBadge(0), bits: 0, - bitsInDollars: 0 + bitsInDollars: 0, + userDetail: new UserDetail(details) ); } var chatMsg = MakeMessage("!pause", false, false, true, "test", "Test"); - var args = new OnMessageReceivedArgs - { - ChatMessage = chatMsg - }; + var args = new OnMessageReceivedArgs(chatMsg); var ev = CaptureEvent(() => @@ -578,10 +572,7 @@ ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcast chatMsg = MakeMessage("!pause", false, false, false, "specialguy", "specialguy"); - args = new OnMessageReceivedArgs - { - ChatMessage = chatMsg - }; + args = new OnMessageReceivedArgs(chatMsg); ev = CaptureEvent(() => service @@ -598,10 +589,7 @@ ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcast chatMsg = MakeMessage("!pause", false, true, false, "testuser", "TestUser"); - args = new OnMessageReceivedArgs - { - ChatMessage = chatMsg - }; + args = new OnMessageReceivedArgs(chatMsg); ev = CaptureEvent(() => service @@ -618,10 +606,7 @@ ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcast chatMsg = MakeMessage("!pause", true, false, false, "testuser", "TestUser"); - args = new OnMessageReceivedArgs - { - ChatMessage = chatMsg - }; + args = new OnMessageReceivedArgs(chatMsg); ev = CaptureEvent(() => service @@ -634,10 +619,7 @@ ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcast chatMsg = MakeMessage("!resume", true, true, false, "testuser", "TestUser"); - args = new OnMessageReceivedArgs - { - ChatMessage = chatMsg - }; + args = new OnMessageReceivedArgs(chatMsg); ev = CaptureEvent(() => service @@ -1066,45 +1048,40 @@ ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcast if (isMod) type = UserType.Moderator; if (isBroadcaster) type = UserType.Broadcaster; + var details = UserDetails.Subscriber; + if (isMod) details |= UserDetails.Moderator; + if (isVip) details |= UserDetails.Vip; + return new ChatMessage( botUsername: "", userId: "123456789", userName: userName, displayName: displayName, - colorHex: "#ffffff", - color: Color.Black, + hexColor: "#ffffff", emoteSet: new("", ""), message: message, userType: type, channel: "teststreamer", id: Guid.NewGuid().ToString(), - isSubscriber: true, subscribedMonthCount: 4, roomId: "098765432", - isTurbo: false, - isModerator: isMod, isMe: false, isBroadcaster: isBroadcaster, - isVip: isVip, - isPartner: false, - isStaff: false, noisy: Noisy.NotSet, rawIrcMessage: "", emoteReplacedMessage: "", badges: [], cheerBadge: new CheerBadge(0), bits: 0, - bitsInDollars: 0 + bitsInDollars: 0, + userDetail: new UserDetail(details) ); } var chatMsg = MakeMessage("SomeGuy used 500 bits to play XYZ", false, false, false, "blerp", "blerp"); - var args = new OnMessageReceivedArgs - { - ChatMessage = chatMsg - }; + var args = new OnMessageReceivedArgs(chatMsg); var ev = CaptureEvent(() => @@ -1140,45 +1117,40 @@ ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcast if (isMod) type = UserType.Moderator; if (isBroadcaster) type = UserType.Broadcaster; + var details = UserDetails.Subscriber; + if (isMod) details |= UserDetails.Moderator; + if (isVip) details |= UserDetails.Vip; + return new ChatMessage( botUsername: "", userId: "123456789", userName: userName, displayName: displayName, - colorHex: "#ffffff", - color: Color.Black, + hexColor: "#ffffff", emoteSet: new("", ""), message: message, userType: type, channel: "teststreamer", id: Guid.NewGuid().ToString(), - isSubscriber: true, subscribedMonthCount: 4, roomId: "098765432", - isTurbo: false, - isModerator: isMod, isMe: false, isBroadcaster: isBroadcaster, - isVip: isVip, - isPartner: false, - isStaff: false, noisy: Noisy.NotSet, rawIrcMessage: "", emoteReplacedMessage: "", badges: [], cheerBadge: new CheerBadge(0), bits: 0, - bitsInDollars: 0 + bitsInDollars: 0, + userDetail: new UserDetail(details) ); } var chatMsg = MakeMessage("SomeGuy used 500 bits to play XYZ", false, false, false, "blerp", "blerp"); - var args = new OnMessageReceivedArgs - { - ChatMessage = chatMsg - }; + var args = new OnMessageReceivedArgs(chatMsg); var ev = CaptureEvent(() => diff --git a/SubathonManager.Tests/SequentialCollectionDefinition.cs b/SubathonManager.Tests/SequentialCollectionDefinition.cs index 31137af8..3d7e16c9 100644 --- a/SubathonManager.Tests/SequentialCollectionDefinition.cs +++ b/SubathonManager.Tests/SequentialCollectionDefinition.cs @@ -1,11 +1,12 @@ -namespace SubathonManager.Tests; +namespace SubathonManager.Tests; [CollectionDefinition("Sequential", DisableParallelization = true)] public class SequentialCollectionDefinition { } -[CollectionDefinition("SequentialParallel", DisableParallelization = false)] + +[CollectionDefinition("SequentialParallel", DisableParallelization = true)] public class SequentialParallelCollectionDefinition { } @@ -14,4 +15,10 @@ public class SequentialParallelCollectionDefinition public class SharedEventBusTestsCollection { } [CollectionDefinition("NonParallel", DisableParallelization = true)] -public class NonParallelCollection { } \ No newline at end of file +public class NonParallelCollection { } + +[CollectionDefinition("ServicesTests", DisableParallelization = true)] +public class ServicesTestsCollection { } + +[CollectionDefinition("ProviderOverrideTests", DisableParallelization = true)] +public class ProviderOverrideTestsCollection { } diff --git a/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs b/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs index a000d074..81730e3e 100644 --- a/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs +++ b/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs @@ -178,6 +178,25 @@ public async Task Api_Unknown_Route_Returns_400() AppServices.Provider = null!; } + [Fact] + public async Task Commands_Endpoint_Returns_Catalog() + { + var ctx = new MockHttpContext + { + Method = "GET", + Path = "/api/data/commands" + }; + + var server = CreateServer(); + SetupServices(); + await server.InvokeHandleRequest(ctx); + Assert.Equal(200, ctx.StatusCode); + Assert.Contains("\"AddTime\"", ctx.ResponseBody); + Assert.Contains("requires_parameter", ctx.ResponseBody); + Assert.DoesNotContain("\"Unknown\"", ctx.ResponseBody); + AppServices.Provider = null!; + } + [Fact] public async Task DataControl_Invalid_Body_Returns_400() { diff --git a/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs b/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs index 6918fe2b..7ff4a2b9 100644 --- a/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs +++ b/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs @@ -762,4 +762,78 @@ public async Task WebSocket_ReceiveAndInitConfigConsumer() } } + [Fact] + public async Task WebSocket_CommandListRequest_ReturnsCatalog() + { + SetupServices(); + var server = CreateServer(); + + try + { + var ctx = new MockHttpContext + { + IsWebSocket = true + }; + + ctx.Socket.EnqueueReceive("{\"ws_type\":\"Command\",\"request\":\"commands\"}"); + ctx.Socket.EnqueueClose(); + + await server.HandleWebSocketRequestAsync(ctx); + + await WaitForMessageMatchingAsync(ctx.Socket, m => m.Contains("command_list"), TimeSpan.FromSeconds(5)); + + string msg = ctx.Socket.SentMessages + .Select(b => Encoding.UTF8.GetString(b)) + .First(m => m.Contains("command_list")); + + Assert.Contains($"\"{nameof(SubathonCommandType.AddTime)}\"", msg); + Assert.Contains($"\"{nameof(SubathonCommandType.Pause)}\"", msg); + Assert.Contains("requires_parameter", msg); + Assert.Contains("is_control", msg); + Assert.DoesNotContain($"\"{nameof(SubathonCommandType.Unknown)}\"", msg); + Assert.DoesNotContain($"\"{nameof(SubathonCommandType.None)}\"", msg); + } + finally + { + AppServices.Provider = null!; + server.Stop(); + } + } + + [Fact] + public async Task WebSocket_Command_SendsAckWithContext() + { + SetupServices(); + var server = CreateServer(); + + try + { + var ctx = new MockHttpContext + { + IsWebSocket = true + }; + + ctx.Socket.EnqueueReceive( + "{\"ws_type\":\"Command\",\"type\":\"Command\",\"command\":\"pause\",\"message\":\"\",\"user\":\"StreamDeck\",\"context\":\"key-context-1\"}"); + ctx.Socket.EnqueueClose(); + + await server.HandleWebSocketRequestAsync(ctx); + + await WaitForMessageMatchingAsync(ctx.Socket, m => m.Contains("command_ack"), TimeSpan.FromSeconds(5)); + + string msg = ctx.Socket.SentMessages + .Select(b => Encoding.UTF8.GetString(b)) + .First(m => m.Contains("command_ack")); + + Assert.Contains("\"success\":true", msg); + Assert.Contains("key-context-1", msg); + Assert.Contains("\"pause\"", msg); + } + finally + { + AppServices.Provider = null!; + server.Stop(); + } + } + } \ No newline at end of file diff --git a/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs b/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs index 1252e212..648697e4 100644 --- a/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs +++ b/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs @@ -5,6 +5,7 @@ using SubathonManager.Data; using SubathonManager.Core.Models; using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; using System.Net; using System.Reflection; using Moq.Protected; @@ -64,13 +65,13 @@ internal static CurrencyService SetupCurrencyService() Microsoft.Data.Sqlite.SqliteConnection)> SetupServiceWithDb(int initialPoints = 10, bool isLocked = true, bool showEventsState = false, bool allowPointsLocked = true) { var dbName = $"test_{Guid.NewGuid():N}"; - var connectionString = $"DataSource={dbName};Mode=Memory;Cache=Shared"; - //var connection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:"); //;Cache=Shared"); + var connectionString = $"DataSource={dbName};Mode=Memory;Cache=Shared;Pooling=False"; + var connection = new Microsoft.Data.Sqlite.SqliteConnection(connectionString); await connection.OpenAsync(); var options = new DbContextOptionsBuilder() - .UseSqlite(connection) + .UseSqlite(connectionString) .Options; await using (var db = new AppDbContext(options)) @@ -84,6 +85,7 @@ internal static CurrencyService SetupCurrencyService() { Id = Guid.NewGuid(), IsActive = true, Goals = [new SubathonGoal() { Text = "New Goal", Points = 1 }] }); await db.SaveChangesAsync(TestContext.Current.CancellationToken); AppDbContext.SeedDefaultValues(db); + SubathonManager.Core.GoAffProStoreRegistry.Initialize(db.GoAffProStores.ToList()); } var factoryMock = new Mock>(); @@ -231,6 +233,36 @@ public async Task SetPointsCommand_Works() await conn.CloseAsync(); } + [Fact] + public async Task SpinWheelCommand_RaisesWheelSpinRequested() + { + var (service, options, conn) = await SetupServiceWithDb(); + + int requested = 0; + Action handler = () => requested++; + WheelEvents.WheelSpinRequested += handler; + try + { + var ev = new SubathonEvent + { + Id = Guid.NewGuid(), + EventType = SubathonEventType.Command, + Command = SubathonCommandType.SpinWheel + }; + + var (processed, _) = await service.ProcessSubathonEvent(ev); + + Assert.True(processed); + Assert.Equal(1, requested); + } + finally + { + WheelEvents.WheelSpinRequested -= handler; + await service.StopAsync(TestContext.Current.CancellationToken); + await conn.CloseAsync(); + } + } + [Fact] public async Task AddTimeCommand_Works() { @@ -326,7 +358,8 @@ public async Task OrderEvent_WithCommission() var ev = new SubathonEvent { Id = Guid.NewGuid(), - EventType = SubathonEventType.UwUMarketOrder, + EventType = SubathonEventType.GoAffProOrder, + EventTypeMeta = "132230", // UwU Market Currency = "USD", Value = "10.00", SecondaryValue = "2.00|USD" @@ -366,7 +399,8 @@ public async Task OrderEvent_ByItemsNoCommission(int pointsValue, int itemCount) var ev = new SubathonEvent { Id = Guid.NewGuid(), - EventType = SubathonEventType.GamerSuppsOrder, + EventType = SubathonEventType.GoAffProOrder, + EventTypeMeta = "165328", // GamerSupps Currency = "items", Value = itemCount.ToString(), Amount = itemCount, @@ -374,7 +408,7 @@ public async Task OrderEvent_ByItemsNoCommission(int pointsValue, int itemCount) }; await using var db = new AppDbContext(options); - var value = await db.SubathonValues.Where(x => x.EventType == SubathonEventType.GamerSuppsOrder).FirstAsync(cancellationToken: TestContext.Current.CancellationToken); + var value = await db.SubathonValues.Where(x => x.EventType == SubathonEventType.GoAffProOrder && x.Meta == "165328").FirstAsync(cancellationToken: TestContext.Current.CancellationToken); value.Points = pointsValue; await db.SaveChangesAsync(TestContext.Current.CancellationToken); @@ -414,7 +448,8 @@ public async Task OrderEvent_ByOrderNoCommission() var ev = new SubathonEvent { Id = Guid.NewGuid(), - EventType = SubathonEventType.GamerSuppsOrder, + EventType = SubathonEventType.GoAffProOrder, + EventTypeMeta = "165328", // GamerSupps Currency = "order", Value = "New", SecondaryValue = "2.00|USD" @@ -510,7 +545,8 @@ public async Task DeleteOrderTypeSubathonEvent_RemovesPointsAndTimeAndMoney() var ev = new SubathonEvent { Id = Guid.NewGuid(), - EventType = SubathonEventType.UwUMarketOrder, + EventType = SubathonEventType.GoAffProOrder, + EventTypeMeta = "132230", // UwUMarket SecondaryValue = "2.00|USD", Value = "10.00", Currency = "USD" @@ -590,7 +626,7 @@ public async Task UndoSimulatedEvents_RemovesSingleOrderCommissionEvent() ev = new SubathonEvent { Id = Guid.NewGuid(), SubathonId = sub.Id, Source = SubathonEventSource.Simulated, - User = "SYSTEM", EventType = SubathonEventType.UwUMarketOrder, Value = "10", + User = "SYSTEM", EventType = SubathonEventType.GoAffProOrder, EventTypeMeta = "132230", Value = "10", Currency = "USD", SecondaryValue = "2.00|USD", PointsValue = 5, SecondsValue = 10, ProcessedToSubathon = true }; @@ -1203,17 +1239,18 @@ public async Task ProcessSubathonEvent_WasReversed_DecrementsMilliseconds() } [Theory] - [InlineData(SubathonEventType.UwUMarketOrder, true)] - [InlineData(SubathonEventType.GamerSuppsOrder, false)] + [InlineData("132230", true)] // UwUMarket - commission as donation on + [InlineData("165328", false)] // GamerSupps - commission as donation off public async Task ProcessSubathonEvent_OrderCommission_MoneySum_Branches( - SubathonEventType orderType, bool commissionAsDonation) + string storeMeta, bool commissionAsDonation) { var (service, options, conn) = await SetupServiceWithDb(0, false); var ev = new SubathonEvent { Id = Guid.NewGuid(), - EventType = orderType, + EventType = SubathonEventType.GoAffProOrder, + EventTypeMeta = storeMeta, Currency = "USD", Value = "20.00", SecondaryValue = "5.00|USD" diff --git a/SubathonManager.Tests/ServicesUnitTests/PromptOrchestratorServiceTests.cs b/SubathonManager.Tests/ServicesUnitTests/PromptOrchestratorServiceTests.cs index 24d71bc3..0f1c48f3 100644 --- a/SubathonManager.Tests/ServicesUnitTests/PromptOrchestratorServiceTests.cs +++ b/SubathonManager.Tests/ServicesUnitTests/PromptOrchestratorServiceTests.cs @@ -24,11 +24,13 @@ public class PromptOrchestratorServiceTests : IAsyncDisposable public PromptOrchestratorServiceTests() { var dbName = $"prompt_test_{Guid.NewGuid():N}"; - _connection = new SqliteConnection($"DataSource={dbName};Mode=Memory;Cache=Shared"); + var connectionString = $"DataSource={dbName};Mode=Memory;Cache=Shared;Pooling=False"; + + _connection = new SqliteConnection(connectionString); _connection.Open(); _options = new DbContextOptionsBuilder() - .UseSqlite(_connection) + .UseSqlite(connectionString) .Options; using var db = new AppDbContext(_options); @@ -475,8 +477,8 @@ public async Task GetCurrentCountAsync_Orders_Default_CountsOrders() { await using var db = CreateDb(); db.SubathonDatas.Add(new SubathonData { IsActive = true, Points = 0, Currency = "USD" }); - db.SubathonEvents.Add(new SubathonEvent { EventType = SubathonEventType.UwUMarketOrder, ProcessedToSubathon = true, Amount = 1 }); - db.SubathonEvents.Add(new SubathonEvent { EventType = SubathonEventType.UwUMarketOrder, ProcessedToSubathon = true, Amount = 3 }); + db.SubathonEvents.Add(new SubathonEvent { EventType = SubathonEventType.GoAffProOrder, EventTypeMeta = "132230", ProcessedToSubathon = true, Amount = 1 }); + db.SubathonEvents.Add(new SubathonEvent { EventType = SubathonEventType.GoAffProOrder, EventTypeMeta = "132230", ProcessedToSubathon = true, Amount = 3 }); await db.SaveChangesAsync(TestContext.Current.CancellationToken); var prompt = new SubathonPrompt { Type = SubathonPromptType.Orders, SubType = SubathonPromptSubType.Default }; @@ -489,8 +491,8 @@ public async Task GetCurrentCountAsync_Orders_Items_SumsAmounts() { await using var db = CreateDb(); db.SubathonDatas.Add(new SubathonData { IsActive = true, Points = 0, Currency = "USD" }); - db.SubathonEvents.Add(new SubathonEvent { EventType = SubathonEventType.UwUMarketOrder, ProcessedToSubathon = true, Amount = 2 }); - db.SubathonEvents.Add(new SubathonEvent { EventType = SubathonEventType.UwUMarketOrder, ProcessedToSubathon = true, Amount = 4 }); + db.SubathonEvents.Add(new SubathonEvent { EventType = SubathonEventType.GoAffProOrder, EventTypeMeta = "132230", ProcessedToSubathon = true, Amount = 2 }); + db.SubathonEvents.Add(new SubathonEvent { EventType = SubathonEventType.GoAffProOrder, EventTypeMeta = "132230", ProcessedToSubathon = true, Amount = 4 }); await db.SaveChangesAsync(TestContext.Current.CancellationToken); var prompt = new SubathonPrompt { Type = SubathonPromptType.Orders, SubType = SubathonPromptSubType.Items }; diff --git a/SubathonManager.Tests/ServicesUnitTests/WheelSpinTriggerServiceTests.cs b/SubathonManager.Tests/ServicesUnitTests/WheelSpinTriggerServiceTests.cs index 5dddc956..655cfdcf 100644 --- a/SubathonManager.Tests/ServicesUnitTests/WheelSpinTriggerServiceTests.cs +++ b/SubathonManager.Tests/ServicesUnitTests/WheelSpinTriggerServiceTests.cs @@ -47,10 +47,11 @@ private static CurrencyService MakeCurrencyService() Microsoft.Data.Sqlite.SqliteConnection conn)> SetupServiceWithDb() { var dbName = $"test_{Guid.NewGuid():N}"; - var connection = new Microsoft.Data.Sqlite.SqliteConnection($"DataSource={dbName};Mode=Memory;Cache=Shared"); + var connectionString = $"DataSource={dbName};Mode=Memory;Cache=Shared;Pooling=False"; + var connection = new Microsoft.Data.Sqlite.SqliteConnection(connectionString); await connection.OpenAsync(); - var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + var options = new DbContextOptionsBuilder().UseSqlite(connectionString).Options; await using (var db = new AppDbContext(options)) await db.Database.EnsureCreatedAsync(); @@ -708,7 +709,7 @@ public async Task Order_NoThreshold_ReturnsFlatSpins() { db.WheelSpinTriggers.Add(new WheelSpinTrigger { - EventType = SubathonEventType.UwUMarketOrder, IsEnabled = true, + EventType = SubathonEventType.GoAffProOrder, IsEnabled = true, SpinsToAdd = 4, CountThreshold = null, MoneyThreshold = null }); await db.SaveChangesAsync(TestContext.Current.CancellationToken); @@ -720,7 +721,7 @@ public async Task Order_NoThreshold_ReturnsFlatSpins() SubathonEvents.RaiseSubathonEventProcessed(new SubathonEvent { - Id = Guid.NewGuid(), EventType = SubathonEventType.UwUMarketOrder, + Id = Guid.NewGuid(), EventType = SubathonEventType.GoAffProOrder, EventTypeMeta = "132230", Command = SubathonCommandType.None, Amount = 99, Value = "100.00", Currency = "USD" }, true); @@ -740,7 +741,7 @@ public async Task Order_WithCountThreshold_ComputesItemMultiplier() { db.WheelSpinTriggers.Add(new WheelSpinTrigger { - EventType = SubathonEventType.UwUMarketOrder, IsEnabled = true, + EventType = SubathonEventType.GoAffProOrder, TierValue = "132230", IsEnabled = true, SpinsToAdd = 1, CountThreshold = 3 }); await db.SaveChangesAsync(TestContext.Current.CancellationToken); @@ -752,7 +753,7 @@ public async Task Order_WithCountThreshold_ComputesItemMultiplier() SubathonEvents.RaiseSubathonEventProcessed(new SubathonEvent { - Id = Guid.NewGuid(), EventType = SubathonEventType.UwUMarketOrder, + Id = Guid.NewGuid(), EventType = SubathonEventType.GoAffProOrder, EventTypeMeta = "132230", Command = SubathonCommandType.None, Amount = 9 }, true); // 9/3=3 * 1 = 3 @@ -772,7 +773,7 @@ public async Task Order_WithMoneyThreshold_ComputesCurrencyMultiplier() { db.WheelSpinTriggers.Add(new WheelSpinTrigger { - EventType = SubathonEventType.UwUMarketOrder, IsEnabled = true, + EventType = SubathonEventType.GoAffProOrder, TierValue = "132230", IsEnabled = true, SpinsToAdd = 1, MoneyThreshold = 10, Currency = "USD" }); await db.SaveChangesAsync(TestContext.Current.CancellationToken); @@ -784,7 +785,7 @@ public async Task Order_WithMoneyThreshold_ComputesCurrencyMultiplier() SubathonEvents.RaiseSubathonEventProcessed(new SubathonEvent { - Id = Guid.NewGuid(), EventType = SubathonEventType.UwUMarketOrder, + Id = Guid.NewGuid(), EventType = SubathonEventType.GoAffProOrder, EventTypeMeta = "132230", Command = SubathonCommandType.None, Value = "30.00", Currency = "USD" }, true); // (int)(30/10)=3 * 1 = 3 diff --git a/SubathonManager.Tests/SubathonManager.Tests.csproj b/SubathonManager.Tests/SubathonManager.Tests.csproj index 42256ef9..f45ed160 100644 --- a/SubathonManager.Tests/SubathonManager.Tests.csproj +++ b/SubathonManager.Tests/SubathonManager.Tests.csproj @@ -18,7 +18,7 @@ - + diff --git a/SubathonManager.UI/App.xaml.cs b/SubathonManager.UI/App.xaml.cs index 016cec68..bde2f565 100644 --- a/SubathonManager.UI/App.xaml.cs +++ b/SubathonManager.UI/App.xaml.cs @@ -11,6 +11,7 @@ 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; @@ -88,18 +89,13 @@ protected override void OnStartup(StartupEventArgs e) bool bitsAsDonationCheck = config.GetBool("Currency", "BitsLikeAsDonation", false); Utils.DonationSettings["BitsLikeAsDonation"] = bitsAsDonationCheck; - foreach (var goAffProSource in Enum.GetValues().Where(ga => ga != GoAffProSource.Unknown && !ga.IsDisabled())) - { - Utils.DonationSettings[$"{goAffProSource}"] = - config.GetBool("GoAffPro", $"{goAffProSource}.CommissionAsDonation", false); - } - foreach (var orderSource in Enum.GetValues().Where(et => et.GetSource() is not SubathonEventSource.Throne + foreach (var orderSource in Enum.GetValues().Where(et => et.GetSource() is not (SubathonEventSource.Throne or SubathonEventSource.GoAffPro) && !et.IsDisabled() && ((SubathonEventType?)et).IsOrder())) { Utils.DonationSettings[$"{orderSource.ToString()?.Split("Order")[0]}"] = config.GetBool($"{orderSource.GetSource()}", $"{orderSource.ToString()?.Split("Order")[0]}.CommissionAsDonation", true); } - + _currencyVal = config.Get("Currency", "Primary", "USD")!; SetStartupTheme(config); @@ -113,21 +109,43 @@ protected override void OnStartup(StartupEventArgs e) using (var db = _factory.CreateDbContext()) { db.Database.Migrate(); AppDbContext.SeedDefaultValues(db); - - // var stores = db.GoAffProStores.ToList(); - // GoAffProStoreRegistry.Initialize(stores); + + var stores = db.GoAffProStores.ToList(); + GoAffProStoreRegistry.Initialize(stores); } - - // GoAffProStoreRegistry.StoreDiscovered += async store => - // { - // using var scope = AppServices.Provider.CreateScope(); - // var db2 = scope.ServiceProvider.GetRequiredService(); - // db2.GoAffProStores.Add(store); - // await db2.SaveChangesAsync(); - // var cfg = AppServices.Provider.GetRequiredService(); - // Utils.DonationSettings[store.InternalName] = - // cfg.GetBool("GoAffPro", $"{store.InternalName}.CommissionAsDonation", false); - // }; + + GoAffProConfigMigration.Run(config); + foreach (var store in GoAffProStoreRegistry.All()) + { + Utils.DonationSettings[store.InternalName] = + config.GetBool("GoAffPro", $"{store.InternalName}.CommissionAsDonation", false); + } + + // new stores detected on the user's GoAffPro account get persisted & seeded on the fly + GoAffProStoreRegistry.StoreDiscovered += store => + { + Task.Run(async () => + { + try + { + await using var db2 = await _factory!.CreateDbContextAsync(); + if (!db2.GoAffProStores.Any(s => s.SiteId == store.SiteId)) + db2.GoAffProStores.Add(store); + var meta = store.SiteId.ToString(); + if (!db2.SubathonValues.Any(sv => sv.EventType == SubathonEventType.GoAffProOrder && sv.Meta == meta)) + db2.SubathonValues.Add(new SubathonValue + { EventType = SubathonEventType.GoAffProOrder, Seconds = 12, Meta = meta }); + await db2.SaveChangesAsync(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to persist discovered GoAffPro store {SiteId}", store.SiteId); + } + var cfg = AppServices.Provider.GetRequiredService(); + Utils.DonationSettings[store.InternalName] = + cfg.GetBool("GoAffPro", $"{store.InternalName}.CommissionAsDonation", false); + }); + }; base.OnStartup(e); @@ -254,12 +272,14 @@ private static ProtocolMessageType ParseProtocolRequest(StartupEventArgs e) } else if (uri.Host == "oauth") { - var provider = uri.AbsolutePath.TrimStart('/'); // "twitch" or "fourthwall" + var provider = uri.AbsolutePath.TrimStart('/'); var query = System.Web.HttpUtility.ParseQueryString(uri.Query); var accessToken = query["access_token"] ?? ""; var code = query["code"] ?? ""; var refreshToken = query["refresh_token"] ?? ""; var error = query["error"] ?? ""; + var expiresIn = query["expires_in"] ?? ""; + var clientId = query["client_id"] ?? ""; type = ProtocolMessageType.OAuth; Utils.PendingOAuthCallback = new OAuthCallback @@ -268,7 +288,9 @@ private static ProtocolMessageType ParseProtocolRequest(StartupEventArgs e) AccessToken = accessToken, RefreshToken = refreshToken, Code = code, - Error = error + Error = error, + ExpiresIn = expiresIn, + ClientId = clientId }; } } @@ -410,15 +432,15 @@ private void ConfigChanged(object sender, FileSystemEventArgs e) Utils.DonationSettings["BitsLikeAsDonation"] = bitsAsDonationCheck; } - foreach (var goAffProSource in Enum.GetValues().Where(ga => ga != GoAffProSource.Unknown && !ga.IsDisabled())) + foreach (var store in GoAffProStoreRegistry.All()) { - bool asDonation = config.GetBool("GoAffPro", $"{goAffProSource}.CommissionAsDonation", false); - if (Utils.DonationSettings.TryGetValue($"{goAffProSource}", out bool hasVal) && hasVal == asDonation) continue; + bool asDonation = config.GetBool("GoAffPro", $"{store.InternalName}.CommissionAsDonation", false); + if (Utils.DonationSettings.TryGetValue(store.InternalName, out bool hasVal) && hasVal == asDonation) continue; optionToggled = true; - Utils.DonationSettings[$"{goAffProSource}"] = asDonation; + Utils.DonationSettings[store.InternalName] = asDonation; } - foreach (var orderSource in Enum.GetValues().Where(et => et.GetSource() is not SubathonEventSource.Throne + foreach (var orderSource in Enum.GetValues().Where(et => et.GetSource() is not (SubathonEventSource.Throne or SubathonEventSource.GoAffPro) && !et.IsDisabled() && ((SubathonEventType?)et).IsOrder())) { bool asDonation = config.GetBool($"{orderSource.GetSource()}", $"{orderSource.ToString()?.Split("Order")[0]}.CommissionAsDonation", true); diff --git a/SubathonManager.UI/Converters/Converters.cs b/SubathonManager.UI/Converters/Converters.cs index a1392759..28874db5 100644 --- a/SubathonManager.UI/Converters/Converters.cs +++ b/SubathonManager.UI/Converters/Converters.cs @@ -177,7 +177,7 @@ public class EventTypeValueConverter : IMultiValueConverter { if (eventType == SubathonEventType.TwitchRaid) type = "viewer"; - else if (curr == "item" && eventType.GetSource() == SubathonEventSource.Throne) + else if (curr == "item" && eventType.GetSource() is SubathonEventSource.Throne or SubathonEventSource.TreatStream) type = ""; else type = curr; diff --git a/SubathonManager.UI/EditRouteWindow.Handlers.cs b/SubathonManager.UI/EditRouteWindow.Handlers.cs index bbd97ba2..d43246f8 100644 --- a/SubathonManager.UI/EditRouteWindow.Handlers.cs +++ b/SubathonManager.UI/EditRouteWindow.Handlers.cs @@ -977,22 +977,73 @@ void BoxUnchecked(object o, RoutedEventArgs routedEventArgs) AttachChangeHandler(sender, e); } - private void JsEventTypeSelectBox_Loaded(object sender, RoutedEventArgs e) + private void JsEventTypePickerBtn_Loaded(object sender, RoutedEventArgs e) { - if (sender is not ComboBox { Tag: JsVariable jsVar } cb) return; + if (sender is not System.Windows.Controls.Button { Tag: JsVariable jsVar } btn) return; + SetJsEventTypeButtonLabel(btn, jsVar.Value); + } + + private void JsEventTypePickerBtn_Click(object sender, RoutedEventArgs e) + { + if (sender is not System.Windows.Controls.Button { Tag: JsVariable jsVar } btn) return; + + var current = jsVar.Value ?? string.Empty; + var entries = new List(); + var values = Enum.GetValues() .Where(x => x.IsEnabled()) .Where(x => ((SubathonEventType?)x).HasNoValueConfig()) - .Select(x => x).OrderBy(x => x.GetOrderNumber()); - cb.Items.Add(string.Empty); - foreach (var val in values) cb.Items.Add(val.ToString()); - cb.SelectedValue = string.IsNullOrWhiteSpace(jsVar.Value) ? string.Empty : jsVar.Value; - cb.Dispatcher.BeginInvoke(DispatcherPriority.Loaded, () => + .OrderBy(x => x.GetOrderNumber()); + + foreach (var val in values) { - cb.SelectionChanged += (_, __) => jsVar.Value = $"{cb.SelectedValue}"; - }); - - AttachChangeHandler(sender, e); + if (val == SubathonEventType.GoAffProOrder) + { + foreach (var store in GoAffProStoreRegistry.All().Where(s => s.Enabled)) + { + var capturedStore = store; + entries.Add(new UiUtils.EventTypeMenuEntry( + val.GetSource(), + store.EventName, + current == store.InternalEventName, + () => SetJsEventTypeValue(btn, jsVar, capturedStore.InternalEventName))); + } + continue; + } + + var captured = val; + entries.Add(new UiUtils.EventTypeMenuEntry( + val.GetSource(), + val.GetLabel(), + current == captured.ToString(), + () => SetJsEventTypeValue(btn, jsVar, captured.ToString()))); + } + + UiUtils.EventTypeMenu.Show(btn, entries, groupBySourceType: false, + clearLabel: "- none -", onClear: () => SetJsEventTypeValue(btn, jsVar, string.Empty)); + } + + private void SetJsEventTypeValue(System.Windows.Controls.Button btn, JsVariable jsVar, string value) + { + jsVar.Value = value; + SetJsEventTypeButtonLabel(btn, value); + Value_OnChanged(btn, new RoutedEventArgs()); + } + + private static void SetJsEventTypeButtonLabel(System.Windows.Controls.Button btn, string? value) + { + if (btn.Content is not Grid grid) return; + var label = grid.Children.OfType().FirstOrDefault(); + if (label != null) label.Text = JsEventTypeDisplay(value); + } + + private static string JsEventTypeDisplay(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return "- none -"; + if (Enum.TryParse(value, out var et)) + return $"{et.GetSource()} - {et.GetLabel()}"; + var store = GoAffProStoreRegistry.All().FirstOrDefault(s => s.InternalEventName == value); + return store != null ? $"{SubathonEventSource.GoAffPro} - {store.EventName}" : value; } private void JsEventSubTypeSelectBox_Loaded(object sender, RoutedEventArgs e) @@ -1117,14 +1168,14 @@ private void JsEventTypeList_Loaded(object sender, RoutedEventArgs e) Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(0, 2, 0, 2), IsExpanded = false, Header = group.Key.ToString() }; - + var chkboxList = new StackPanel { Orientation = Orientation.Vertical }; - foreach (var eType in group.Select(x => x).OrderBy(x => x.GetOrderNumber())) + foreach (var (label, key) in BuildEventTypeEntries(group)) { var chkBox = new CheckBox { - Content = new Wpf.Ui.Controls.TextBlock { Text = ((SubathonEventType?)eType).GetLabel(), Tag=eType, TextWrapping = TextWrapping.Wrap, MaxWidth = 240 }, - IsChecked = panelValues.Contains(eType.ToString()), + Content = new Wpf.Ui.Controls.TextBlock { Text = label, Tag=key, TextWrapping = TextWrapping.Wrap, MaxWidth = 240 }, + IsChecked = panelValues.Contains(key), Margin = new Thickness(2) }; chkBox.Checked += (_, __) => UpdateEventListValues(jsVar, outerPanel); @@ -1138,6 +1189,20 @@ private void JsEventTypeList_Loaded(object sender, RoutedEventArgs e) expander.Content = outerPanel; } + private static IEnumerable<(string Label, string Key)> BuildEventTypeEntries(IEnumerable group) + { + foreach (var eType in group.OrderBy(x => x.GetOrderNumber())) + { + if (eType == SubathonEventType.GoAffProOrder) + { + foreach (var store in GoAffProStoreRegistry.All().Where(s => s.Enabled)) + yield return (store.EventName, store.InternalEventName); + continue; + } + yield return (((SubathonEventType?)eType).GetLabel(), eType.ToString()); + } + } + private void JsEventSubTypeList_Loaded(object sender, RoutedEventArgs e) { if (sender is not Expander { Tag: JsVariable jsVar } expander) return; @@ -1240,12 +1305,12 @@ private void JsFilteredEventTypeList_Loaded(object sender, RoutedEventArgs e) }; var chkboxList = new StackPanel { Orientation = Orientation.Vertical }; - foreach (var eType in group.Select(x => x).OrderBy(x => x.GetOrderNumber())) + foreach (var (label, key) in BuildEventTypeEntries(group)) { var chkBox = new CheckBox { - Content = new Wpf.Ui.Controls.TextBlock { Text = ((SubathonEventType?)eType).GetLabel(), Tag=eType, TextWrapping = TextWrapping.Wrap, MaxWidth = 240 }, - IsChecked = panelValues.Contains(eType.ToString()), + Content = new Wpf.Ui.Controls.TextBlock { Text = label, Tag=key, TextWrapping = TextWrapping.Wrap, MaxWidth = 240 }, + IsChecked = panelValues.Contains(key), Margin = new Thickness(2) }; chkBox.Checked += (_, __) => UpdateEventListValues(jsVar, outerPanel); diff --git a/SubathonManager.UI/EditRouteWindow.xaml b/SubathonManager.UI/EditRouteWindow.xaml index cafaf27f..934201ec 100644 --- a/SubathonManager.UI/EditRouteWindow.xaml +++ b/SubathonManager.UI/EditRouteWindow.xaml @@ -614,9 +614,30 @@ - + diff --git a/SubathonManager.UI/EditRouteWindow.xaml.cs b/SubathonManager.UI/EditRouteWindow.xaml.cs index f4be9a9f..da1aef8c 100644 --- a/SubathonManager.UI/EditRouteWindow.xaml.cs +++ b/SubathonManager.UI/EditRouteWindow.xaml.cs @@ -610,7 +610,7 @@ private void UpdateSaveButtonBorder(Border border, bool hasPendingChanges) private void OnObsConnectionUpdated(IntegrationConnection? connection) { - if (connection is not { Source: SubathonEventSource.OBS }) return; + if (connection is not { Source: SubathonEventSource.OBS, Service: "OBS" }) return; Dispatcher.Invoke(() => ObsConnected = connection.Status); } diff --git a/SubathonManager.UI/MainWindow.ConnectionStatus.cs b/SubathonManager.UI/MainWindow.ConnectionStatus.cs new file mode 100644 index 00000000..c3280216 --- /dev/null +++ b/SubathonManager.UI/MainWindow.ConnectionStatus.cs @@ -0,0 +1,335 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using System.Windows.Shapes; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Objects; + +namespace SubathonManager.UI +{ + public partial class MainWindow + { + private static readonly Brush StatusUpBrush = new SolidColorBrush(Color.FromRgb(0x3F, 0xB9, 0x50)); + private static readonly Brush StatusDownBrush = new SolidColorBrush(Color.FromRgb(0xE8, 0x4A, 0x4A)); + private static readonly Brush StatusMixedBrush = new SolidColorBrush(Color.FromRgb(0xE8, 0xA3, 0x3D)); + private static readonly Brush StatusNoneBrush = new SolidColorBrush(Color.FromRgb(0x80, 0x80, 0x80)); + + private void InitConnectionStatus() + { + IntegrationEvents.ConnectionUpdated += OnConnectionStatusChanged; + Closed += (_, _) => IntegrationEvents.ConnectionUpdated -= OnConnectionStatusChanged; + UpdateConnectionStatusDot(); + } + + private ContextMenu? _statusMenu; + + private void OnConnectionStatusChanged(IntegrationConnection _) + => Dispatcher.BeginInvoke(() => + { + UpdateConnectionStatusDot(); + RefreshOpenConnectionStatusMenu(); + }); + + private void UpdateConnectionStatusDot() + { + var counted = Utils.GetAllConnections().Where(c => !IsAggregateExempt(c)).ToList(); + ConnectionStatusDot.Fill = GetAggregateBrush(counted); + int up = counted.Count(c => c.Status); + ConnectionStatusBtn.ToolTip = counted.Count == 0 + ? "Connection Status" + : $"Connection Status: {up}/{counted.Count} connected"; + } + + private static bool IsAggregateExempt(IntegrationConnection c) => + !c.Status && (!c.Configured + || c is { Source: SubathonEventSource.KoFi, Service: "Socket" } + or { Source: SubathonEventSource.OBS, Service: "HelperScript" }); + + private static Brush LeafBrush(IntegrationConnection? c) => + c is not { Status: true } + ? c is { Configured: true } ? StatusDownBrush : StatusNoneBrush + : StatusUpBrush; + + private static Brush GetAggregateBrush(IEnumerable connections) + { + var counted = connections.Where(c => !IsAggregateExempt(c)).ToList(); + if (counted.Count == 0) return StatusNoneBrush; + int up = counted.Count(c => c.Status); + if (up == counted.Count) return StatusUpBrush; + return up == 0 ? StatusDownBrush : StatusMixedBrush; + } + + private void ConnectionStatusBtn_Click(object sender, RoutedEventArgs e) + { + var menu = new ContextMenu + { + PlacementTarget = ConnectionStatusBtn, + Placement = PlacementMode.Bottom + }; + FillConnectionStatusMenu(menu); + menu.Closed += (_, _) => + { + if (ReferenceEquals(_statusMenu, menu)) _statusMenu = null; + }; + _statusMenu = menu; + menu.IsOpen = true; + } + + private void RefreshOpenConnectionStatusMenu() + { + if (_statusMenu is not { IsOpen: true } menu) return; + var fresh = new ContextMenu(); + FillConnectionStatusMenu(fresh); + SyncMenuItems(menu.Items, fresh.Items); + } + + private static void SyncMenuItems(ItemCollection target, ItemCollection source) + { + if (target.Count != source.Count) + { + var items = source.Cast().ToList(); + source.Clear(); + target.Clear(); + foreach (var item in items) target.Add(item); + return; + } + + for (int i = 0; i < target.Count; i++) + { + if (target[i] is not MenuItem tgt || source[i] is not MenuItem src) + continue; + tgt.Header = src.Header; + tgt.ToolTip = src.ToolTip; + tgt.IsEnabled = src.IsEnabled; + if (src.Icon is Ellipse dot) tgt.Icon = MakeStatusDot(dot.Fill); + SyncMenuItems(tgt.Items, src.Items); + } + } + + private void FillConnectionStatusMenu(ContextMenu menu) + { + menu.Items.Clear(); + var connections = Utils.GetAllConnections().ToList(); + if (connections.Count == 0) + { + menu.Items.Add(new MenuItem { Header = "No integrations active", IsEnabled = false }); + return; + } + + var bySource = connections + .GroupBy(c => GetEffectiveSource(c.Source)) + .OrderBy(g => SubathonEventSourceHelper.GetSourceOrder(g.Key)) + .ToList(); + + var byGroup = bySource + .GroupBy(g => g.Key.GetGroup()) + .OrderBy(gg => gg.Min(s => SubathonEventSourceHelper.GetSourceOrder(s.Key))); + + foreach (var group in byGroup) + { + var groupItem = new MenuItem + { + Header = group.First().Key.GetGroupLabel(), + Icon = MakeStatusDot(GetAggregateBrush(group.SelectMany(s => s))), + StaysOpenOnClick = true + }; + foreach (var source in group) + groupItem.Items.Add(BuildSourceItem(source.Key, source.ToList())); + menu.Items.Add(groupItem); + } + } + + private MenuItem BuildSourceItem(SubathonEventSource source, List connections) + { + switch (source) + { + case SubathonEventSource.KoFi: return BuildKoFiItem(connections); + case SubathonEventSource.PallyGG: return BuildPallyItem(connections); + case SubathonEventSource.DevTunnels: return BuildDevTunnelsItem(connections); + case SubathonEventSource.OBS: return BuildObsItem(connections); + } + + var sourceGroup = source.GetGroup(); + if (sourceGroup is SubathonSourceGroup.StreamExtension or SubathonSourceGroup.ExternalSoftware + || source is SubathonEventSource.FourthWall or SubathonEventSource.Throne) + { + bool up = connections.Any(c => c.Status); + Brush brush = up ? StatusUpBrush + : sourceGroup == SubathonSourceGroup.ExternalSoftware || connections.Any(c => c.Configured) + ? StatusDownBrush + : StatusNoneBrush; + return MakeLeafItem(source.GetDescription(), brush, null, source); + } + + var sourceItem = new MenuItem + { + Header = source.GetDescription(), + Icon = MakeStatusDot(GetAggregateBrush(connections)), + StaysOpenOnClick = true + }; + var ordered = connections + .OrderBy(c => c.Service == c.Source.ToString() ? 0 : 1) + .ThenBy(c => c.Service, StringComparer.OrdinalIgnoreCase); + foreach (var connection in ordered) + { + string label = connection.Service == connection.Source.ToString() ? "Account" : connection.Service; + string? navDetail = null; + if (connection.Source == SubathonEventSource.GoAffPro + && GoAffProStoreRegistry.TryGetByInternalName(connection.Service, out var store)) + { + label = store.StoreName; + navDetail = store.InternalName; + } + string detail = ShowableDetail(connection); + if (detail.Length > 0) label += $": {detail}"; + sourceItem.Items.Add(MakeLeafItem(label, LeafBrush(connection), null, source, navDetail)); + } + return sourceItem; + } + + private MenuItem BuildKoFiItem(List connections) + { + var tunnel = connections.FirstOrDefault(c => c.Source == SubathonEventSource.KoFiTunnel); + var socket = connections.FirstOrDefault(c => c is { Source: SubathonEventSource.KoFi, Service: "Socket" }); + bool up = tunnel?.Status == true || socket?.Status == true; + + var item = new MenuItem + { + Header = SubathonEventSource.KoFi.GetDescription(), + Icon = MakeStatusDot(up ? StatusUpBrush : LeafBrush(tunnel)), + StaysOpenOnClick = true + }; + item.Items.Add(MakeLeafItem("Webhook Tunnel", LeafBrush(tunnel), null, SubathonEventSource.KoFi)); + + bool socketUp = socket?.Status == true; + var socketItem = MakeLeafItem("Socket (Legacy)", + socketUp ? StatusUpBrush : StatusNoneBrush, null, SubathonEventSource.KoFi); + socketItem.IsEnabled = socketUp; + item.Items.Add(socketItem); + return item; + } + + private MenuItem BuildPallyItem(List connections) + { + var socket = connections.FirstOrDefault(c => c.Service == "Socket") ?? connections.First(); + var brush = LeafBrush(socket); + var item = new MenuItem + { + Header = SubathonEventSource.PallyGG.GetDescription(), + Icon = MakeStatusDot(brush), + StaysOpenOnClick = true + }; + string room = string.IsNullOrWhiteSpace(socket.Name) ? "All" : socket.Name; + item.Items.Add(MakeLeafItem($"Room: {room}", brush, null, SubathonEventSource.PallyGG)); + return item; + } + + private MenuItem BuildDevTunnelsItem(List connections) + { + var item = new MenuItem + { + Header = SubathonEventSource.DevTunnels.GetDescription(), + Icon = MakeStatusDot(GetAggregateBrush(connections)), + StaysOpenOnClick = true + }; + + var cli = connections.FirstOrDefault(c => c.Service == "Cli"); + var login = connections.FirstOrDefault(c => c.Service == "Login"); + var tunnel = connections.FirstOrDefault(c => c.Service == "Tunnel"); + + if (cli != null) + { + string label = cli.Status && !string.IsNullOrWhiteSpace(cli.Name) ? $"CLI: v{cli.Name}" : "CLI"; + item.Items.Add(MakeLeafItem(label, LeafBrush(cli), + cli.Status ? null : "Not Installed", SubathonEventSource.DevTunnels)); + } + if (login != null) + { + string label = login.Status && !string.IsNullOrWhiteSpace(login.Detail) + ? $"Login: {login.Detail}" + : "Login"; + item.Items.Add(MakeLeafItem(label, LeafBrush(login), null, SubathonEventSource.DevTunnels)); + } + if (tunnel != null) + item.Items.Add(MakeLeafItem("Tunnel", LeafBrush(tunnel), null, SubathonEventSource.DevTunnels)); + return item; + } + + private MenuItem BuildObsItem(List connections) + { + var webSocket = connections.FirstOrDefault(c => c.Service == "OBS"); + var helper = connections.FirstOrDefault(c => c.Service == "HelperScript"); + + var item = new MenuItem + { + Header = SubathonEventSource.OBS.GetDescription(), + Icon = MakeStatusDot(GetAggregateBrush(connections)), + StaysOpenOnClick = true + }; + bool wsUp = webSocket?.Status == true; + item.Items.Add(MakeLeafItem("WebSocket", + wsUp ? StatusUpBrush : StatusDownBrush, null, SubathonEventSource.OBS)); + + bool helperUp = helper?.Status == true; + string helperLabel = helperUp && !string.IsNullOrWhiteSpace(helper!.Detail) + ? $"Helper Script: {helper.Detail}" + : "Helper Script"; + item.Items.Add(MakeLeafItem(helperLabel, + helperUp ? StatusUpBrush : StatusNoneBrush, + helperUp ? null : "Not detected. Add via OBS Tools -> Scripts", SubathonEventSource.OBS)); + return item; + } + + private static string ShowableDetail(IntegrationConnection connection) + { + if (string.IsNullOrWhiteSpace(connection.Name)) return ""; + if (string.Equals(connection.Name, "None", StringComparison.OrdinalIgnoreCase)) return ""; + if (connection.Name.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return ""; + return Truncate(connection.Name, 36); + } + + private static SubathonEventSource GetEffectiveSource(SubathonEventSource source) + { + var trueSource = EnumMetaCache.Get(source)?.TrueSource + ?? SubathonEventSource.Unknown; + return trueSource == SubathonEventSource.Unknown ? source : trueSource; + } + + private MenuItem MakeLeafItem(string header, Brush brush, string? toolTip, + SubathonEventSource? navigateTo = null, string? navigateDetail = null) + { + var item = new MenuItem + { + Header = header, + Icon = MakeStatusDot(brush), + ToolTip = toolTip, + StaysOpenOnClick = navigateTo == null + }; + if (navigateTo is { } source) + item.Click += (_, _) => NavigateToSourceSettings(source, navigateDetail); + return item; + } + + private void NavigateToSourceSettings(SubathonEventSource source, string? detail = null) + { + if (_statusMenu != null) _statusMenu.IsOpen = false; + MainWindowTabs.SelectedItem = SettingsTabItem; + SettingsEvents.RaiseHotLinkToSourceRequest(source, detail); + } + + private static Ellipse MakeStatusDot(Brush brush) => new() + { + Width = 9, + Height = 9, + Fill = brush, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + + private static string Truncate(string value, int max) + => value.Length <= max ? value : value[..(max - 1)] + "..."; + } +} diff --git a/SubathonManager.UI/MainWindow.Obs.cs b/SubathonManager.UI/MainWindow.Obs.cs index f06563d1..c91bfdd8 100644 --- a/SubathonManager.UI/MainWindow.Obs.cs +++ b/SubathonManager.UI/MainWindow.Obs.cs @@ -35,7 +35,7 @@ private void InitObsIntegration() private void OnObsConnectionUpdated(IntegrationConnection? connection) { - if (connection is not { Source: SubathonEventSource.OBS }) return; + if (connection is not { Source: SubathonEventSource.OBS, Service: "OBS" }) return; Dispatcher.Invoke(() => ObsConnected = connection.Status); } diff --git a/SubathonManager.UI/MainWindow.Routes.cs b/SubathonManager.UI/MainWindow.Routes.cs index b77387db..781566a4 100644 --- a/SubathonManager.UI/MainWindow.Routes.cs +++ b/SubathonManager.UI/MainWindow.Routes.cs @@ -170,7 +170,7 @@ private void EditRoute_Click(object sender, RoutedEventArgs e) } } - private void OpenRouteEditor(Route route) + internal void OpenRouteEditor(Route route) { if (_editWindow != null) { diff --git a/SubathonManager.UI/MainWindow.xaml b/SubathonManager.UI/MainWindow.xaml index cc6d0b52..f87da07b 100644 --- a/SubathonManager.UI/MainWindow.xaml +++ b/SubathonManager.UI/MainWindow.xaml @@ -106,6 +106,19 @@ + + + @@ -584,7 +597,12 @@ - + + + + + + Create and manage your overlays in the list to the left. @@ -616,6 +634,11 @@ + + @@ -635,7 +658,7 @@ - + diff --git a/SubathonManager.UI/MainWindow.xaml.cs b/SubathonManager.UI/MainWindow.xaml.cs index e96d8b56..ff2cad4e 100644 --- a/SubathonManager.UI/MainWindow.xaml.cs +++ b/SubathonManager.UI/MainWindow.xaml.cs @@ -45,6 +45,7 @@ public MainWindow() IntegrationEvents.DevTunnelLegacyNotification += OnDevTunnelLegacyNotification; Task.Run(App.InitSubathonTimer); InitObsIntegration(); + InitConnectionStatus(); } protected override void OnSourceInitialized(EventArgs e) @@ -91,7 +92,9 @@ private IntPtr WndProc( Code = query["code"] ?? "", AccessToken = query["access_token"] ?? "", RefreshToken = query["refresh_token"] ?? "", - Error = query["error"] ?? "" + Error = query["error"] ?? "", + ExpiresIn = query["expires_in"] ?? "", + ClientId = query["client_id"] ?? "" }; handled = true; return IntPtr.Zero; diff --git a/SubathonManager.UI/Services/ServiceManager.cs b/SubathonManager.UI/Services/ServiceManager.cs index 52f3d25f..870a8e89 100644 --- a/SubathonManager.UI/Services/ServiceManager.cs +++ b/SubathonManager.UI/Services/ServiceManager.cs @@ -41,12 +41,14 @@ public async Task StartIntegrationsAsync() await StartAsync(); await StartAsync(); await StartAsync(); + await StartAsync(); await StartAsync();//(fireAndForget: true); await StartAsync(); await StartAsync(); // shared tunnel infrastructure; must start before webhook integrations await StartAsync(); await StartAsync(); await StartAsync(); + await StartAsync(); await StartAsync(); } @@ -59,10 +61,12 @@ public async Task StopIntegrationsAsync() await StopAsync(); await StopAsync(); await StopAsync(); + await StopAsync(); await StopAsync(); await StopAsync(); await StopAsync(); await StopAsync(); + await StopAsync(); await StopAsync(); await StopAsync(); await StopAsync(); @@ -145,6 +149,7 @@ public async Task StopAsync(CancellationToken ct = default) where T : IAppSer public static TangiaService Tangia => Provider.GetRequiredService(); public static FourthWallService FourthWall => Provider.GetRequiredService(); public static ThroneService Throne => Provider.GetRequiredService(); + public static PallyService Pally => Provider.GetRequiredService(); public static DevTunnelsService DevTunnels => Provider.GetRequiredService(); public static KoFiService KoFi => Provider.GetRequiredService(); public static TwitchService Twitch => Provider.GetRequiredService(); @@ -153,6 +158,7 @@ public async Task StopAsync(CancellationToken ct = default) where T : IAppSer public static StreamElementsService StreamElements => Provider.GetRequiredService(); public static StreamLabsService StreamLabs => Provider.GetRequiredService(); public static TipeeeStreamService TipeeeStream => Provider.GetRequiredService(); + public static TreatStreamService TreatStream => Provider.GetRequiredService(); public static OBSService OBS => Provider.GetRequiredService(); public static WebServer Server => Provider.GetRequiredService(); diff --git a/SubathonManager.UI/Services/ServiceRegistration.cs b/SubathonManager.UI/Services/ServiceRegistration.cs index 5f9e9338..10878ea0 100644 --- a/SubathonManager.UI/Services/ServiceRegistration.cs +++ b/SubathonManager.UI/Services/ServiceRegistration.cs @@ -37,7 +37,11 @@ public static void SetupCoreServices(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + + if (OperatingSystem.IsWindows()) + services.AddSingleton(); + else + services.AddSingleton(); } public static void AddIntegrations(this IServiceCollection services) @@ -78,12 +82,15 @@ public static void AddIntegrations(this IServiceCollection services) services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); // Stream Extensions // services.AddHttpClient(nameof(TipeeeStreamService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan); services.AddSingleton(); services.AddHttpClient(nameof(TangiaService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan); services.AddSingleton(); + services.AddHttpClient(nameof(TreatStreamService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan); + services.AddSingleton(); // Other // services.AddHttpClient(nameof(DiscordWebhookService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan);; diff --git a/SubathonManager.UI/SubathonManager.UI.csproj b/SubathonManager.UI/SubathonManager.UI.csproj index fbf1d8b1..e4fc3fc2 100644 --- a/SubathonManager.UI/SubathonManager.UI.csproj +++ b/SubathonManager.UI/SubathonManager.UI.csproj @@ -47,7 +47,7 @@ - + diff --git a/SubathonManager.UI/UiUtils/EventTypeMenu.cs b/SubathonManager.UI/UiUtils/EventTypeMenu.cs new file mode 100644 index 00000000..6a79c86d --- /dev/null +++ b/SubathonManager.UI/UiUtils/EventTypeMenu.cs @@ -0,0 +1,189 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using SubathonManager.Core.Enums; + +namespace SubathonManager.UI.UiUtils; + +public sealed record EventTypeMenuEntry( + SubathonEventSource Source, + string Label, + bool IsSelected, + Action OnSelected); + +public static class EventTypeMenu +{ + public static void Show(FrameworkElement placementTarget, IReadOnlyList entries, + bool groupBySourceType = true, string? clearLabel = null, Action? onClear = null) + { + var menu = new ContextMenu + { + PlacementTarget = placementTarget, + Placement = PlacementMode.Bottom, + MinWidth = placementTarget.ActualWidth + }; + + var searchBox = new Wpf.Ui.Controls.TextBox + { + PlaceholderText = "Search...", + ClearButtonEnabled = false, + MinWidth = 170, + Height = 34, + FontSize = 12, + VerticalContentAlignment = VerticalAlignment.Center + }; + var searchItem = new MenuItem + { + Header = searchBox, + StaysOpenOnClick = true, + Focusable = false + }; + menu.Items.Add(searchItem); + + int fixedItemCount = 1; + if (onClear != null) + { + var clearItem = new MenuItem + { + Header = new TextBlock { Text = clearLabel ?? "(none)", FontStyle = FontStyles.Italic } + }; + clearItem.Click += (_, _) => onClear(); + menu.Items.Add(clearItem); + fixedItemCount = 2; + } + + var nestedItems = BuildNested(entries, groupBySourceType); + foreach (var item in nestedItems) menu.Items.Add(item); + + List currentMatches = []; + + searchBox.TextChanged += (_, _) => + { + while (menu.Items.Count > fixedItemCount) menu.Items.RemoveAt(fixedItemCount); + var query = searchBox.Text.Trim(); + + if (query.Length == 0) + { + currentMatches.Clear(); + foreach (var item in nestedItems) menu.Items.Add(item); + return; + } + + currentMatches = entries.Where(en => Matches(en, query, groupBySourceType)).ToList(); + foreach (var entry in currentMatches) + { + var flat = new MenuItem + { + Header = $"{entry.Source} - {entry.Label}", + IsChecked = entry.IsSelected + }; + var captured = entry; + flat.Click += (_, _) => captured.OnSelected(); + menu.Items.Add(flat); + } + }; + + searchBox.KeyDown += (_, e) => + { + if (e.Key != Key.Enter || currentMatches.Count == 0) return; + e.Handled = true; + menu.IsOpen = false; + currentMatches[0].OnSelected(); + }; + + menu.Opened += (_, _) => searchBox.Focus(); + menu.IsOpen = true; + } + + private static bool Matches(EventTypeMenuEntry entry, string query, bool groupBySourceType) + { + var haystack = groupBySourceType + ? $"{entry.Source.GetGroup().GetLabel()} {entry.Source} {entry.Label}" + : $"{entry.Source} {entry.Label}"; + return haystack.Contains(query, StringComparison.OrdinalIgnoreCase); + } + + private static List BuildNested(IReadOnlyList entries, bool groupBySourceType) + { + if (!groupBySourceType) + { + var sourceItems = new List(); + foreach (var sourceGroup in entries.GroupBy(en => en.Source) + .OrderBy(g => SubathonEventSourceHelper.GetSourceOrder(g.Key))) + { + var sourceItem = BuildSourceItem(sourceGroup, out _); + AttachHoverExpand(sourceItem, () => sourceItems); + sourceItems.Add(sourceItem); + } + return sourceItems; + } + + var groupItems = new List(); + + var groups = entries + .GroupBy(en => en.Source.GetGroup()) + .OrderBy(g => g.Min(en => SubathonEventSourceHelper.GetSourceOrder(en.Source))); + + foreach (var group in groups) + { + var groupItem = new MenuItem(); + bool groupHasSelection = false; + + foreach (var sourceGroup in group.GroupBy(en => en.Source) + .OrderBy(g => SubathonEventSourceHelper.GetSourceOrder(g.Key))) + { + var sourceItem = BuildSourceItem(sourceGroup, out bool sourceHasSelection); + groupHasSelection |= sourceHasSelection; + AttachHoverExpand(sourceItem, () => groupItem.Items.OfType()); + groupItem.Items.Add(sourceItem); + } + + groupItem.Header = MakeHeader(group.Key.GetLabel(), groupHasSelection); + AttachHoverExpand(groupItem, () => groupItems); + groupItems.Add(groupItem); + } + + return groupItems; + } + + private static MenuItem BuildSourceItem(IGrouping sourceGroup, + out bool hasSelection) + { + var sourceItem = new MenuItem(); + hasSelection = false; + + foreach (var entry in sourceGroup) + { + var leaf = new MenuItem { Header = entry.Label, IsChecked = entry.IsSelected }; + hasSelection |= entry.IsSelected; + var captured = entry; + leaf.Click += (_, _) => captured.OnSelected(); + sourceItem.Items.Add(leaf); + } + + sourceItem.Header = MakeHeader(sourceGroup.Key.ToString(), hasSelection); + return sourceItem; + } + + private static TextBlock MakeHeader(string text, bool hasSelection) + { + var header = new TextBlock + { + Text = text, + FontWeight = hasSelection ? FontWeights.SemiBold : FontWeights.Normal + }; + if (hasSelection) header.Foreground = Brushes.CornflowerBlue; + return header; + } + + private static void AttachHoverExpand(MenuItem item, Func> siblings) + { + item.MouseEnter += (_, _) => + { + foreach (var sibling in siblings()) + sibling.IsSubmenuOpen = ReferenceEquals(sibling, item); + }; + } +} diff --git a/SubathonManager.UI/Views/EventListView.xaml b/SubathonManager.UI/Views/EventListView.xaml index 72e481d8..e4a7da8c 100644 --- a/SubathonManager.UI/Views/EventListView.xaml +++ b/SubathonManager.UI/Views/EventListView.xaml @@ -169,6 +169,13 @@ + + + + + + + diff --git a/SubathonManager.UI/Views/ExportOverlayDialog.xaml b/SubathonManager.UI/Views/ExportOverlayDialog.xaml index 5e53100e..fa1cbb79 100644 --- a/SubathonManager.UI/Views/ExportOverlayDialog.xaml +++ b/SubathonManager.UI/Views/ExportOverlayDialog.xaml @@ -92,7 +92,7 @@ diff --git a/SubathonManager.UI/Views/ExportOverlayDialog.xaml.cs b/SubathonManager.UI/Views/ExportOverlayDialog.xaml.cs index 7fb5f4a1..2319d2c7 100644 --- a/SubathonManager.UI/Views/ExportOverlayDialog.xaml.cs +++ b/SubathonManager.UI/Views/ExportOverlayDialog.xaml.cs @@ -288,7 +288,7 @@ private async void ConfirmButton_Click(object sender, RoutedEventArgs e) ConfirmButton.IsEnabled = false; CancelButton.IsEnabled = false; - ConfirmButton.Content = "Exporting…"; + ConfirmButton.Content = "Exporting..."; try { @@ -316,7 +316,7 @@ private async void ConfirmButton_Click(object sender, RoutedEventArgs e) { ConfirmButton.IsEnabled = true; CancelButton.IsEnabled = true; - ConfirmButton.Content = "Export…"; + ConfirmButton.Content = "Export..."; } } diff --git a/SubathonManager.UI/Views/GoalsEditor.xaml b/SubathonManager.UI/Views/GoalsEditor.xaml index cdbf0700..027a3536 100644 --- a/SubathonManager.UI/Views/GoalsEditor.xaml +++ b/SubathonManager.UI/Views/GoalsEditor.xaml @@ -26,7 +26,7 @@ BorderThickness="0" FontSize="18" Background="Transparent" VerticalAlignment="Center" - PlaceholderText="Set name…" FontWeight="Bold" Margin="2" + PlaceholderText="Set name..." FontWeight="Bold" Margin="2" HorizontalAlignment="Left" ClearButtonEnabled="False" LostFocus="GoalSetNameBox_LostFocus"/> + + + + + + + + + + + + + + + + + + + + + diff --git a/SubathonManager.UI/Views/ObsBrowserSourcesPanel.xaml.cs b/SubathonManager.UI/Views/ObsBrowserSourcesPanel.xaml.cs new file mode 100644 index 00000000..205e8874 --- /dev/null +++ b/SubathonManager.UI/Views/ObsBrowserSourcesPanel.xaml.cs @@ -0,0 +1,389 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Objects; +using SubathonManager.Data; +using SubathonManager.Integration; +using SubathonManager.UI.Services; + +namespace SubathonManager.UI.Views; + +public partial class ObsBrowserSourcesPanel : UserControl +{ + private bool _refreshing; + private bool _refreshQueued; + private bool? _lastScriptStatus; + private List _lastCards = []; + + public ObsBrowserSourcesPanel() + { + InitializeComponent(); + + Loaded += (_, _) => + { + IntegrationEvents.ConnectionUpdated -= OnConnectionUpdated; + IntegrationEvents.ConnectionUpdated += OnConnectionUpdated; + ServiceManager.OBS.HelperScriptStatusChanged -= OnHelperScriptStatusChanged; + ServiceManager.OBS.HelperScriptStatusChanged += OnHelperScriptStatusChanged; + ServiceManager.OBS.BrowserSourcesChanged -= OnBrowserSourcesChanged; + ServiceManager.OBS.BrowserSourcesChanged += OnBrowserSourcesChanged; + _ = RefreshAsync(); + }; + Unloaded += (_, _) => + { + IntegrationEvents.ConnectionUpdated -= OnConnectionUpdated; + ServiceManager.OBS.HelperScriptStatusChanged -= OnHelperScriptStatusChanged; + ServiceManager.OBS.BrowserSourcesChanged -= OnBrowserSourcesChanged; + }; + } + + private void UpdatePanelStatusText(int count, bool scriptActive) + { + PanelStatusText.Text = count == 0 + ? "No overlay browser sources found" + : scriptActive + ? $"{count} source(s)" + : $"{count} source(s) - helper script not loaded, SRGB control unavailable"; + } + + private void OnBrowserSourcesChanged() + { + Dispatcher.InvokeAsync(async () => await RefreshAsync()); + } + + private void OnHelperScriptStatusChanged(bool active) + { + if (_lastScriptStatus == active) return; + Dispatcher.InvokeAsync(async () => await RefreshAsync()); + } + + private void OnConnectionUpdated(IntegrationConnection? connection) + { + if (connection is not { Source: SubathonEventSource.OBS, Service: "OBS" }) return; + Dispatcher.InvokeAsync(async () => await RefreshAsync()); + } + + private void Refresh_Click(object sender, RoutedEventArgs e) + { + _ = RefreshAsync(); + } + + private async Task RefreshAsync() + { + if (_refreshing) + { + _refreshQueued = true; + return; + } + _refreshing = true; + try + { + if (!ServiceManager.OBS.Connected) + { + CardsStack.Children.Clear(); + _lastCards = []; + _lastScriptStatus = null; + PanelStatusText.Text = "OBS not connected"; + return; + } + + PanelStatusText.Text = "Loading..."; + + if (!ServiceManager.OBS.HelperScriptActive) + ServiceManager.OBS.RecheckHelperScript(); + + var config = AppServices.Provider.GetRequiredService(); + var port = config.Get("Server", "Port", "14040") ?? "14040"; + + var cards = await Task.Run(() => ServiceManager.OBS.GetOverlayBrowserSourcesAsync(port)); + + Dictionary routeNames; + var factory = AppServices.Provider.GetRequiredService>(); + await using (var db = await factory.CreateDbContextAsync()) + { + routeNames = await db.Routes.ToDictionaryAsync(r => r.Id, r => r.Name); + } + + bool scriptActive = ServiceManager.OBS.HelperScriptActive; + var ordered = cards.OrderBy(c => c.SceneName).ThenBy(c => c.SourceName).ToList(); + + if (scriptActive == _lastScriptStatus && ordered.SequenceEqual(_lastCards)) + { + UpdatePanelStatusText(ordered.Count, scriptActive); + return; + } + _lastScriptStatus = scriptActive; + _lastCards = ordered; + + CardsStack.Children.Clear(); + foreach (var card in ordered) + { + string? overlayName = null; + bool unknownOverlay = false; + if (card.RouteId.HasValue) + { + if (routeNames.TryGetValue(card.RouteId.Value, out var name)) overlayName = name; + else unknownOverlay = true; + } + CardsStack.Children.Add(BuildCard(card, overlayName, unknownOverlay)); + } + + UpdatePanelStatusText(ordered.Count, scriptActive); + } + catch (Exception) + { + PanelStatusText.Text = "Failed to query OBS"; + } + finally + { + _refreshing = false; + } + + if (_refreshQueued) + { + _refreshQueued = false; + await RefreshAsync(); + } + } + + private UIElement BuildCard(ObsBrowserSourceCard card, string? overlayName, bool unknownOverlay) + { + var border = new Border + { + BorderBrush = (Brush)new BrushConverter().ConvertFromString("#333")!, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(6), + Padding = new Thickness(10, 8, 12, 8), + Margin = new Thickness(0, 0, 12, 8) + }; + + var grid = new Grid(); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + var titleGrid = new Grid(); + titleGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + titleGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var title = new TextBlock + { + Text = card.SourceName, + TextTrimming = TextTrimming.CharacterEllipsis, + FontSize = 13, + FontWeight = FontWeights.SemiBold, + ToolTip = card.Url + }; + titleGrid.Children.Add(title); + + var overlayLabel = new TextBlock + { + FontSize = 11, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(8, 0, 0, 0) + }; + if (unknownOverlay) + { + overlayLabel.Text = "Unknown Overlay"; + overlayLabel.Foreground = Brushes.Orange; + overlayLabel.ToolTip = "URL points at an overlay that no longer exists in SubathonManager"; + } + else if (!string.IsNullOrEmpty(overlayName)) + { + overlayLabel.Text = overlayName; + overlayLabel.Foreground = Brushes.CornflowerBlue; + } + + Grid.SetColumn(overlayLabel, 1); + titleGrid.Children.Add(overlayLabel); + grid.Children.Add(titleGrid); + + var sceneText = new TextBlock + { + FontSize = 11, + Margin = new Thickness(0, 1, 0, 6), + Foreground = (Brush)FindResource("TextFillColorSecondaryBrush"), + TextTrimming = TextTrimming.CharacterEllipsis + }; + sceneText.Inlines.Add(new System.Windows.Documents.Run("Scene: ")); + sceneText.Inlines.Add(new System.Windows.Documents.Run(card.ScenePath) + { + Foreground = Brushes.CornflowerBlue + }); + Grid.SetRow(sceneText, 1); + grid.Children.Add(sceneText); + + var controlsRow = new Grid(); + controlsRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + controlsRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + Grid.SetRow(controlsRow, 2); + + var controls = new StackPanel { Orientation = Orientation.Horizontal }; + controlsRow.Children.Add(controls); + + var widthBox = new Wpf.Ui.Controls.TextBox + { + Text = card.Width.ToString(), + Width = 62, + Height = 30, + FontSize = 12, + ClearButtonEnabled = false, + ToolTip = "Width" + }; + var heightBox = new Wpf.Ui.Controls.TextBox + { + Text = card.Height.ToString(), + Width = 62, + Height = 30, + FontSize = 12, + ClearButtonEnabled = false, + Margin = new Thickness(4, 0, 0, 0), + ToolTip = "Height" + }; + var applySizeBtn = new Wpf.Ui.Controls.Button + { + Content = "Set", + Height = 30, + Padding = new Thickness(10, 2, 10, 2), + Margin = new Thickness(6, 0, 0, 0), + ToolTip = "Apply resolution (not transform) to the browser source" + }; + applySizeBtn.Click += (_, _) => + { + if (!int.TryParse(widthBox.Text.Trim(), out int w) || w < 1 || + !int.TryParse(heightBox.Text.Trim(), out int h) || h < 1) return; + ServiceManager.OBS.SetBrowserSourceSize(card.SourceName, w, h); + }; + + controls.Children.Add(widthBox); + controls.Children.Add(new TextBlock + { + Text = "x", + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(4, 0, 0, 0) + }); + controls.Children.Add(heightBox); + controls.Children.Add(applySizeBtn); + + bool scriptActive = ServiceManager.OBS.HelperScriptActive; + var srgbCheck = new CheckBox + { + Content = "SRGB Off", + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(16, 0, 0, 0), + IsChecked = card.SrgbOff, + IsThreeState = false, + IsEnabled = scriptActive, + ToolTip = scriptActive + ? "Fixes dull/grey transparent or glow visuals when checked" + : "Fixes dull/grey transparent or glow visuals when checked. Requires the SubathonManager helper script to be loaded in OBS (see Settings -> External Software -> OBS)" + }; + srgbCheck.Checked += async (_, _) => + await ServiceManager.OBS.RequestBlendMethodAsync(card.SourceName, card.SceneName, card.SceneItemId, true); + srgbCheck.Unchecked += async (_, _) => + await ServiceManager.OBS.RequestBlendMethodAsync(card.SourceName, card.SceneName, card.SceneItemId, false); + controls.Children.Add(srgbCheck); + + var visibilityBtn = MakeIconButton( + card.Visible ? Wpf.Ui.Controls.SymbolRegular.Eye24 : Wpf.Ui.Controls.SymbolRegular.EyeOff24, + card.Visible ? "Visible" : "Hidden", + leftMargin: 16); + visibilityBtn.Tag = card.Visible; + visibilityBtn.Click += (_, _) => + { + bool newVisible = !(bool)visibilityBtn.Tag; + ServiceManager.OBS.SetSceneItemVisible(card.SceneName, card.SceneItemId, newVisible); + visibilityBtn.Tag = newVisible; + ((Wpf.Ui.Controls.SymbolIcon)visibilityBtn.Content).Symbol = + newVisible ? Wpf.Ui.Controls.SymbolRegular.Eye24 : Wpf.Ui.Controls.SymbolRegular.EyeOff24; + visibilityBtn.ToolTip = newVisible ? "Visible" : "Hidden"; + }; + controls.Children.Add(visibilityBtn); + + var rightBtns = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right + }; + Grid.SetColumn(rightBtns, 1); + + if (!string.IsNullOrEmpty(overlayName) && card.RouteId.HasValue) + { + var editBtn = MakeIconButton(Wpf.Ui.Controls.SymbolRegular.Edit24, + "Edit Overlay", leftMargin: 12); + editBtn.Click += async (_, _) => await OpenOverlayEditorAsync(card.RouteId.Value); + rightBtns.Children.Add(editBtn); + } + + var refreshBtn = MakeIconButton(Wpf.Ui.Controls.SymbolRegular.ArrowClockwise24, + "Refresh source inside OBS", leftMargin: 6); + refreshBtn.Click += (_, _) => ServiceManager.OBS.RefreshBrowserSource(card.SourceName); + rightBtns.Children.Add(refreshBtn); + + var deleteBtn = MakeIconButton(Wpf.Ui.Controls.SymbolRegular.Delete24, + "Remove browser source from OBS", leftMargin: 6, danger: true); + deleteBtn.Click += async (_, _) => await ConfirmAndDeleteAsync(card); + rightBtns.Children.Add(deleteBtn); + + controlsRow.Children.Add(rightBtns); + + grid.Children.Add(controlsRow); + border.Child = grid; + return border; + } + + private Wpf.Ui.Controls.Button MakeIconButton(Wpf.Ui.Controls.SymbolRegular symbol, string tooltip, + double leftMargin, bool danger = false) + { + var btn = new Wpf.Ui.Controls.Button + { + Width = 32, + Height = 30, + Padding = new Thickness(2), + Margin = new Thickness(leftMargin, 0, 0, 0), + ToolTip = tooltip + }; + if (danger) + btn.Appearance = Wpf.Ui.Controls.ControlAppearance.Danger; + else if (TryFindResource("OpaqueSecondaryButton") is Style style) + btn.Style = style; + btn.SetResourceReference(ForegroundProperty, "TextFillColorPrimaryBrush"); + btn.Content = new Wpf.Ui.Controls.SymbolIcon { Symbol = symbol, Width = 16, Height = 16 }; + return btn; + } + + private async Task OpenOverlayEditorAsync(Guid routeId) + { + var factory = AppServices.Provider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + var route = await db.Routes.FirstOrDefaultAsync(r => r.Id == routeId); + if (route == null) return; + + (Window.GetWindow(this) as MainWindow)?.OpenRouteEditor(route); + } + + private async Task ConfirmAndDeleteAsync(ObsBrowserSourceCard card) + { + var msgBox = new Wpf.Ui.Controls.MessageBox + { + Title = "Delete Browser Source", + Content = $"Delete source '{card.SourceName}' from OBS?\n", + PrimaryButtonText = "Delete", + PrimaryButtonAppearance = Wpf.Ui.Controls.ControlAppearance.Danger, + CloseButtonText = "Cancel", + Owner = Window.GetWindow(this), + WindowStartupLocation = WindowStartupLocation.CenterOwner + }; + + if (await msgBox.ShowDialogAsync() != Wpf.Ui.Controls.MessageBoxResult.Primary) return; + + ServiceManager.OBS.RemoveBrowserSource(card.SourceName); + } +} diff --git a/SubathonManager.UI/Views/Prompts/PromptsEditor.xaml b/SubathonManager.UI/Views/Prompts/PromptsEditor.xaml index 14e5762e..5f84fe26 100644 --- a/SubathonManager.UI/Views/Prompts/PromptsEditor.xaml +++ b/SubathonManager.UI/Views/Prompts/PromptsEditor.xaml @@ -30,7 +30,7 @@ - @@ -205,71 +205,33 @@ - - - - - - - - - - - - - - - + diff --git a/SubathonManager.UI/Views/Prompts/PromptsEditor.xaml.cs b/SubathonManager.UI/Views/Prompts/PromptsEditor.xaml.cs index 157bd2f4..2ce87061 100644 --- a/SubathonManager.UI/Views/Prompts/PromptsEditor.xaml.cs +++ b/SubathonManager.UI/Views/Prompts/PromptsEditor.xaml.cs @@ -4,7 +4,6 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Input; -using System.Windows.Threading; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SubathonManager.Core; @@ -23,31 +22,24 @@ public partial class PromptsEditor private SubathonPrompt? _selectedPrompt; private int _suppressCount = 0; private SubathonEventType? _selectedFilterEventType; + private string? _selectedFilterEventMeta; private Guid? _activeRunPromptId; - private record EventTypeEntry(SubathonEventType EventType, string Label); + private record EventTypeEntry(SubathonEventType EventType, string Label, string? Meta = null); private Dictionary> _eventsBySource = new(); - private readonly DispatcherTimer _popupCloseTimer; - public PromptsEditor() { _factory = AppServices.Provider.GetRequiredService>(); InitializeComponent(); - _popupCloseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120) }; - _popupCloseTimer.Tick += (_, _) => - { - _popupCloseTimer.Stop(); - EventTypePopup.IsOpen = false; - EventListPanel.Visibility = Visibility.Collapsed; - }; - BuildEventsBySource(); PopulateTypeComboBox(); - PopulateSourceListBox(); LoadActiveSet(); + GoAffProStoreRegistry.StoreDiscovered -= OnGoAffProStoreDiscovered; + GoAffProStoreRegistry.StoreDiscovered += OnGoAffProStoreDiscovered; + SubathonEvents.PromptRunStarted += (run, _) => Dispatcher.InvokeAsync(() => OnRunStateChanged(run.PromptId, true)); SubathonEvents.PromptRunUpdate += (run, _) => Dispatcher.InvokeAsync(() => { @@ -59,6 +51,11 @@ public PromptsEditor() Loaded += (_, _) => Dispatcher.Invoke(AttachSetChangeHandlers); } + private void OnGoAffProStoreDiscovered(GoAffProStore store) + { + Dispatcher.InvokeAsync(BuildEventsBySource); + } + private void BuildEventsBySource() { _eventsBySource = Enum.GetValues() @@ -68,107 +65,48 @@ private void BuildEventsBySource() .ToDictionary( g => g.Key, g => g.OrderBy(e => e.GetOrderNumber()) - .Select(e => new EventTypeEntry(e, e.GetLabel())) + .SelectMany(BuildEventEntries) .ToList() ); } - private void PopulateSourceListBox() - { - SourceListBox.Items.Clear(); - foreach (var source in _eventsBySource.Keys) - { - var item = new ListBoxItem - { - Content = source.ToString(), - Tag = source, - Padding = new Thickness(10, 6, 10, 6) - }; - item.MouseEnter += SourceItem_MouseEnter; - SourceListBox.Items.Add(item); - } - } - - private void SourceItem_MouseEnter(object sender, MouseEventArgs e) + private static IEnumerable BuildEventEntries(SubathonEventType e) { - _popupCloseTimer.Stop(); - - if (sender is not ListBoxItem item || item.Tag is not SubathonEventSource source) return; - - SourceListBox.SelectedItem = item; - PopulateEventList(source); - EventListPanel.Visibility = Visibility.Visible; - } - - private void PopulateEventList(SubathonEventSource source) - { - EventListBox.Items.Clear(); - if (!_eventsBySource.TryGetValue(source, out var events)) return; - - foreach (var entry in events) + if (e == SubathonEventType.GoAffProOrder) { - EventListBox.Items.Add(new ListBoxItem - { - Content = entry.Label, - Tag = entry.EventType, - Padding = new Thickness(10, 6, 10, 6) - }); + return GoAffProStoreRegistry.All().Where(s => s.Enabled) + .Select(s => new EventTypeEntry(e, s.EventName, s.SiteId.ToString())); } - } - - private void EventListBox_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - if (EventListBox.SelectedItem is not ListBoxItem item) return; - if (item.Tag is not SubathonEventType eventType) return; - - _popupCloseTimer.Stop(); - EventTypePopup.IsOpen = false; - EventListPanel.Visibility = Visibility.Collapsed; - OnEventTypeSelected(eventType); - } - - private void EventTypePopupBorder_MouseEnter(object sender, MouseEventArgs e) - { - _popupCloseTimer.Stop(); - } - - private void EventTypePopupBorder_MouseLeave(object sender, MouseEventArgs e) - { - _popupCloseTimer.Start(); + return [new EventTypeEntry(e, e.GetLabel())]; } private void EventTypePickerBtn_Click(object sender, RoutedEventArgs e) { - if (EventTypePopup.IsOpen) - { - _popupCloseTimer.Stop(); - EventTypePopup.IsOpen = false; - EventListPanel.Visibility = Visibility.Collapsed; - return; - } - - EventListPanel.Visibility = Visibility.Collapsed; + var entries = new List(); - if (_selectedFilterEventType.HasValue) + foreach (var (source, events) in _eventsBySource) { - var source = _selectedFilterEventType.Value.GetSource(); - var sourceItem = SourceListBox.Items.OfType() - .FirstOrDefault(i => i.Tag is SubathonEventSource s && s == source); - if (sourceItem != null) + foreach (var entry in events) { - SourceListBox.SelectedItem = sourceItem; - PopulateEventList(source); - EventListPanel.Visibility = Visibility.Visible; + var captured = entry; + entries.Add(new UiUtils.EventTypeMenuEntry( + source, + entry.Label, + _selectedFilterEventType == entry.EventType && + entry.Meta == _selectedFilterEventMeta, + () => OnEventTypeSelected(captured))); } } - EventTypePopup.IsOpen = true; + UiUtils.EventTypeMenu.Show(EventTypePickerBtn, entries, groupBySourceType: true); } - private void OnEventTypeSelected(SubathonEventType eventType) + private void OnEventTypeSelected(EventTypeEntry entry) { + var eventType = entry.EventType; _selectedFilterEventType = eventType; - EventTypePickerLabel.Text = $"{eventType.GetSource()} - {eventType.GetLabel()}"; + _selectedFilterEventMeta = entry.Meta; + EventTypePickerLabel.Text = $"{eventType.GetSource()} - {entry.Label}"; if (_suppressCount > 0) return; @@ -535,7 +473,9 @@ private Grid BuildPromptRow(SubathonPrompt prompt) Grid.SetColumn(textLabel, 1); string typeBadgeText = prompt is { Type: SubathonPromptType.Event, FilterEventType: not null } - ? $"Evt:{prompt.FilterEventType.Value.GetLabel()}" + ? prompt.FilterEventType == SubathonEventType.GoAffProOrder + ? $"Evt:{GoAffProOrderHelper.GetOrderEventDisplayLabel(prompt.FilterEventType, prompt.FilterMeta)}" + : $"Evt:{prompt.FilterEventType.Value.GetLabel()}" : prompt.Type.DisplayName(); var typeBadge = new TextBlock { @@ -653,8 +593,13 @@ private void SelectPrompt(SubathonPrompt prompt, Grid? clickedRow = null) EventTypePanel.Visibility = isEvent ? Visibility.Visible : Visibility.Collapsed; _selectedFilterEventType = isEvent ? prompt.FilterEventType : null; + _selectedFilterEventMeta = isEvent && prompt.FilterEventType == SubathonEventType.GoAffProOrder + ? prompt.FilterMeta + : null; EventTypePickerLabel.Text = _selectedFilterEventType.HasValue - ? $"{_selectedFilterEventType.Value.GetSource()} - {_selectedFilterEventType.Value.GetLabel()}" + ? _selectedFilterEventType.Value == SubathonEventType.GoAffProOrder + ? $"{_selectedFilterEventType.Value.GetSource()} - {GoAffProOrderHelper.GetOrderEventDisplayLabel(_selectedFilterEventType, _selectedFilterEventMeta)}" + : $"{_selectedFilterEventType.Value.GetSource()} - {_selectedFilterEventType.Value.GetLabel()}" : "- select -"; RefreshSubTypeComboBox(prompt.Type, prompt.FilterEventType); @@ -722,6 +667,7 @@ private void PromptType_SelectionChanged(object sender, SelectionChangedEventArg SuppressChanges(() => { _selectedFilterEventType = null; + _selectedFilterEventMeta = null; EventTypePickerLabel.Text = "- select -"; }); } @@ -864,9 +810,12 @@ private void WriteDetailToPrompt(SubathonPrompt prompt) { prompt.FilterEventType = _selectedFilterEventType; prompt.FilterSubType = prompt.FilterEventType?.GetSubType(); - prompt.FilterMeta = prompt.SubType == SubathonPromptSubType.ByTier - ? (PromptTierBox.SelectedItem as ComboBoxItem)?.Tag as string - : null; + if (prompt.FilterEventType == SubathonEventType.GoAffProOrder) + prompt.FilterMeta = _selectedFilterEventMeta; // site id is meta + else + prompt.FilterMeta = prompt.SubType == SubathonPromptSubType.ByTier + ? (PromptTierBox.SelectedItem as ComboBoxItem)?.Tag as string + : null; } else { @@ -1054,15 +1003,29 @@ private async void ImportPromptSet_Click(object sender, RoutedEventArgs e) } SubathonEventType? filterEvent = null; + string? goAffProMeta = null; var eventStr = cols.Length > 8 ? cols[8].Trim() : ""; if (!string.IsNullOrEmpty(eventStr)) { - if (!Enum.TryParse(eventStr, out var fet)) - { await ShowInvalidPromptCsvPopup(); return; } - filterEvent = fet; + if (Enum.TryParse(eventStr, out var fet)) + { + if (fet.GetLegacyGoAffProSiteId() > 0) + { + goAffProMeta = fet.GetLegacyGoAffProSiteId().ToString(); + fet = SubathonEventType.GoAffProOrder; + } + filterEvent = fet; + } + else if (GoAffProOrderHelper.TryGetStoreByOrderKey(eventStr, out var keyStore)) + { + filterEvent = SubathonEventType.GoAffProOrder; + goAffProMeta = keyStore.SiteId.ToString(); + } + else { await ShowInvalidPromptCsvPopup(); return; } } string? filterMeta = cols.Length > 9 && !string.IsNullOrWhiteSpace(cols[9]) ? cols[9].Trim() : null; + if (goAffProMeta != null) filterMeta = goAffProMeta; prompts.Add(new SubathonPrompt { diff --git a/SubathonManager.UI/Views/SettingsView.Obs.cs b/SubathonManager.UI/Views/SettingsView.Obs.cs deleted file mode 100644 index 778e57b4..00000000 --- a/SubathonManager.UI/Views/SettingsView.Obs.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Windows; -using System.Windows.Input; -using SubathonManager.Core.Enums; -using SubathonManager.Core.Events; -using SubathonManager.Core.Objects; -using SubathonManager.UI.Services; - -namespace SubathonManager.UI.Views; - -public partial class SettingsView -{ - internal void InitOBSSettings() - { - var (host, port, pw) = ServiceManager.OBS.GetConfig(); - SuppressUnsavedChanges(() => - { - ObsHostBox.Text = host; - ObsPortBox.Text = port; - ObsPasswordBox.Password = pw; - }); - - IntegrationEvents.ConnectionUpdated += OnObsConnectionUpdated; - UpdateObsStatus(ServiceManager.OBS.Connected); - } - - internal void UnloadOBSSettings() - { - IntegrationEvents.ConnectionUpdated -= OnObsConnectionUpdated; - } - - private void OnObsConnectionUpdated(IntegrationConnection? connection) - { - if (connection is not { Source: SubathonEventSource.OBS }) return; - UpdateObsStatus(connection.Status); - } - - private void UpdateObsStatus(bool connected) - { - Dispatcher.Invoke(() => - { - ObsStatusText.Text = connected ? "Connected" : "Disconnected"; - ObsConnectBtn.Content = connected ? "Reconnect" : "Connect"; - }); - } - - private void ObsConnect_Click(object sender, RoutedEventArgs e) - { - var host = ObsHostBox.Text.Trim(); - var port = ObsPortBox.Text.Trim(); - var password = ObsPasswordBox.Password.Trim(); - - if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(port)) return; - - ServiceManager.OBS.SaveConfig(host, port, password, true); - - if (ServiceManager.OBS.Connected) - ServiceManager.OBS.StopAsync(); - - ServiceManager.OBS.TryConnect(); - } - - private void NumberOnly_PreviewTextInput(object sender, TextCompositionEventArgs e) - { - e.Handled = !int.TryParse(e.Text, out _); - } - -} \ No newline at end of file diff --git a/SubathonManager.UI/Views/SettingsView.xaml b/SubathonManager.UI/Views/SettingsView.xaml index c5bccd1d..53e7ed83 100644 --- a/SubathonManager.UI/Views/SettingsView.xaml +++ b/SubathonManager.UI/Views/SettingsView.xaml @@ -176,33 +176,8 @@ - - - - - - - - - - - - - - - - - - - - - + + diff --git a/SubathonManager.UI/Views/SettingsView.xaml.cs b/SubathonManager.UI/Views/SettingsView.xaml.cs index c4cd654c..f37209b9 100644 --- a/SubathonManager.UI/Views/SettingsView.xaml.cs +++ b/SubathonManager.UI/Views/SettingsView.xaml.cs @@ -7,11 +7,13 @@ using Microsoft.Extensions.Logging; using SubathonManager.Core.Events; using SubathonManager.Core; +using SubathonManager.Core.Enums; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; using SubathonManager.Core.Objects; using SubathonManager.Data; using SubathonManager.UI.Services; +using SubathonManager.UI.Views.SettingsViews; namespace SubathonManager.UI.Views; @@ -35,7 +37,6 @@ public SettingsView() UpdateServerStatus(ServiceManager.Server?.Running ?? false); SubathonEvents.SubathonValueConfigUpdatedRemote += RefreshSubathonValues; SettingsEvents.SettingsUnsavedChanges += UpdateSaveButtonBorder; - InitOBSSettings(); RegisterUnsavedChangeHandlers(); InitCurrencySelects(); }; @@ -45,6 +46,10 @@ public SettingsView() ExternalServiceSettingsControl.Init(this); CommandsSettingsControl.Init(this); ExtensionSettingsControl.Init(this); + ExternalSoftwareSettingsControl.Init(this); + + SettingsEvents.HotLinkToSourceRequested -= HotLinkToSource; + SettingsEvents.HotLinkToSourceRequested += HotLinkToSource; ServerPortTextBox.Text = config.Get("Server", "Port", string.Empty) ?? string.Empty; LoadValues(); @@ -55,12 +60,32 @@ public SettingsView() //SubathonEvents.SubathonDataUpdate -= UpdateTimerValue; WebServerEvents.WebServerStatusChanged -= UpdateServerStatus; SubathonEvents.SubathonValueConfigUpdatedRemote -= RefreshSubathonValues; - UnloadOBSSettings(); }; Task.Run(CheckForUpdateOnBoot); } + private void HotLinkToSource(SubathonEventSource source, string? detail) + { + Dispatcher.BeginInvoke(() => + { + SettingsGroupControl? group = source.GetGroup() switch + { + SubathonSourceGroup.Stream => StreamingSettingsControl, + SubathonSourceGroup.StreamExtension => ExtensionSettingsControl, + SubathonSourceGroup.ExternalService => ExternalServiceSettingsControl, + SubathonSourceGroup.ExternalSoftware => ExternalSoftwareSettingsControl, + _ => null + }; + if (group == null) return; + group.TryHotLinkToSource(source); + if (!string.IsNullOrWhiteSpace(detail) + && group.GetControlForSource(source) is SettingsViews.External.GoAffProSettings goAffPro) + goAffPro.TrySelectStore(detail); + group.BringIntoView(); + }); + } + private async void CheckForUpdateOnBoot() { try diff --git a/SubathonManager.UI/Views/SettingsViews/ExtensionSettings.xaml.cs b/SubathonManager.UI/Views/SettingsViews/ExtensionSettings.xaml.cs index 12fea020..74aa6cb3 100644 --- a/SubathonManager.UI/Views/SettingsViews/ExtensionSettings.xaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/ExtensionSettings.xaml.cs @@ -36,6 +36,9 @@ public ExtensionSettings() case SubathonEventSource.TipeeeStream: _settingsControls[eventSource] = new TipeeeStreamSettings(); break; + case SubathonEventSource.TreatStream: + _settingsControls[eventSource] = new TreatStreamSettings(); + break; case SubathonEventSource.Tangia: _settingsControls[eventSource] = new TangiaSettings(); break; diff --git a/SubathonManager.UI/Views/SettingsViews/Extensions/TipeeeStreamSettings.xaml b/SubathonManager.UI/Views/SettingsViews/Extensions/TipeeeStreamSettings.xaml index 5c21575e..9fddb15e 100644 --- a/SubathonManager.UI/Views/SettingsViews/Extensions/TipeeeStreamSettings.xaml +++ b/SubathonManager.UI/Views/SettingsViews/Extensions/TipeeeStreamSettings.xaml @@ -20,7 +20,7 @@ Content="Disconnect" Width="120" Height="35" - Margin="4,5,0,10" + Margin="6,5,0,10" Visibility="Collapsed" Click="Disconnect_Click"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SubathonManager.UI/Views/SettingsViews/Extensions/TreatStreamSettings.xaml.cs b/SubathonManager.UI/Views/SettingsViews/Extensions/TreatStreamSettings.xaml.cs new file mode 100644 index 00000000..39730298 --- /dev/null +++ b/SubathonManager.UI/Views/SettingsViews/Extensions/TreatStreamSettings.xaml.cs @@ -0,0 +1,113 @@ +using System.Windows; +using System.Windows.Controls; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; +using SubathonManager.Data; +using SubathonManager.Integration; +using SubathonManager.UI.Services; + +namespace SubathonManager.UI.Views.SettingsViews.Extensions; + +public partial class TreatStreamSettings : SettingsControl +{ + private readonly ILogger? _logger = AppServices.Provider.GetRequiredService>(); + + public TreatStreamSettings() + { + InitializeComponent(); + Loaded += (_, _) => + { + IntegrationEvents.ConnectionUpdated += UpdateStatus; + RegisterUnsavedChangeHandlers(); + UpdateStatus(Utils.GetConnection(SubathonEventSource.TreatStream, "Socket")); + }; + Unloaded += (_, _) => + { + IntegrationEvents.ConnectionUpdated -= UpdateStatus; + }; + } + + public override void Init(SettingsView host) + { + Host = host; + UpdateStatus(Utils.GetConnection(SubathonEventSource.TreatStream, "Socket")); + } + + internal override void UpdateStatus(IntegrationConnection? connection) + { + if (connection is not { Source: SubathonEventSource.TreatStream, Service: "Socket" }) return; + Host.UpdateConnectionStatus(connection.Status, TreatStatusText, null); + Dispatcher.Invoke(() => + { + DisconnectTreatBtn.Visibility = connection.Status ? Visibility.Visible : Visibility.Collapsed; + ConnectTreatBtn.Content = connection.Status ? "Reconnect" : "Connect"; + }); + } + + private async void ConnectTreatStream_Click(object sender, RoutedEventArgs e) + { + try + { + await ServiceManager.TreatStream.ConnectAsync(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to connect TreatStream Service"); + } + } + + private async void DisconnectTreatStream_Click(object sender, RoutedEventArgs e) + { + try + { + await ServiceManager.TreatStream.StopAsync(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to disconnect TreatStream Service"); + } + } + + private void TestTreat_Click(object sender, RoutedEventArgs e) + { + TreatStreamService.SimulateTreat(SimulateTreatTitleBox.Text); + } + + public override bool UpdateValueSettings(AppDbContext db) + { + bool hasUpdated = false; + var treatValue = db.SubathonValues.FirstOrDefault(sv => + sv.EventType == SubathonEventType.TreatStreamOrder && sv.Meta == ""); + if (treatValue != null && double.TryParse(TreatBox.Text, out var seconds) + && !seconds.Equals(treatValue.Seconds)) + { + treatValue.Seconds = seconds; + hasUpdated = true; + } + if (treatValue != null && double.TryParse(TreatBox2.Text, out var points) + && !points.Equals(treatValue.Points)) + { + treatValue.Points = points; + hasUpdated = true; + } + return hasUpdated; + } + + public override void UpdateCurrencyBoxes(List currencies, string selected) { } + + public override (string, string, TextBox?, TextBox?) GetValueBoxes(SubathonValue val) + { + string v = $"{val.Seconds}"; + string p = $"{val.Points}"; + return val.EventType switch + { + SubathonEventType.TreatStreamOrder => (v, p, TreatBox, TreatBox2), + _ => (v, p, null, null) + }; + } +} diff --git a/SubathonManager.UI/Views/SettingsViews/External/DevTunnelSettingsControl.cs b/SubathonManager.UI/Views/SettingsViews/External/DevTunnelSettingsControl.cs index 6ad003c0..a1969550 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/DevTunnelSettingsControl.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/DevTunnelSettingsControl.cs @@ -83,8 +83,8 @@ private void ApplyWebhookUrl(string? url) private void ApplyTunnelBanner(bool running, string? nameOrHint) { - bool starting = nameOrHint == "(starting…)"; - _TunnelPrereqStatusText.Text = starting ? "Starting…" : (running ? "Running" : "Not running"); + bool starting = nameOrHint == "(starting...)"; + _TunnelPrereqStatusText.Text = starting ? "Starting..." : (running ? "Running" : "Not running"); _TunnelPrereqHint.Visibility = running ? Visibility.Collapsed : Visibility.Visible; // The URL row is driven by the sourceTunnel connection (Name field), not here. } diff --git a/SubathonManager.UI/Views/SettingsViews/External/DevTunnelsSettings.xaml.cs b/SubathonManager.UI/Views/SettingsViews/External/DevTunnelsSettings.xaml.cs index ff9e9fd6..dc19af57 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/DevTunnelsSettings.xaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/DevTunnelsSettings.xaml.cs @@ -136,9 +136,9 @@ private void ToggleUsername_Click(object sender, RoutedEventArgs e) private void ApplyTunnelState(bool running, string? url) { - if (url == "(starting…)") + if (url == "(starting...)") { - TunnelStatusText.Text = "Starting…"; + TunnelStatusText.Text = "Starting..."; StartTunnelBtn.IsEnabled = false; StopTunnelBtn.Visibility = Visibility.Collapsed; StartTunnelBtn.Visibility = Visibility.Visible; @@ -147,9 +147,9 @@ private void ApplyTunnelState(bool running, string? url) return; } - if (url == "(stopping…)") + if (url == "(stopping...)") { - TunnelStatusText.Text = "Stopping…"; + TunnelStatusText.Text = "Stopping..."; StopTunnelBtn.IsEnabled = false; StopTunnelBtn.Visibility = Visibility.Visible; StartTunnelBtn.Visibility = Visibility.Collapsed; @@ -186,7 +186,7 @@ private async void CheckCli_Click(object sender, RoutedEventArgs e) // ReSharper disable once NullableWarningSuppressionIsUsed brush = (Brush)Application.Current.Resources["TextFillColorPrimaryBrush"]!; CliStatusText.Foreground = brush; - CliStatusText.Text = "Checking…"; + CliStatusText.Text = "Checking..."; await ServiceManager.DevTunnels.RefreshCliStatusAsync(); } finally @@ -230,7 +230,7 @@ private async Task RunLoginAsync(LoginProvider provider) { LoginMicrosoftBtn.IsEnabled = false; LoginGithubBtn.IsEnabled = false; - LoginStatusText.Text = "Opening browser…"; + LoginStatusText.Text = "Opening browser..."; try { await ServiceManager.DevTunnels.LoginAsync(provider); diff --git a/SubathonManager.UI/Views/SettingsViews/External/GoAffPro/GoAffProSourceControl.xaml.cs b/SubathonManager.UI/Views/SettingsViews/External/GoAffPro/GoAffProSourceControl.xaml.cs index 363733e0..b95eaa01 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/GoAffPro/GoAffProSourceControl.xaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/GoAffPro/GoAffProSourceControl.xaml.cs @@ -1,4 +1,4 @@ -using System.Windows; +using System.Windows; using System.Windows.Controls; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -17,14 +17,16 @@ public partial class GoAffProSourceControl : SettingsControl { private readonly ILogger? _logger = AppServices.Provider.GetRequiredService>(); private readonly SettingsView _host; - public GoAffProSource Source { get; } + public GoAffProStore Store { get; } - public GoAffProSourceControl(SettingsView host, GoAffProSource source) + private string Meta => Store.SiteId.ToString(); + + public GoAffProSourceControl(SettingsView host, GoAffProStore store) { _host = host; - Source = source; + Store = store; InitializeComponent(); - + TotalSimBox.ToolTip = "Order Total $"; CommSimBox.ToolTip = "Commission Total $"; QuantitySimBox.ToolTip = "Items Ordered"; @@ -36,30 +38,27 @@ public void UpdateStatus(bool status, string currencyName) Dispatcher.Invoke(() => { _host.UpdateConnectionStatus(status, StatusText, null); - CurrencyText.Text = string.IsNullOrWhiteSpace(currencyName) - ? string.Empty + CurrencyText.Text = string.IsNullOrWhiteSpace(currencyName) + ? string.Empty : $"[{currencyName}]"; - + }); } public void LoadValues(AppDbContext db, IConfig config, string configSection) { - var eventType = Source.GetOrderEvent(); - if (eventType == SubathonEventType.Unknown) return; - var value = db.SubathonValues.AsNoTracking() - .FirstOrDefault(v => v.EventType == eventType); + .FirstOrDefault(v => v.EventType == SubathonEventType.GoAffProOrder && v.Meta == Meta); if (value != null) _host.UpdateTimePointsBoxes(SecondsBox, PointsBox, $"{value.Seconds}", $"{value.Points}"); ModeBox.ItemsSource = Enum.GetNames().ToList(); - ModeBox.SelectedItem = $"{config.GetOrderTypeMode(configSection, Source.ToString(), OrderTypeModes.Dollar)}"; + ModeBox.SelectedItem = $"{config.GetOrderTypeMode(configSection, Store.InternalName, OrderTypeModes.Dollar)}"; + + CommissionBox.IsChecked = config.GetBool(configSection, $"{Store.InternalName}.CommissionAsDonation", false); + EnabledBox.IsChecked = config.GetBool(configSection, $"{Store.InternalName}.Enabled", true); - CommissionBox.IsChecked = config.GetBool(configSection, $"{Source}.CommissionAsDonation", false); - EnabledBox.IsChecked = config.GetBool(configSection, $"{Source}.Enabled", true); - } internal override void UpdateStatus(IntegrationConnection? connection) @@ -70,16 +69,20 @@ internal override void UpdateStatus(IntegrationConnection? connection) public override bool UpdateValueSettings(AppDbContext db) { bool hasUpdated = false; - var eventType = Source.GetOrderEvent(); - if (eventType == SubathonEventType.Unknown) return false; - - var value = db.SubathonValues.FirstOrDefault(x => x.EventType == eventType && x.Meta == ""); - if (value != null && double.TryParse(SecondsBox.Text, out var seconds) && !value.Seconds.Equals(seconds)) + var value = db.SubathonValues.FirstOrDefault(x => + x.EventType == SubathonEventType.GoAffProOrder && x.Meta == Meta); + if (value == null) + { + value = new SubathonValue { EventType = SubathonEventType.GoAffProOrder, Meta = Meta, Seconds = 12 }; + db.SubathonValues.Add(value); + hasUpdated = true; + } + if (double.TryParse(SecondsBox.Text, out var seconds) && !value.Seconds.Equals(seconds)) { value.Seconds = seconds; hasUpdated = true; } - if (value != null && double.TryParse(PointsBox.Text, out var points) && !value.Points.Equals(points)) + if (double.TryParse(PointsBox.Text, out var points) && !value.Points.Equals(points)) { value.Points = points; hasUpdated = true; @@ -100,12 +103,12 @@ public override (string seconds, string points, TextBox? timeBox, TextBox? point public bool UpdateConfigSettings(IConfig config, string configSection) { bool hasUpdated = false; - hasUpdated |= config.SetOrderTypeMode(configSection, $"{Source}", Enum.Parse($"{ModeBox.SelectedItem}")); - hasUpdated |= config.SetBool(configSection, $"{Source}.CommissionAsDonation", CommissionBox.IsChecked ?? false); - hasUpdated |= config.SetBool(configSection, $"{Source}.Enabled", EnabledBox.IsChecked ?? true); + hasUpdated |= config.SetOrderTypeMode(configSection, Store.InternalName, Enum.Parse($"{ModeBox.SelectedItem}")); + hasUpdated |= config.SetBool(configSection, $"{Store.InternalName}.CommissionAsDonation", CommissionBox.IsChecked ?? false); + hasUpdated |= config.SetBool(configSection, $"{Store.InternalName}.Enabled", EnabledBox.IsChecked ?? true); return hasUpdated; } - + public void SimulateOrder() { @@ -115,8 +118,8 @@ public void SimulateOrder() string currency = CurrencyText.Text.Replace("[", "").Replace("]", ""); if (string.IsNullOrWhiteSpace(currency)) currency = "USD"; - ServiceManager.GoAffPro.SimulateOrder(total, itemCount, commTotal, Source, currency); + ServiceManager.GoAffPro.SimulateOrder(total, itemCount, commTotal, Store, currency); } private void TestOrder_Click(object sender, RoutedEventArgs e) => SimulateOrder(); -} \ No newline at end of file +} diff --git a/SubathonManager.UI/Views/SettingsViews/External/GoAffProSettings.xaml.cs b/SubathonManager.UI/Views/SettingsViews/External/GoAffProSettings.xaml.cs index 2cdfe4ee..507b5036 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/GoAffProSettings.xaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/GoAffProSettings.xaml.cs @@ -1,4 +1,4 @@ -using System.Windows; +using System.Windows; using System.Windows.Controls; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -24,18 +24,19 @@ public partial class GoAffProSettings : SettingsControl { private readonly ILogger? _logger = AppServices.Provider.GetRequiredService>(); private readonly string _configSection = "GoAffPro"; - private readonly Dictionary _sourceControls = new(); - private readonly Dictionary _connectedStatus = new(); + // keyed by store InternalName + private readonly Dictionary _sourceControls = new(); + private readonly Dictionary _connectedStatus = new(); + + private static IEnumerable _stores => GoAffProStoreRegistry.All() + .Where(s => s.Enabled); + private string _activeSource = string.Empty; - private IEnumerable _sources => Enum.GetValues() - .Where(s => !s.IsDisabled()); - private GoAffProSource _activeSource = GoAffProSource.Unknown; - public GoAffProSettings() { InitializeComponent(); - + Loaded += (_, _) => { IntegrationEvents.ConnectionUpdated += UpdateStatus; @@ -48,103 +49,131 @@ public GoAffProSettings() IntegrationEvents.ConnectionUpdated -= UpdateStatus; }; } - + public override void Init(SettingsView host) { Host = host; Dispatcher.Invoke(() => { UpdateStatus(Utils.GetConnection(SubathonEventSource.GoAffPro, nameof(SubathonEventSource.GoAffPro))); - foreach (var source in _sources) - { - var control = new GoAffProSourceControl(host, source); - _sourceControls[source] = control; - UpdateStatus(Utils.GetConnection(SubathonEventSource.GoAffPro, source.ToString())); - - var navBtn = new Wpf.Ui.Controls.Button - { - Content = source.GetDescription(), - HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Left, - Margin = new Thickness(2, 1, 2, 1), - Padding = new Thickness(6, 4, 6, 4), - Appearance = ControlAppearance.Transparent, - Height = 34, - Tag = source.ToString(), - BorderThickness = new Thickness(2, 1, 1, 1), - }; - navBtn.Click += GroupNav_Click; - SourceList?.Children.Add(navBtn); - - _sourceControls.TryGetValue(source, out var cachedControl); - if (cachedControl != null && _activeSource == GoAffProSource.Unknown) - { - SelectGroup(source.ToString()); - } - } + foreach (var store in _stores) + AddStoreTab(store); }); + GoAffProStoreRegistry.StoreDiscovered -= OnStoreDiscovered; + GoAffProStoreRegistry.StoreDiscovered += OnStoreDiscovered; + Dispatcher.InvokeAsync(async () => { await Task.Delay(5000); - foreach (var source in _sources) + foreach (var store in _stores) { - SetNavButtonStatus(source, - Utils.GetConnection(SubathonEventSource.GoAffPro, source.ToString()).Status); + SetNavButtonStatus(store.InternalName, + Utils.GetConnection(SubathonEventSource.GoAffPro, store.InternalName).Status); } }); } - + private void OnStoreDiscovered(GoAffProStore store) + { + Dispatcher.InvokeAsync(() => + { + if (_sourceControls.ContainsKey(store.InternalName) || !store.Enabled) return; + AddStoreTab(store); + var config = AppServices.Provider.GetRequiredService(); + SuppressUnsavedChanges(() => + { + var factory = AppServices.Provider + .GetRequiredService>(); + using var db = factory.CreateDbContext(); + _sourceControls[store.InternalName].LoadValues(db, config, _configSection); + }); + }); + } + + private void AddStoreTab(GoAffProStore store) + { + if (_sourceControls.ContainsKey(store.InternalName)) return; + var control = new GoAffProSourceControl(Host, store); + _sourceControls[store.InternalName] = control; + UpdateStatus(Utils.GetConnection(SubathonEventSource.GoAffPro, store.InternalName)); + + var navBtn = new Wpf.Ui.Controls.Button + { + Content = store.StoreName, + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Left, + Margin = new Thickness(2, 1, 2, 1), + Padding = new Thickness(6, 4, 6, 4), + Appearance = ControlAppearance.Transparent, + Height = 34, + Tag = store.InternalName, + BorderThickness = new Thickness(2, 1, 1, 1), + }; + navBtn.Click += GroupNav_Click; + SourceList?.Children.Add(navBtn); + + if (string.IsNullOrEmpty(_activeSource)) + { + SelectGroup(store.InternalName); + } + } + + private void GroupNav_Click(object sender, RoutedEventArgs e) { if (sender is Wpf.Ui.Controls.Button { Tag: string label }) SelectGroup(label); } - + + public void TrySelectStore(string internalName) + => Dispatcher.Invoke(() => SelectGroup(internalName)); + private void SelectGroup(string label) { if (SourceList == null) return; foreach (var child in SourceList.Children) { if (child is not Wpf.Ui.Controls.Button btn) continue; - + btn.Appearance = btn.Tag as string == label ? ControlAppearance.Secondary : ControlAppearance.Transparent; } - if (!Enum.TryParse(label, out GoAffProSource source)) return; - if (source == _activeSource) return; + if (label == _activeSource) return; + + _sourceControls.TryGetValue(label, out var control); - _sourceControls.TryGetValue(source, out var control); - SourcesPanel?.Children.Clear(); if(control != null) SourcesPanel?.Children.Add(control); - _activeSource = source; + _activeSource = label; } - + internal override void UpdateStatus(IntegrationConnection? connection) { if (connection is not { Source: SubathonEventSource.GoAffPro }) return; - Host.UpdateConnectionStatus(connection.Status, StatusText, ConnectBtn); - - if (!Enum.TryParse(connection.Service, out GoAffProSource goAffProSource)) return; + if (connection.Service == nameof(SubathonEventSource.GoAffPro)) + { + Host.UpdateConnectionStatus(connection.Status, StatusText, ConnectBtn); + return; + } - _sourceControls.TryGetValue(goAffProSource, out var control); + _sourceControls.TryGetValue(connection.Service, out var control); control?.UpdateStatus(connection.Status, connection.Name); - SetNavButtonStatus(goAffProSource, connection.Status); + SetNavButtonStatus(connection.Service, connection.Status); } - private void SetNavButtonStatus(GoAffProSource source, bool status) + private void SetNavButtonStatus(string internalName, bool status) { - _connectedStatus[source] = status; + if (!_sourceControls.ContainsKey(internalName)) return; + _connectedStatus[internalName] = status; var btn = SourceList?.Children .OfType() - .FirstOrDefault(b => Equals(b.Tag, source.ToString())); + .FirstOrDefault(b => Equals(b.Tag, internalName)); if (btn == null) return; btn.Opacity = status ? 1.0 : 0.6; @@ -155,14 +184,13 @@ private void SortSourceList() { if (SourceList == null) return; - var originalOrder = _sources - .Select((s, i) => (Key: s.ToString(), Index: i)) + var originalOrder = _stores + .Select((s, i) => (Key: s.InternalName, Index: i)) .ToDictionary(x => x.Key, x => x.Index); var buttons = SourceList.Children.OfType().ToList(); var sorted = buttons - .OrderByDescending(b => _connectedStatus.GetValueOrDefault( - Enum.TryParse(b.Tag as string, out GoAffProSource s) ? s : GoAffProSource.Unknown)) + .OrderByDescending(b => _connectedStatus.GetValueOrDefault(b.Tag as string ?? "")) .ThenBy(b => originalOrder.GetValueOrDefault(b.Tag as string ?? "", int.MaxValue)) .ToList(); @@ -170,7 +198,7 @@ private void SortSourceList() foreach (var b in sorted) SourceList.Children.Add(b); } - + protected internal override void LoadValues(AppDbContext db) { var config = AppServices.Provider.GetRequiredService(); @@ -213,7 +241,7 @@ private async void OpenLogin_Click(object sender, RoutedEventArgs routedEventArg { var config = AppServices.Provider.GetRequiredService(); var secureStorage = AppServices.Provider.GetRequiredService(); - + var msgBox = new Wpf.Ui.Controls.MessageBox { Title = "Login to GoAffPro", @@ -238,7 +266,7 @@ private async void OpenLogin_Click(object sender, RoutedEventArgs routedEventArg Width = 240, Margin = new Thickness(2, 4, 0, 0) }; - + var pwLabel = new Wpf.Ui.Controls.TextBlock { Text = "Password: ", @@ -266,7 +294,7 @@ private async void OpenLogin_Click(object sender, RoutedEventArgs routedEventArg { Orientation = Orientation.Horizontal }; - + panel2.Children.Add(userLabel); panel2.Children.Add(userBox); panel3.Children.Add(pwLabel); @@ -274,19 +302,19 @@ private async void OpenLogin_Click(object sender, RoutedEventArgs routedEventArg panel.Children.Add(panel2); panel.Children.Add(panel3); msgBox.Content = panel; - + var result = await msgBox.ShowDialogAsync(); bool confirm = result == Wpf.Ui.Controls.MessageBoxResult.Primary; if (!confirm) return; - + await ServiceManager.GoAffPro.StopAsync(); bool setData = false; setData |= secureStorage.Set(StorageKeys.GoAffProEmail, userBox.Text); setData |= secureStorage.Set(StorageKeys.GoAffProPassword, pwBox.Password); if (setData) config.Save(); - - + + if (string.IsNullOrWhiteSpace(secureStorage.GetOrDefault(StorageKeys.GoAffProPassword, string.Empty)) || string.IsNullOrWhiteSpace(secureStorage.GetOrDefault(StorageKeys.GoAffProEmail, string.Empty))) { @@ -299,4 +327,4 @@ private async void OpenLogin_Click(object sender, RoutedEventArgs routedEventArg _logger?.LogError(ex, "Error logging into GoAffPro"); } } -} \ No newline at end of file +} diff --git a/SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiSettings.xaml.cs b/SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiSettings.xaml.cs index bb312f43..7b61fa9f 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiSettings.xaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiSettings.xaml.cs @@ -13,6 +13,7 @@ namespace SubathonManager.UI.Views.SettingsViews.External.KoFi; +[Obsolete("Legacy Ko-Fi via StreamerBot socket setup. DevTunnels method is recommended")] public partial class KoFiSettings : SettingsControl { private readonly ILogger? _logger = AppServices.Provider.GetRequiredService>(); diff --git a/SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiWebhookSettings.xaml b/SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiWebhookSettings.xaml index 29401d26..bd624a7d 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiWebhookSettings.xaml +++ b/SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiWebhookSettings.xaml @@ -53,11 +53,14 @@ + ToolTip="The verification token from your Ko-Fi webhook settings page. Found at Ko-Fi > My Account > API > Webhooks."/> + ToolTip="Copy this from Ko-Fi > My Account > API > Webhooks"/> + KoFiWebhookForwardUrlsBox; protected override Wpf.Ui.Controls.TextBox _ForwardUrlsMultiBox => ForwardUrlsMultiBox; protected override Popup _ForwardUrlsPopup => ForwardUrlsPopup; - protected override Button? _ConnectBtn => null; + protected override Button? _ConnectBtn => ConnectBtn; public KoFiWebhookSettings() { @@ -94,6 +94,13 @@ private static async Task RestartKoFiAsync() public override bool UpdateValueSettings(AppDbContext db) => false; public override void UpdateCurrencyBoxes(List currencies, string selected) { } public override (string, string, TextBox?, TextBox?) GetValueBoxes(SubathonValue val) => ("", "", null, null); + + private async void ConnectKoFi_Click(object sender, RoutedEventArgs e) + { + var secureStorage = AppServices.Provider.GetRequiredService(); + secureStorage.Set(StorageKeys.KoFiVerificationToken, KoFiWebhookTokenBox.Password.Trim()); + await RestartKoFiAsync(); + } private void OpenKoFiTokenLink_Click(object sender, RoutedEventArgs e) { diff --git a/SubathonManager.UI/Views/SettingsViews/External/KoFiCombinedSettings.xaml b/SubathonManager.UI/Views/SettingsViews/External/KoFiCombinedSettings.xaml index 2a73d0b6..73eb4f95 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/KoFiCombinedSettings.xaml +++ b/SubathonManager.UI/Views/SettingsViews/External/KoFiCombinedSettings.xaml @@ -10,8 +10,8 @@ - - + SubathonEventType.KoFiSub; @@ -40,7 +42,9 @@ public override void Init(SettingsView host) { base.Init(host); +#pragma warning disable CS0618 // legacy StreamerBot setup control kept behind FeatureFlags.KoFiStreamerBotSetupEnabled _socket = new KoFiSettings(); +#pragma warning restore CS0618 _webhook = new KoFiWebhookSettings(); _socket.Init(host); @@ -48,6 +52,9 @@ public override void Init(SettingsView host) SocketSlot.Children.Add(_socket); WebhookSlot.Children.Add(_webhook); + + if (!FeatureFlags.KoFiStreamerBotSetupEnabled) + MethodTogglePanel.Visibility = Visibility.Collapsed; } internal override void UpdateStatus(IntegrationConnection? connection) { } @@ -77,10 +84,12 @@ private void LoadValuesCore(AppDbContext db) var secureStorage = AppServices.Provider.GetRequiredService(); bool hasToken = !string.IsNullOrWhiteSpace(secureStorage.GetOrDefault(StorageKeys.KoFiVerificationToken, string.Empty)); - WebhookRadio.IsChecked = hasToken; - SocketRadio.IsChecked = !hasToken; - SocketSlot.Visibility = hasToken ? Visibility.Collapsed : Visibility.Visible; - WebhookSlot.Visibility = hasToken ? Visibility.Visible : Visibility.Collapsed; + + bool useWebhook = hasToken || !FeatureFlags.KoFiStreamerBotSetupEnabled; + WebhookRadio.IsChecked = useWebhook; + SocketRadio.IsChecked = !useWebhook; + SocketSlot.Visibility = useWebhook ? Visibility.Collapsed : Visibility.Visible; + WebhookSlot.Visibility = useWebhook ? Visibility.Visible : Visibility.Collapsed; var values = db.SubathonValues.Where(v => v.EventType == SubathonEventType.KoFiSub) .OrderBy(meta => meta) diff --git a/SubathonManager.UI/Views/SettingsViews/External/PallySettings.xaml b/SubathonManager.UI/Views/SettingsViews/External/PallySettings.xaml new file mode 100644 index 00000000..cc21e7f7 --- /dev/null +++ b/SubathonManager.UI/Views/SettingsViews/External/PallySettings.xaml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SubathonManager.UI/Views/SettingsViews/External/PallySettings.xaml.cs b/SubathonManager.UI/Views/SettingsViews/External/PallySettings.xaml.cs new file mode 100644 index 00000000..1315463e --- /dev/null +++ b/SubathonManager.UI/Views/SettingsViews/External/PallySettings.xaml.cs @@ -0,0 +1,150 @@ +using System.Diagnostics; +using System.Windows; +using System.Windows.Controls; +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; +using SubathonManager.Core.Objects; +using SubathonManager.Core.Security; +using SubathonManager.Core.Security.Interfaces; +using SubathonManager.Integration; +using SubathonManager.UI.Services; +using SubathonManager.Data; + +namespace SubathonManager.UI.Views.SettingsViews.External; + +public partial class PallySettings : SettingsControl +{ + private const string ConfigSection = "PallyGG"; + private readonly ILogger? _logger = AppServices.Provider.GetRequiredService>(); + + public PallySettings() + { + InitializeComponent(); + Loaded += (_, _) => + { + IntegrationEvents.ConnectionUpdated += UpdateStatus; + RegisterUnsavedChangeHandlers(); + UpdateStatus(Utils.GetConnection(SubathonEventSource.PallyGG, "Socket")); + }; + Unloaded += (_, _) => + { + IntegrationEvents.ConnectionUpdated -= UpdateStatus; + }; + } + + public override void Init(SettingsView host) + { + Host = host; + var secureStorage = AppServices.Provider.GetRequiredService(); + var config = AppServices.Provider.GetRequiredService(); + PallyApiKeyBox.Password = secureStorage.GetOrDefault(StorageKeys.PallyApiKey, string.Empty)!; + PallyRoomBox.Text = config.Get(ConfigSection, "Room", string.Empty) ?? string.Empty; + UpdateStatus(Utils.GetConnection(SubathonEventSource.PallyGG, "Socket")); + } + + internal override void UpdateStatus(IntegrationConnection? connection) + { + if (connection is not { Source: SubathonEventSource.PallyGG, Service: "Socket" }) return; + Host.UpdateConnectionStatus(connection.Status, PallyStatusText, null); + Dispatcher.Invoke(() => + { + DisconnBtn.Visibility = connection.Status ? Visibility.Visible : Visibility.Collapsed; + ConnectBtn.Visibility = connection.Status ? Visibility.Collapsed : Visibility.Visible; + }); + } + + private async void ConnectPally_Click(object sender, RoutedEventArgs e) + { + try + { + var config = AppServices.Provider.GetRequiredService(); + ServiceManager.Pally.SaveConfig(PallyApiKeyBox.Password.Trim(), PallyRoomBox.Text.Trim()); + config.SetBool(ConfigSection, "Enabled", true); + config.Save(); + + if (ServiceManager.Pally.IsKeyEmpty()) + { + OpenDashboard(); + return; + } + await ServiceManager.Pally.RestartAsync(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to connect PallyGG Service"); + } + } + + private async void DisconnectPally_Click(object sender, RoutedEventArgs e) + { + try + { + var config = AppServices.Provider.GetRequiredService(); + if (config.SetBool(ConfigSection, "Enabled", false)) config.Save(); + await ServiceManager.Pally.StopAsync(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to disconnect PallyGG Service"); + } + } + + private void GetApiKey_Click(object sender, RoutedEventArgs e) => OpenDashboard(); + + private static void OpenDashboard() + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = "https://pally.gg/dashboard/settings/api-keys", + UseShellExecute = true + }); + } + catch { /**/ } + } + + private void TestPallyGGDonation_Click(object sender, RoutedEventArgs e) + { + PallyService.SimulateTip(string.IsNullOrWhiteSpace(SimulatePallyGGDonationAmountBox.Text) + ? "10.00" : SimulatePallyGGDonationAmountBox.Text); + } + + public override bool UpdateValueSettings(AppDbContext db) + { + bool hasUpdated = false; + var tipValue = db.SubathonValues.FirstOrDefault(sv => + sv.EventType == SubathonEventType.PallyGGDonation && sv.Meta == ""); + if (tipValue != null && double.TryParse(TipBox.Text, out var seconds) + && !seconds.Equals(tipValue.Seconds)) + { + tipValue.Seconds = seconds; + hasUpdated = true; + } + if (tipValue != null && double.TryParse(TipBox2.Text, out var points) + && !points.Equals(tipValue.Points)) + { + tipValue.Points = points; + hasUpdated = true; + } + return hasUpdated; + } + + public override void UpdateCurrencyBoxes(List currencies, string selected) { } + + public override (string, string, TextBox?, TextBox?) GetValueBoxes(SubathonValue val) + { + string v = $"{val.Seconds}"; + string p = $"{val.Points}"; + return val.EventType switch + { + SubathonEventType.PallyGGDonation => (v, p, TipBox, TipBox2), + _ => (v, p, null, null) + }; + } +} diff --git a/SubathonManager.UI/Views/SettingsViews/ExternalSettings.xaml.cs b/SubathonManager.UI/Views/SettingsViews/ExternalSettings.xaml.cs index ae504731..f75531fc 100644 --- a/SubathonManager.UI/Views/SettingsViews/ExternalSettings.xaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/ExternalSettings.xaml.cs @@ -1,6 +1,5 @@ using System.Windows.Controls; using SubathonManager.Core.Enums; -using SubathonManager.Core.Events; using SubathonManager.UI.Views.SettingsViews.External; namespace SubathonManager.UI.Views.SettingsViews; @@ -18,7 +17,6 @@ public partial class ExternalSettings : SettingsGroupControl public ExternalSettings() { InitializeComponent(); - SettingsEvents.HotLinkToDevTunnelsRequested += HotLinkToDevTunnels; } protected override SettingsControl? GetSettingsControl(SubathonEventSource eventSource) @@ -33,9 +31,6 @@ public ExternalSettings() case SubathonEventSource.KoFiTunnel: // merged into KoFi tab - return the shared instance without registering again return _settingsControls.TryGetValue(SubathonEventSource.KoFi, out var kofi) ? kofi : null; - case SubathonEventSource.DevTunnels: - _settingsControls[eventSource] = new DevTunnelsSettings(); - break; case SubathonEventSource.GoAffPro: _settingsControls[eventSource] = new GoAffProSettings(); break; @@ -48,6 +43,9 @@ public ExternalSettings() case SubathonEventSource.Throne: _settingsControls[eventSource] = new ThroneSettings(); break; + case SubathonEventSource.PallyGG: + _settingsControls[eventSource] = new PallySettings(); + break; default: return null; } _settingsControls[eventSource].Init(Host); @@ -65,9 +63,4 @@ public void RefreshTierCombo(SubathonEventSource source) ((ExternalServiceSettings?) control)?.RefreshTierCombo(); } - private void HotLinkToDevTunnels() - { - TryHotLinkToSource(SubathonEventSource.DevTunnels); - } - } \ No newline at end of file diff --git a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/ObsSettings.xaml b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/ObsSettings.xaml new file mode 100644 index 00000000..01de000e --- /dev/null +++ b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/ObsSettings.xaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/ObsSettings.xaml.cs b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/ObsSettings.xaml.cs new file mode 100644 index 00000000..1ec7b7b7 --- /dev/null +++ b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/ObsSettings.xaml.cs @@ -0,0 +1,125 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; +using SubathonManager.Data; +using SubathonManager.UI.Services; + +namespace SubathonManager.UI.Views.SettingsViews.ExternalSoftware; + +public partial class ObsSettings : SettingsControl +{ + public ObsSettings() + { + InitializeComponent(); + Loaded += (_, _) => + { + var (host, port, pw) = ServiceManager.OBS.GetConfig(); + SuppressUnsavedChanges(() => + { + ObsHostBox.Text = host; + ObsPortBox.Text = port; + ObsPasswordBox.Password = pw; + }); + + IntegrationEvents.ConnectionUpdated -= UpdateStatus; + IntegrationEvents.ConnectionUpdated += UpdateStatus; + ServiceManager.OBS.HelperScriptStatusChanged -= OnHelperScriptStatusChanged; + ServiceManager.OBS.HelperScriptStatusChanged += OnHelperScriptStatusChanged; + UpdateObsStatus(ServiceManager.OBS.Connected); + UpdateScriptStatus(ServiceManager.OBS.HelperScriptActive); + ServiceManager.OBS.RecheckHelperScript(); + }; + Unloaded += (_, _) => + { + IntegrationEvents.ConnectionUpdated -= UpdateStatus; + ServiceManager.OBS.HelperScriptStatusChanged -= OnHelperScriptStatusChanged; + }; + } + + private void OnHelperScriptStatusChanged(bool active) + { + Dispatcher.InvokeAsync(() => UpdateScriptStatus(active)); + } + + private void UpdateScriptStatus(bool active) + { + if (active) + { + var loaded = ServiceManager.OBS.HelperScriptVersion; + var expected = Integration.OBSService.ExpectedHelperScriptVersion; + if (ServiceManager.OBS.HelperScriptOutdated) + { + ScriptStatusText.Text = loaded != null + ? $"Outdated (v{loaded} loaded, v{expected} available) - reload in OBS Tools -> Scripts" + : $"Outdated (v{expected} available) - reload in OBS Tools -> Scripts"; + ScriptStatusText.Foreground = System.Windows.Media.Brushes.Orange; + } + else + { + ScriptStatusText.Text = loaded != null ? $"Active (v{loaded})" : "Active"; + ScriptStatusText.Foreground = System.Windows.Media.Brushes.LimeGreen; + } + } + else if (ServiceManager.OBS.Connected) + { + ScriptStatusText.Text = "Not Loaded (add via OBS Tools -> Scripts)"; + ScriptStatusText.Foreground = System.Windows.Media.Brushes.Orange; + } + else + { + ScriptStatusText.Text = "Unknown (not connected)"; + ScriptStatusText.Foreground = System.Windows.Media.Brushes.Gray; + } + } + + private async void CopyScriptPath_Click(object sender, RoutedEventArgs e) + { + await UiUtils.UiUtils.TrySetClipboardTextAsync(Integration.OBSService.ScriptPath); + } + + private void RecheckScript_Click(object sender, RoutedEventArgs e) + { + ServiceManager.OBS.RecheckHelperScript(); + } + + internal override void UpdateStatus(IntegrationConnection? connection) + { + if (connection is not { Source: SubathonEventSource.OBS, Service: "OBS" }) return; + UpdateObsStatus(connection.Status); + } + + private void UpdateObsStatus(bool connected) + { + Host.UpdateConnectionStatus(connected, ObsStatusText, ObsConnectBtn); + UpdateScriptStatus(ServiceManager.OBS.HelperScriptActive); + } + + private void ObsConnect_Click(object sender, RoutedEventArgs e) + { + var host = ObsHostBox.Text.Trim(); + var port = ObsPortBox.Text.Trim(); + var password = ObsPasswordBox.Password.Trim(); + + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(port)) return; + + ServiceManager.OBS.SaveConfig(host, port, password, true); + + if (ServiceManager.OBS.Connected) + ServiceManager.OBS.StopAsync(); + + ServiceManager.OBS.TryConnect(); + } + + private void NumberOnly_PreviewTextInput(object sender, TextCompositionEventArgs e) + { + e.Handled = !int.TryParse(e.Text, out _); + } + + public override bool UpdateValueSettings(AppDbContext db) => false; + public override void UpdateCurrencyBoxes(List currencies, string selected) { } + public override (string, string, TextBox?, TextBox?) GetValueBoxes(SubathonValue val) => ("", "", null, null); +} diff --git a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamDeckSettings.xaml b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamDeckSettings.xaml new file mode 100644 index 00000000..05b9958e --- /dev/null +++ b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamDeckSettings.xaml @@ -0,0 +1,24 @@ + + + + + + + + + + + diff --git a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamDeckSettings.xaml.cs b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamDeckSettings.xaml.cs new file mode 100644 index 00000000..2aed25ae --- /dev/null +++ b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamDeckSettings.xaml.cs @@ -0,0 +1,70 @@ +using System.Diagnostics; +using System.Windows; +using System.Windows.Controls; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; +using SubathonManager.Data; + +namespace SubathonManager.UI.Views.SettingsViews.ExternalSoftware; + +public partial class StreamDeckSettings : SettingsControl +{ + public StreamDeckSettings() + { + InitializeComponent(); + Loaded += (_, _) => + { + IntegrationEvents.ConnectionUpdated -= UpdateStatus; + IntegrationEvents.ConnectionUpdated += UpdateStatus; + UpdateStatus(Utils.GetConnection(SubathonEventSource.StreamDeck, "Socket")); + }; + Unloaded += (_, _) => + { + IntegrationEvents.ConnectionUpdated -= UpdateStatus; + }; + } + + internal override void UpdateStatus(IntegrationConnection? connection) + { + if (connection is not { Source: SubathonEventSource.StreamDeck, Service: "Socket" }) return; + Host.UpdateConnectionStatus(connection.Status, StreamDeckStatusText, null); + } + + private void OpenStreamDeckSetup_Click(object sender, RoutedEventArgs e) => OpenDocs(); + + private static void OpenDocs() + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = "https://docs.subathonmanager.app/latest/config/setup/StreamDeck/", + UseShellExecute = true + }); + } + catch { /**/ } + } + + private void DownloadPlugin_Click(object sender, RoutedEventArgs e) + { + string version = AppServices.AppVersion; + if (!version.StartsWith('v') || version.Length > 16) version = "nightly"; + string url = $"https://github.com/WolfwithSword/SubathonManager/releases/download/{version}/SubathonManager_StreamDeck.streamDeckPlugin"; + try + { + Process.Start(new ProcessStartInfo + { + FileName = url, + UseShellExecute = true + }); + } + catch { OpenDocs(); } + } + + public override bool UpdateValueSettings(AppDbContext db) => false; + public override void UpdateCurrencyBoxes(List currencies, string selected) { } + public override (string, string, TextBox?, TextBox?) GetValueBoxes(SubathonValue val) => ("", "", null, null); +} diff --git a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamerBotSettings.xaml b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamerBotSettings.xaml new file mode 100644 index 00000000..09489cf6 --- /dev/null +++ b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamerBotSettings.xaml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamerBotSettings.xaml.cs b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamerBotSettings.xaml.cs new file mode 100644 index 00000000..8126e973 --- /dev/null +++ b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamerBotSettings.xaml.cs @@ -0,0 +1,52 @@ +using System.Diagnostics; +using System.Windows; +using System.Windows.Controls; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; +using SubathonManager.Data; + +namespace SubathonManager.UI.Views.SettingsViews.ExternalSoftware; + +public partial class StreamerBotSettings : SettingsControl +{ + public StreamerBotSettings() + { + InitializeComponent(); + Loaded += (_, _) => + { + IntegrationEvents.ConnectionUpdated -= UpdateStatus; + IntegrationEvents.ConnectionUpdated += UpdateStatus; + UpdateStatus(Utils.GetConnection(SubathonEventSource.StreamerBot, "Socket")); + }; + Unloaded += (_, _) => + { + IntegrationEvents.ConnectionUpdated -= UpdateStatus; + }; + } + + internal override void UpdateStatus(IntegrationConnection? connection) + { + if (connection is not { Source: SubathonEventSource.StreamerBot, Service: "Socket" }) return; + Host.UpdateConnectionStatus(connection.Status, StreamerBotStatusText, null); + } + + private void OpenStreamerBotExtension_Click(object sender, RoutedEventArgs e) + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = "https://extensions.wolfwithsword.com/extensions/subathonmanager-extension/", + UseShellExecute = true + }); + } + catch { /**/ } + } + + public override bool UpdateValueSettings(AppDbContext db) => false; + public override void UpdateCurrencyBoxes(List currencies, string selected) { } + public override (string, string, TextBox?, TextBox?) GetValueBoxes(SubathonValue val) => ("", "", null, null); +} diff --git a/SubathonManager.UI/Views/SettingsViews/ExternalSoftwareSettings.xaml b/SubathonManager.UI/Views/SettingsViews/ExternalSoftwareSettings.xaml new file mode 100644 index 00000000..9581f269 --- /dev/null +++ b/SubathonManager.UI/Views/SettingsViews/ExternalSoftwareSettings.xaml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + diff --git a/SubathonManager.UI/Views/SettingsViews/ExternalSoftwareSettings.xaml.cs b/SubathonManager.UI/Views/SettingsViews/ExternalSoftwareSettings.xaml.cs new file mode 100644 index 00000000..398c91b1 --- /dev/null +++ b/SubathonManager.UI/Views/SettingsViews/ExternalSoftwareSettings.xaml.cs @@ -0,0 +1,53 @@ +using System.Windows.Controls; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.UI.Views.SettingsViews.External; +using SubathonManager.UI.Views.SettingsViews.ExternalSoftware; + +namespace SubathonManager.UI.Views.SettingsViews; + +public partial class ExternalSoftwareSettings : SettingsGroupControl +{ + protected override IEnumerable _eventSources => + Enum.GetValues().Where(s => s.GetGroup() == SubathonSourceGroup.ExternalSoftware) + .OrderBy(g => g.GetGroupLabelOrder()); + + protected override StackPanel? GetSourceContents => SourceContents; + protected override Panel? GetSourceList => SourceList; + + public ExternalSoftwareSettings() + { + InitializeComponent(); + SettingsEvents.HotLinkToDevTunnelsRequested += HotLinkToDevTunnels; + } + + private void HotLinkToDevTunnels() + { + TryHotLinkToSource(SubathonEventSource.DevTunnels); + Dispatcher.BeginInvoke(() => BringIntoView()); + } + + protected override SettingsControl? GetSettingsControl(SubathonEventSource eventSource) + { + if (_settingsControls.TryGetValue(eventSource, out var control)) return control; + + switch (eventSource) + { + case SubathonEventSource.OBS: + _settingsControls[eventSource] = new ObsSettings(); + break; + case SubathonEventSource.DevTunnels: + _settingsControls[eventSource] = new DevTunnelsSettings(); + break; + case SubathonEventSource.StreamDeck: + _settingsControls[eventSource] = new StreamDeckSettings(); + break; + case SubathonEventSource.StreamerBot: + _settingsControls[eventSource] = new StreamerBotSettings(); + break; + default: return null; + } + _settingsControls[eventSource].Init(Host); + return _settingsControls[eventSource]; + } +} diff --git a/SubathonManager.UI/Views/SettingsViews/SettingsGroupControl.cs b/SubathonManager.UI/Views/SettingsViews/SettingsGroupControl.cs index 0c5e0f4e..f903a342 100644 --- a/SubathonManager.UI/Views/SettingsViews/SettingsGroupControl.cs +++ b/SubathonManager.UI/Views/SettingsViews/SettingsGroupControl.cs @@ -39,7 +39,7 @@ public override void Init(SettingsView host) Margin = new Thickness(0, 1, 0, 0), Padding = new Thickness(10, 6, 10, 6), Appearance = ControlAppearance.Transparent, - FontSize = 20, + FontSize = 13, MinWidth = 100, Tag = $"{source}", BorderThickness = new Thickness(1, 1, 1, 2), @@ -92,9 +92,16 @@ private void SelectGroup(string label) public void TryHotLinkToSource(SubathonEventSource eventSource) { - Dispatcher.Invoke(() => SelectGroup(eventSource.ToString())); + Dispatcher.Invoke(() => + { + if (Content is Expander expander) expander.IsExpanded = true; + SelectGroup(eventSource.ToString()); + }); } + public SettingsControl? GetControlForSource(SubathonEventSource eventSource) + => GetSettingsControl(eventSource); + internal override void UpdateStatus(IntegrationConnection? connection) { throw new NotImplementedException(); diff --git a/SubathonManager.UI/Views/SettingsViews/WebhookLogSettings.xaml.cs b/SubathonManager.UI/Views/SettingsViews/WebhookLogSettings.xaml.cs index 99124407..3dd178a1 100644 --- a/SubathonManager.UI/Views/SettingsViews/WebhookLogSettings.xaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/WebhookLogSettings.xaml.cs @@ -17,10 +17,11 @@ namespace SubathonManager.UI.Views.SettingsViews; public partial class WebhookLogSettings : SettingsControl { - private readonly Dictionary>> + private readonly Dictionary>> _groupCheckboxes = new(); private readonly Dictionary> _subTabGroups = new(); private readonly Dictionary _activeSubTab = new(); + private string? _activeGroup; public WebhookLogSettings() { @@ -32,6 +33,40 @@ public override void Init(SettingsView host) { Host = host; SuppressUnsavedChanges(InitWebhookSettings); + + GoAffProStoreRegistry.StoreDiscovered -= OnGoAffProStoreDiscovered; + GoAffProStoreRegistry.StoreDiscovered += OnGoAffProStoreDiscovered; + } + + private void OnGoAffProStoreDiscovered(GoAffProStore store) + { + // rerender to add new + Dispatcher.InvokeAsync(() => + { + if (!store.Enabled) return; + var groupLabel = SubathonEventSource.GoAffPro.GetGroupLabel(); + var sourceName = SubathonEventSource.GoAffPro.GetDescription(); + if (!_groupCheckboxes.TryGetValue(groupLabel, out var sourceMap) || + !sourceMap.TryGetValue(sourceName, out var checkboxes)) return; + + var key = $"{SubathonEventType.GoAffProOrder}.{store.SiteId}"; + if (checkboxes.Any(c => c.ConfigKey == key)) return; + + var config = AppServices.Provider.GetRequiredService(); + var cb = new CheckBox + { + Content = store.EventName, + IsChecked = config.GetBool("Discord", $"Events.Log.{key}", false), + Margin = new Thickness(0, 4, 8, 4), + VerticalAlignment = VerticalAlignment.Center, + Width = 224 + }; + SuppressUnsavedChanges(() => WireControl(cb)); + checkboxes.Add((key, cb)); + + if (_activeGroup == groupLabel) + SelectGroup(groupLabel); + }); } internal override void UpdateStatus(IntegrationConnection? connection) @@ -68,24 +103,32 @@ private void InitWebhookSettings() foreach (var (label, bySource) in rawGroups) { - var sourceMap = new Dictionary>(); + var sourceMap = new Dictionary>(); foreach (var (sourceName, events) in bySource) { - var checkboxes = new List<(SubathonEventType, CheckBox)>(); + var checkboxes = new List<(string, CheckBox)>(); foreach (var eventType in events) { - bool isChecked = config.GetBool("Discord", $"Events.Log.{eventType}", false); - var cb = new CheckBox + IEnumerable<(string Label, string Key)> entries = eventType == SubathonEventType.GoAffProOrder + ? GoAffProStoreRegistry.All().Where(s => s.Enabled) + .Select(s => (s.EventName, $"{SubathonEventType.GoAffProOrder}.{s.SiteId}")) + : new[] { (((SubathonEventType?)eventType).GetLabel(), eventType.ToString()) }; + + foreach (var (entryLabel, key) in entries) { - Content = ((SubathonEventType?)eventType).GetLabel(), - IsChecked = isChecked, - Margin = new Thickness(0, 4, 8, 4), - VerticalAlignment = VerticalAlignment.Center, - Width = 224 - }; - WireControl(cb); - checkboxes.Add((eventType, cb)); + bool isChecked = config.GetBool("Discord", $"Events.Log.{key}", false); + var cb = new CheckBox + { + Content = entryLabel, + IsChecked = isChecked, + Margin = new Thickness(0, 4, 8, 4), + VerticalAlignment = VerticalAlignment.Center, + Width = 224 + }; + WireControl(cb); + checkboxes.Add((key, cb)); + } } sourceMap[sourceName] = checkboxes; } @@ -103,7 +146,7 @@ private void InitWebhookSettings() Margin = new Thickness(2, 1, 2, 1), Padding = new Thickness(10, 6, 10, 6), Appearance = ControlAppearance.Transparent, - FontSize = 34, + FontSize = 13, Height = 34, BorderThickness = new Thickness(2, 1, 1, 1), Tag = label @@ -144,6 +187,7 @@ private void WebhookGroupNav_Click(object sender, RoutedEventArgs e) private void SelectGroup(string label) { + _activeGroup = label; foreach (var child in WebhookGroupList.Children) { if (child is Wpf.Ui.Controls.Button btn) @@ -175,7 +219,7 @@ private void SelectGroup(string label) Margin = new Thickness(0, 0, 0, 0), Padding = new Thickness(10, 6, 10, 6), Appearance = _activeSubTab[label].Equals(sn) ? ControlAppearance.Secondary : ControlAppearance.Transparent, - FontSize = 20, + FontSize = 13, MinWidth = 100, Tag = sn, BorderThickness = new Thickness(1, 1, 1, 2), @@ -208,7 +252,7 @@ private void SelectGroup(string label) } } - private void PopulateCheckboxWrap(IEnumerable<(SubathonEventType, CheckBox cb)> checkboxes) + private void PopulateCheckboxWrap(IEnumerable<(string, CheckBox cb)> checkboxes) { var wrap = new WrapPanel { @@ -242,8 +286,8 @@ protected internal override bool UpdateConfigValueSettings() foreach (var checkboxes in _groupCheckboxes.Values.SelectMany(sourceMap => sourceMap.Values)) - foreach (var (eventType, cb) in checkboxes) - hasUpdated |= config.Set("Discord", $"Events.Log.{eventType}", $"{cb.IsChecked}"); + foreach (var (configKey, cb) in checkboxes) + hasUpdated |= config.Set("Discord", $"Events.Log.{configKey}", $"{cb.IsChecked}"); hasUpdated |= config.Set("Discord", "WebhookUrl", ErrorWebhookUrlBx.Text); hasUpdated |= config.Set("Discord", "Events.WebhookUrl", EventWebhookUrlBx.Text); diff --git a/SubathonManager.UI/Views/WheelSpin/WheelEditor.xaml b/SubathonManager.UI/Views/WheelSpin/WheelEditor.xaml index 7f123be4..89ef33fd 100644 --- a/SubathonManager.UI/Views/WheelSpin/WheelEditor.xaml +++ b/SubathonManager.UI/Views/WheelSpin/WheelEditor.xaml @@ -38,7 +38,7 @@ - - + + @@ -237,7 +237,7 @@ - diff --git a/SubathonManager.UI/Views/WheelSpin/WheelEditor.xaml.cs b/SubathonManager.UI/Views/WheelSpin/WheelEditor.xaml.cs index 8e0898cc..2f5057d2 100644 --- a/SubathonManager.UI/Views/WheelSpin/WheelEditor.xaml.cs +++ b/SubathonManager.UI/Views/WheelSpin/WheelEditor.xaml.cs @@ -49,6 +49,8 @@ public WheelEditor() PopulateActionTypeComboBox(); LoadActiveWheel(); LoadGlobalState(); + + WheelEvents.WheelSpinRequested += OnWheelSpinRequested; Loaded += (_, _) => { if (!_initialized) @@ -876,6 +878,15 @@ private async void SpinsOwedIncrement_Click(object sender, RoutedEventArgs e) private async void SpinWheel_Click(object sender, RoutedEventArgs e) => await PerformSpinAsync(); + private void OnWheelSpinRequested() + { + Dispatcher.InvokeAsync(async () => + { + if (_activeWheel == null || _isSpinning || !SpinWheelBtn.IsEnabled) return; + await PerformSpinAsync(); + }); + } + private async Task PerformSpinAsync() { if (_activeWheel == null || _isSpinning) return; diff --git a/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.xaml b/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.xaml index 7fd03722..4106936f 100644 --- a/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.xaml +++ b/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.xaml @@ -140,8 +140,33 @@ - + diff --git a/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.xaml.cs b/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.xaml.cs index d1d4f9e3..20c4f7d2 100644 --- a/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.xaml.cs +++ b/SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.xaml.cs @@ -29,6 +29,7 @@ public partial class WheelTriggerEditor private const int HistoryPageSize = 20; private bool _historyLoading; private bool _initialized; + private object? _selectedEventTag; // SubathonEventType or GoAffProStore private static readonly System.Windows.Media.SolidColorBrush SelectedRowBrush = new(System.Windows.Media.Color.FromArgb(30, 100, 149, 237)); @@ -70,7 +71,6 @@ public WheelTriggerEditor() LoadCurrencies(); if (!_initialized) { - PopulateEventTypeComboBox(); WireDirtyHandlers(); _initialized = true; } @@ -136,7 +136,6 @@ private void WireDirtyHandlers() { TriggerEnabledCheck.Checked += (_, _) => MarkDirty(); TriggerEnabledCheck.Unchecked += (_, _) => MarkDirty(); - EventTypeBox.SelectionChanged += (_, _) => MarkDirty(); TierComboBox.SelectionChanged += (_, _) => MarkDirty(); TierTextBox.TextChanged += (_, _) => MarkDirty(); GiftCountBox.TextChanged += (_, _) => MarkDirty(); @@ -160,9 +159,9 @@ private void Grid_MouseDown(object sender, MouseButtonEventArgs e) (sender as Grid)?.Focus(); } - private void PopulateEventTypeComboBox() + private void EventTypePickerBtn_Click(object sender, RoutedEventArgs e) { - EventTypeBox.Items.Clear(); + var entries = new List(); var groups = Enum.GetValues() .Where(et => et.IsEnabled() && @@ -172,28 +171,44 @@ private void PopulateEventTypeComboBox() foreach (var group in groups) { - var header = new ComboBoxItem - { - Content = group.Key.ToString(), - FontWeight = FontWeights.SemiBold, - Foreground = System.Windows.Media.Brushes.CornflowerBlue, - IsEnabled = false, // trick to make groups :) - Focusable = false, - FontSize = 11, - Padding = new Thickness(6, 4, 6, 2) - }; - EventTypeBox.Items.Add(header); - foreach (var et in group.OrderBy(et => et.GetLabel())) { - EventTypeBox.Items.Add(new ComboBoxItem + if (et == SubathonEventType.GoAffProOrder) { - Content = $" {et.GetLabel()}", // fake indent - Tag = et, - Padding = new Thickness(6, 2, 6, 2) - }); + foreach (var store in GoAffProStoreRegistry.All().Where(s => s.Enabled) + .OrderBy(s => s.EventName)) + { + var capturedStore = store; + entries.Add(new UiUtils.EventTypeMenuEntry( + group.Key, + store.EventName, + _selectedEventTag is GoAffProStore sel && sel.SiteId == store.SiteId, + () => OnEventTypeSelected(capturedStore, capturedStore.EventName))); + } + continue; + } + + var captured = et; + entries.Add(new UiUtils.EventTypeMenuEntry( + group.Key, + et.GetLabel(), + _selectedEventTag is SubathonEventType s && s == et, + () => OnEventTypeSelected(captured, captured.GetLabel()))); } } + + UiUtils.EventTypeMenu.Show(EventTypePickerBtn, entries, groupBySourceType: true); + } + + private void OnEventTypeSelected(object tag, string label) + { + _selectedEventTag = tag; + EventTypePickerLabel.Text = label; + var et = SelectedEventTypeFromTag(tag); + EventTypeSourceLabel.Text = et.HasValue ? et.Value.GetSource().ToString() : ""; + TriggerStatusText.Text = ""; + UpdateEditorPanels(et); + MarkDirty(); } private void LoadTriggerRows() @@ -296,6 +311,10 @@ private Grid BuildTriggerRow(WheelSpinTrigger trigger) private static string BuildTriggerEventLabel(WheelSpinTrigger t) { + if (t.EventType == SubathonEventType.GoAffProOrder) + { + return $"{t.EventType.GetSource()} - {GoAffProOrderHelper.GetOrderEventDisplayLabel(t.EventType, t.TierValue)}"; + } var label = $"{t.EventType.GetSource()} - {t.EventType.GetLabel()}"; if (!string.IsNullOrEmpty(t.TierValue)) { @@ -372,9 +391,18 @@ private void PopulateEditor(WheelSpinTrigger trigger) { TriggerEnabledCheck.IsChecked = trigger.IsEnabled; - var match = EventTypeBox.Items.OfType() - .FirstOrDefault(i => i.Tag is SubathonEventType et && et == trigger.EventType); - EventTypeBox.SelectedItem = match; + if (trigger.EventType == SubathonEventType.GoAffProOrder) + { + var store = GoAffProStoreRegistry.All() + .FirstOrDefault(s => s.SiteId.ToString() == trigger.TierValue); + _selectedEventTag = store; + EventTypePickerLabel.Text = store?.EventName ?? "- select -"; + } + else + { + _selectedEventTag = trigger.EventType; + EventTypePickerLabel.Text = trigger.EventType.GetLabel(); + } UpdateEditorPanels(trigger.EventType); }); EventTypeSourceLabel.Text = trigger.EventType.GetSource().ToString(); @@ -515,22 +543,27 @@ private void UpdateOrderModePanel(SubathonEventType? eventType = null) { if (OrderItemPanel == null || OrderMoneyPanel == null) return; - eventType ??= (EventTypeBox.SelectedItem as ComboBoxItem)?.Tag as SubathonEventType?; + eventType ??= SelectedEventTypeFromTag(_selectedEventTag); bool forceByMoney = eventType == SubathonEventType.ThroneGiftContribution; bool noItemCount = eventType is SubathonEventType.ThroneGiftPurchase - or SubathonEventType.KoFiCommissionOrder; + or SubathonEventType.KoFiCommissionOrder + or SubathonEventType.TreatStreamOrder; + // treats are only items/orders + bool noMoney = eventType == SubathonEventType.TreatStreamOrder; if (forceByMoney) { SuppressChanges(() => OrderByMoneyRadio.IsChecked = true); OrderByItemsRadio.IsEnabled = false; OrderByOrderRadio.IsEnabled = false; + OrderByMoneyRadio.IsEnabled = true; } else if (noItemCount) { OrderByItemsRadio.IsEnabled = false; OrderByOrderRadio.IsEnabled = true; + OrderByMoneyRadio.IsEnabled = !noMoney; if (OrderByItemsRadio.IsChecked == true) SuppressChanges(() => OrderByOrderRadio.IsChecked = true); } @@ -538,6 +571,7 @@ private void UpdateOrderModePanel(SubathonEventType? eventType = null) { OrderByItemsRadio.IsEnabled = true; OrderByOrderRadio.IsEnabled = true; + OrderByMoneyRadio.IsEnabled = !noMoney; } bool byItems = OrderByItemsRadio.IsChecked == true; @@ -561,7 +595,8 @@ private void AddTrigger_Click(object sender, RoutedEventArgs e) SuppressChanges(() => { TriggerEnabledCheck.IsChecked = true; - EventTypeBox.SelectedItem = null; + _selectedEventTag = null; + EventTypePickerLabel.Text = "- select -"; TierTextBox.Text = ""; GiftCountBox.Text = ""; TokenCountBox.Text = ""; @@ -619,8 +654,8 @@ private async void SaveTrigger_Click(object sender, RoutedEventArgs e) { TriggerStatusText.Text = ""; - var eventTypeItem = EventTypeBox.SelectedItem as ComboBoxItem; - if (eventTypeItem?.Tag is not SubathonEventType eventType) + var goAffProStore = _selectedEventTag as GoAffProStore; + if (SelectedEventTypeFromTag(_selectedEventTag) is not { } eventType) { TriggerStatusText.Text = "Select an event type"; return; @@ -638,7 +673,11 @@ private async void SaveTrigger_Click(object sender, RoutedEventArgs e) bool isSubLike = subType is SubathonEventSubType.SubLike or SubathonEventSubType.GiftSubLike; string? tierValue = null; - if (isSubLike) + if (goAffProStore != null) + { + tierValue = goAffProStore.SiteId.ToString(); + } + else if (isSubLike) { if (isTwitchTier || isPicartoTier) { @@ -737,7 +776,8 @@ private async void SaveTrigger_Click(object sender, RoutedEventArgs e) // unique check await using var db = await _factory.CreateDbContextAsync(); - bool isDuplicateTierEvent = subType is SubathonEventSubType.SubLike or SubathonEventSubType.GiftSubLike; + bool isDuplicateTierEvent = subType is SubathonEventSubType.SubLike or SubathonEventSubType.GiftSubLike + || eventType == SubathonEventType.GoAffProOrder; var existing = await db.WheelSpinTriggers .Where(t => t.EventType == eventType && t.Id != (_selectedTrigger != null ? _selectedTrigger.Id : Guid.Empty)) @@ -749,7 +789,9 @@ private async void SaveTrigger_Click(object sender, RoutedEventArgs e) string.Equals(t.TierValue, tierValue, StringComparison.OrdinalIgnoreCase)); if (tierConflict) { - TriggerStatusText.Text = $"A trigger for {eventType.GetLabel()} ({tierValue}) already exists. Edit or delete it first"; + TriggerStatusText.Text = eventType == SubathonEventType.GoAffProOrder + ? $"A trigger for {GoAffProOrderHelper.GetOrderEventDisplayLabel(eventType, tierValue)} already exists. Edit or delete it first" + : $"A trigger for {eventType.GetLabel()} ({tierValue}) already exists. Edit or delete it first"; return; } } @@ -953,7 +995,9 @@ private async void ExportHistoryToCsv_Click(object sender, RoutedEventArgs e) sb.AppendLine("Id,TriggerId,TriggerEventType,TriggeredAt,TriggerUser,TriggerSource,SpinsAdded,SubathonEventId,SubathonEventType"); foreach (var h in rows) { - var eventLabel = h.Trigger?.EventType.GetLabel() ?? h.SubathonEventType?.GetLabel() ?? ""; + var eventLabel = h.Trigger != null + ? BuildTriggerEventLabel(h.Trigger) + : h.SubathonEventType?.GetLabel() ?? ""; var user = h.TriggerUser?.Replace("\"", "\"\"") ?? ""; sb.AppendLine( $"{h.Id}," + @@ -1038,11 +1082,26 @@ private async void ImportTriggers_Click(object sender, RoutedEventArgs e) if (cols.Length < 3 || !bool.TryParse(cols[0].Trim(), out bool enabled) - || !int.TryParse(cols[1].Trim(), out int spins) - || !Enum.TryParse(cols[2].Trim(), out var eventType)) + || !int.TryParse(cols[1].Trim(), out int spins)) { await ShowInvalidTriggerCsvPopup(); return; } + string typeStr = cols[2].Trim(); + string? goAffProMeta = null; + if (!Enum.TryParse(typeStr, out var eventType)) + { + if (!GoAffProOrderHelper.TryGetStoreByOrderKey(typeStr, out var keyStore)) + { await ShowInvalidTriggerCsvPopup(); return; } + eventType = SubathonEventType.GoAffProOrder; + goAffProMeta = keyStore.SiteId.ToString(); + } + else if (eventType.GetLegacyGoAffProSiteId() > 0) + { + goAffProMeta = eventType.GetLegacyGoAffProSiteId().ToString(); + eventType = SubathonEventType.GoAffProOrder; + } + string? tierValue = cols.Length > 3 && !string.IsNullOrWhiteSpace(cols[3]) ? cols[3].Trim() : null; + if (goAffProMeta != null) tierValue = goAffProMeta; int? countThreshold = null; if (cols.Length > 4 && !string.IsNullOrWhiteSpace(cols[4])) @@ -1194,14 +1253,12 @@ private void OnTriggersChanged() Dispatcher.InvokeAsync(LoadTriggerRows); } - private void EventTypeBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + private static SubathonEventType? SelectedEventTypeFromTag(object? tag) => tag switch { - if (_suppressCount > 0) return; - var et = (EventTypeBox.SelectedItem as ComboBoxItem)?.Tag as SubathonEventType?; - UpdateEditorPanels(et); - TriggerStatusText.Text = ""; - EventTypeSourceLabel.Text = et.HasValue ? et.Value.GetSource().ToString() : ""; - } + SubathonEventType et => et, + GoAffProStore => SubathonEventType.GoAffProOrder, + _ => null + }; private void OrderMode_Changed(object sender, RoutedEventArgs e) { diff --git a/external/linux/README.md b/external/linux/README.md new file mode 100644 index 00000000..f897b0d4 --- /dev/null +++ b/external/linux/README.md @@ -0,0 +1,34 @@ +# Linux (Wine/Proton) support files + +SubathonManager might work on Linux under Wine/Proton. These files help fill in some gaps. + +## Install + +1. Run SubathonManager at least once inside your wine prefix to write its registry keys on boot. +2. Run the installer: + + ./install.sh /path/to/wineprefix + + - No argument: uses $WINEPREFIX, else ~/.wine + - Custom wine binary (proton, wine-staging, etc): + WINE_BIN=/path/to/wine ./install.sh /path/to/prefix +3. Test: xdg-open "subathonmanager://test" should focus the running app. + +Everything is installed user-level, no root needed: + +- ~/.local/share/applications/subathonmanager-url.desktop - handles subathonmanager:// links +- ~/.local/share/applications/subathonmanager-smo.desktop - opens .smo files via wine +- ~/.local/share/mime/packages/subathonmanager-smo.xml - defines the .smo mime type + +## Remove + +Delete the three files above, then: + + update-mime-database ~/.local/share/mime + update-desktop-database ~/.local/share/applications + +## Notes + +- The WebView2 overlay preview is not available under wine/proton. The app detects this and fallsback to opening a browser for the editor preview. +- If you move the app to a different prefix, re-run install.sh with the new path. +- Secrets: under wine the app uses DPAPI like on Windows. Native (non-wine) builds use an AES file store (data/secure_store.bin + data/secure_store.key) instead, in case I can ever get this native in the future. diff --git a/external/linux/install.sh b/external/linux/install.sh new file mode 100644 index 00000000..a0143855 --- /dev/null +++ b/external/linux/install.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Installs native Linux handlers that forward subathonmanager:// links +# Run SubathonManager once inside the prefix first so the wine registry keys exist. +# Somewhat untested +set -euo pipefail + +PREFIX="${1:-${WINEPREFIX:-$HOME/.wine}}" +WINE_BIN="${WINE_BIN:-wine}" + +if [ ! -d "$PREFIX" ]; then + echo "wine prefix not found: $PREFIX" >&2 + echo "usage: $0 [/path/to/wineprefix] (or set WINEPREFIX)" >&2 + exit 1 +fi + +if ! command -v "$WINE_BIN" >/dev/null 2>&1 && [ ! -x "$WINE_BIN" ]; then + echo "wine binary not found: $WINE_BIN (set WINE_BIN to override, e.g. for proton)" >&2 + exit 1 +fi + +APPS="$HOME/.local/share/applications" +MIME="$HOME/.local/share/mime" +mkdir -p "$APPS" "$MIME/packages" + +cat > "$APPS/subathonmanager-url.desktop" < "$APPS/subathonmanager-smo.desktop" </dev/null 2>&1 || true +update-desktop-database "$APPS" >/dev/null 2>&1 || true +xdg-mime default subathonmanager-url.desktop x-scheme-handler/subathonmanager +xdg-mime default subathonmanager-smo.desktop application/x-subathonmanager-overlay + +echo "installed:" +echo " $APPS/subathonmanager-url.desktop" +echo " $APPS/subathonmanager-smo.desktop" +echo " $MIME/packages/subathonmanager-smo.xml" +echo "" +echo "test with: xdg-open \"subathonmanager://test\"" +echo "reminder: SubathonManager must have been run once in this prefix so its registry keys exist." diff --git a/external/linux/subathonmanager-smo.xml b/external/linux/subathonmanager-smo.xml new file mode 100644 index 00000000..7e4328ef --- /dev/null +++ b/external/linux/subathonmanager-smo.xml @@ -0,0 +1,7 @@ + + + + Subathon Manager Overlay + + + diff --git a/external/obs/subathonmanager.lua b/external/obs/subathonmanager.lua new file mode 100644 index 00000000..61e33a60 --- /dev/null +++ b/external/obs/subathonmanager.lua @@ -0,0 +1,154 @@ +-- SubathonManager OBS helper script +-- https://github.com/WolfwithSword/SubathonManager +-- +-- Install: OBS -> Tools -> Scripts -> "+" -> select this file. + +local obs = obslua + +local HOTKEY_NAME = "subathonmanager_apply_tweaks" +local MARKER_KEY = "subathon_managed" +local REQUEST_KEY = "subathon_blend_request" +local STATES_KEY = "subathon_blend_states" +local SCRIPT_VERSION = "1.2.3" +local VERSION_HOTKEY_PREFIX = "subathonmanager_version_" + +local hotkey_id = nil +local version_hotkey_id = nil + +local collected = {} + +local function method_to_name(method) + if method == obs.OBS_BLEND_METHOD_SRGB_OFF then return "srgb_off" end + return "default" +end + +local function handle_item(scene_name, item) + local src = obs.obs_sceneitem_get_source(item) + if src == nil then return end + if obs.obs_source_get_unversioned_id(src) ~= "browser_source" then return end + + local settings = obs.obs_source_get_settings(src) + if settings == nil then return end + + if obs.obs_data_get_bool(settings, MARKER_KEY) then + local name = obs.obs_source_get_name(src) + local entry = collected[name] + if entry == nil then + entry = { states = {}, applied = false } + collected[name] = entry + end + + local item_id = obs.obs_sceneitem_get_id(item) + + local request = obs.obs_data_get_obj(settings, REQUEST_KEY) + if request ~= nil then + local method = obs.obs_data_get_string(request, "method") + local target_scene = obs.obs_data_get_string(request, "scene") + local target_item = obs.obs_data_get_int(request, "item") + local targeted = (target_scene == nil or target_scene == "") + or (target_scene == scene_name and target_item == item_id) + + if method ~= nil and method ~= "" and targeted then + if method == "srgb_off" then + obs.obs_sceneitem_set_blending_method(item, obs.OBS_BLEND_METHOD_SRGB_OFF) + elseif method == "default" then + obs.obs_sceneitem_set_blending_method(item, obs.OBS_BLEND_METHOD_DEFAULT) + end + entry.applied = true + end + obs.obs_data_release(request) + end + + entry.states[scene_name .. "|" .. tostring(item_id)] = + method_to_name(obs.obs_sceneitem_get_blending_method(item)) + end + + obs.obs_data_release(settings) +end + +local function process_scene(scene, scene_name) + if scene == nil then return end + + local items = obs.obs_scene_enum_items(scene) + if items == nil then return end + + for _, item in ipairs(items) do + handle_item(scene_name, item) + if obs.obs_sceneitem_is_group(item) then + local group_src = obs.obs_sceneitem_get_source(item) + if group_src ~= nil then + process_scene(obs.obs_sceneitem_group_get_scene(item), + obs.obs_source_get_name(group_src)) + end + end + end + + obs.sceneitem_list_release(items) +end + +local function apply_tweaks() + collected = {} + + local scenes = obs.obs_frontend_get_scenes() + if scenes ~= nil then + for _, scene_source in ipairs(scenes) do + process_scene(obs.obs_scene_from_source(scene_source), + obs.obs_source_get_name(scene_source)) + end + obs.source_list_release(scenes) + end + + for name, entry in pairs(collected) do + local src = obs.obs_get_source_by_name(name) + if src ~= nil then + local update = obs.obs_data_create() + + local states = obs.obs_data_create() + for key, value in pairs(entry.states) do + obs.obs_data_set_string(states, key, value) + end + obs.obs_data_set_obj(update, STATES_KEY, states) + obs.obs_data_release(states) + + if entry.applied then + local cleared = obs.obs_data_create() + obs.obs_data_set_string(cleared, "method", "") + obs.obs_data_set_obj(update, REQUEST_KEY, cleared) + obs.obs_data_release(cleared) + end + + obs.obs_source_update(src, update) + obs.obs_data_release(update) + obs.obs_source_release(src) + end + end + + collected = {} +end + +local function on_hotkey(pressed) + if pressed then apply_tweaks() end +end + +local function on_frontend_event(event) + if event == obs.OBS_FRONTEND_EVENT_FINISHED_LOADING or + event == obs.OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGED then + apply_tweaks() + end +end + +function script_description() + return "SubathonManager helper (v" .. SCRIPT_VERSION .. ")\n\n" .. + "Lets SubathonManager automate source tweaks it cannot over websocket.\n\n" .. + "Leave this script loaded; SubathonManager detects it automatically." +end + +function script_load(settings) + hotkey_id = obs.obs_hotkey_register_frontend(HOTKEY_NAME, + "SubathonManager: Apply overlay source tweaks", on_hotkey) + version_hotkey_id = obs.obs_hotkey_register_frontend( + VERSION_HOTKEY_PREFIX .. SCRIPT_VERSION, + "SubathonManager: version marker (do not bind)", + function(pressed) end) + obs.obs_frontend_add_event_callback(on_frontend_event) +end diff --git a/external/streamdeck/README.md b/external/streamdeck/README.md new file mode 100644 index 00000000..4d2a8b80 --- /dev/null +++ b/external/streamdeck/README.md @@ -0,0 +1,27 @@ +# SubathonManager - Elgato Stream Deck Plugin + +A Stream Deck plugin that sends SubathonManager commands over the +app's websocket (`ws://127.0.0.1:/ws`) + +- On connect it identifies itself with `{ "ws_type": "IntegrationSource", "source": "StreamDeck" }`, connection status visible in SubathonManager +- It requests the live command catalog with `{ "ws_type": "Command", "request": "commands" }` to fetch all current commands +- A key press sends + `{ "ws_type": "Command", "type": "Command", "command": "", "message": "", "user": "EXTERNAL", "source": "StreamDeck", "context": "" }` + and shows the Stream Deck OK/alert overlay based on the app's `command_ack` reply. + +## Parameter examples + +| Command | Parameter | +|---------|-----------| +| Add/Remove/Set Time | `10m30s`, `1h`, `90s` | +| Add/Remove/Set Points | `500` | +| Add/Remove Money | `5 USD` | +| Set Multiplier | `2xtp 10m` (x = multiplier, t = time, p = points) | +| Add/Set/Remove Wheel Spins | `2` | + +Many commands do not require parameters + +## Setup + +- `package.ps1` makes a `SubathonManager_StreamDeck.streamDeckPlugin` - double-click to install in the Stream Deck app. +- Can also grab it from the CI/CD or release diff --git a/external/streamdeck/com.wolfwithsword.subathonmanager.sdPlugin/manifest.json b/external/streamdeck/com.wolfwithsword.subathonmanager.sdPlugin/manifest.json new file mode 100644 index 00000000..2c086101 --- /dev/null +++ b/external/streamdeck/com.wolfwithsword.subathonmanager.sdPlugin/manifest.json @@ -0,0 +1,37 @@ +{ + "SDKVersion": 2, + "Author": "WolfwithSword", + "Name": "SubathonManager", + "Description": "Control SubathonManager from your Stream Deck. Send any SubathonManager command with a key press. Requires the SubathonManager app to be running.", + "URL": "https://github.com/WolfwithSword/SubathonManager/external/streamdeck", + "Version": "1.0.1", + "Icon": "images/pluginIcon", + "CodePath": "plugin/index.html", + "PropertyInspectorPath": "pi/index.html", + "Category": "SubathonManager", + "CategoryIcon": "images/categoryIcon", + "OS": [ + { + "Platform": "windows", + "MinimumVersion": "10" + } + ], + "Software": { + "MinimumVersion": "5.0" + }, + "Actions": [ + { + "Icon": "images/actionIcon", + "Name": "Subathon Command", + "States": [ + { + "Image": "images/keyIcon", + "TitleAlignment": "bottom" + } + ], + "SupportedInMultiActions": true, + "Tooltip": "Sends a command to SubathonManager.", + "UUID": "com.wolfwithsword.subathonmanager.command" + } + ] +} diff --git a/external/streamdeck/com.wolfwithsword.subathonmanager.sdPlugin/pi/index.html b/external/streamdeck/com.wolfwithsword.subathonmanager.sdPlugin/pi/index.html new file mode 100644 index 00000000..5e6bbe01 --- /dev/null +++ b/external/streamdeck/com.wolfwithsword.subathonmanager.sdPlugin/pi/index.html @@ -0,0 +1,212 @@ + + + + +SubathonManager Command + + + +
+
+
Status
+
+ + Not connected +
+
+
+
App Port
+
+ +
+
+
SubathonManager WebServer port (default 14040). Shared by all keys.
+
+
Command
+
+ +
+
+ + +
+ + + + diff --git a/external/streamdeck/com.wolfwithsword.subathonmanager.sdPlugin/plugin/index.html b/external/streamdeck/com.wolfwithsword.subathonmanager.sdPlugin/plugin/index.html new file mode 100644 index 00000000..d2312ae7 --- /dev/null +++ b/external/streamdeck/com.wolfwithsword.subathonmanager.sdPlugin/plugin/index.html @@ -0,0 +1,10 @@ + + + + + SubathonManager Plugin + + + + + diff --git a/external/streamdeck/com.wolfwithsword.subathonmanager.sdPlugin/plugin/plugin.js b/external/streamdeck/com.wolfwithsword.subathonmanager.sdPlugin/plugin/plugin.js new file mode 100644 index 00000000..ff68efe3 --- /dev/null +++ b/external/streamdeck/com.wolfwithsword.subathonmanager.sdPlugin/plugin/plugin.js @@ -0,0 +1,192 @@ +// SubathonManager - Stream Deck plugin + +const DEFAULT_APP_PORT = 14040; +const APP_RECONNECT_MS = 5000; +const APP_PING_MS = 15000; +const ACK_TIMEOUT_MS = 3000; + +let sdSocket = null; +let sdPluginUUID = null; + +let globalSettings = { port: DEFAULT_APP_PORT }; + +let appSocket = null; +let appConnected = false; +let appCommands = []; // [{ command, description, requires_parameter, is_control }] +let appReconnectTimer = null; +let appPingTimer = null; + +let lastPI = null; + +const pendingAcks = new Map(); + +// register +function connectElgatoStreamDeckSocket(inPort, inPluginUUID, inRegisterEvent, inInfo) { + sdPluginUUID = inPluginUUID; + sdSocket = new WebSocket('ws://127.0.0.1:' + inPort); + + sdSocket.onopen = () => { + sdSend({ event: inRegisterEvent, uuid: inPluginUUID }); + sdSend({ event: 'getGlobalSettings', context: sdPluginUUID }); + connectApp(); + }; + + sdSocket.onmessage = (evt) => { + let msg; + try { msg = JSON.parse(evt.data); } catch { return; } + + switch (msg.event) { + case 'didReceiveGlobalSettings': { + const settings = (msg.payload && msg.payload.settings) || {}; + const newPort = parsePort(settings.port); + const changed = newPort !== globalSettings.port; + globalSettings = { port: newPort }; + if (changed) reconnectApp(); + break; + } + case 'keyDown': + handleKeyDown(msg.context, (msg.payload && msg.payload.settings) || {}); + break; + case 'propertyInspectorDidAppear': + lastPI = { action: msg.action, context: msg.context }; + pushStateToPI(); + break; + case 'sendToPlugin': { + lastPI = { action: msg.action, context: msg.context }; + const payload = msg.payload || {}; + if (payload.event === 'getCommands') pushStateToPI(); + if (payload.event === 'setPort') { + const newPort = parsePort(payload.port); + const changed = newPort !== globalSettings.port; + globalSettings = { port: newPort }; + sdSend({ event: 'setGlobalSettings', context: sdPluginUUID, payload: globalSettings }); + if (changed) reconnectApp(); + pushStateToPI(); + } + break; + } + } + }; +} + +function sdSend(obj) { + if (sdSocket && sdSocket.readyState === WebSocket.OPEN) + sdSocket.send(JSON.stringify(obj)); +} + +function parsePort(value) { + const port = parseInt(value, 10); + return Number.isFinite(port) && port > 0 && port <= 65535 ? port : DEFAULT_APP_PORT; +} + + +function connectApp() { + // subathonmanager conn + if (appSocket && (appSocket.readyState === WebSocket.OPEN || appSocket.readyState === WebSocket.CONNECTING)) + return; + + appSocket = new WebSocket('ws://127.0.0.1:' + globalSettings.port + '/ws'); + + appSocket.onopen = () => { + appConnected = true; + appSend({ ws_type: 'hello', origin: 'StreamDeck Plugin' }); + appSend({ ws_type: 'IntegrationSource', source: 'StreamDeck' }); + appSend({ ws_type: 'Command', request: 'commands' }); + startAppPing(); + pushStateToPI(); + }; + + appSocket.onmessage = (evt) => { + let data; + try { data = JSON.parse(evt.data); } catch { return; } + + if (data.type === 'command_list' && Array.isArray(data.commands)) { + appCommands = data.commands; + pushStateToPI(); + } + else if (data.type === 'command_ack' && data.context) { + const pending = pendingAcks.get(data.context); + if (pending) { + clearTimeout(pending); + pendingAcks.delete(data.context); + sdSend({ event: data.success ? 'showOk' : 'showAlert', context: data.context }); + } + } + }; + + appSocket.onclose = () => { + appConnected = false; + stopAppPing(); + pushStateToPI(); + if (appReconnectTimer) clearTimeout(appReconnectTimer); + appReconnectTimer = setTimeout(connectApp, APP_RECONNECT_MS); + }; + + appSocket.onerror = () => { + try { appSocket.close(); } catch { /**/ } + }; +} + +function reconnectApp() { + if (appReconnectTimer) clearTimeout(appReconnectTimer); + if (appSocket) { + try { appSocket.close(); } catch { /**/ } + } + appReconnectTimer = setTimeout(connectApp, 100); +} + +function appSend(obj) { + if (appSocket && appSocket.readyState === WebSocket.OPEN) + appSocket.send(JSON.stringify(obj)); +} + +function startAppPing() { + stopAppPing(); + appPingTimer = setInterval(() => appSend({ ws_type: 'ping' }), APP_PING_MS); +} + +function stopAppPing() { + if (appPingTimer) clearInterval(appPingTimer); + appPingTimer = null; +} + +function handleKeyDown(context, settings) { + const command = settings.command; + if (!command || !appConnected) { + sdSend({ event: 'showAlert', context: context }); + return; + } + + const parameter = (settings.parameter || '').trim(); + + appSend({ + ws_type: 'Command', + type: 'Command', + command: command, + message: parameter, + user: 'EXTERNAL', + source: 'StreamDeck', + context: context + }); + + if (pendingAcks.has(context)) clearTimeout(pendingAcks.get(context)); + pendingAcks.set(context, setTimeout(() => { + pendingAcks.delete(context); + sdSend({ event: 'showAlert', context: context }); + }, ACK_TIMEOUT_MS)); +} + +function pushStateToPI() { + if (!lastPI) return; + sdSend({ + event: 'sendToPropertyInspector', + action: lastPI.action, + context: lastPI.context, + payload: { + event: 'state', + connected: appConnected, + commands: appCommands, + port: globalSettings.port + } + }); +} diff --git a/external/streamdeck/package.ps1 b/external/streamdeck/package.ps1 new file mode 100644 index 00000000..32cec308 --- /dev/null +++ b/external/streamdeck/package.ps1 @@ -0,0 +1,39 @@ +# makes the StreamDeck plugin bits into a .streamDeckPlugin file locally + +Add-Type -AssemblyName System.Drawing + +$pluginDir = Join-Path $PSScriptRoot "com.wolfwithsword.subathonmanager.sdPlugin" +$imagesDir = Join-Path $pluginDir "images" +$sourceIcon = Join-Path $PSScriptRoot "../../assets/icon.png" +$output = Join-Path $PSScriptRoot "SubathonManager_StreamDeck.streamDeckPlugin" + +if (-not (Test-Path $sourceIcon)) { throw "Missing $sourceIcon" } +New-Item -ItemType Directory -Force $imagesDir | Out-Null + +function New-ResizedIcon([string]$name, [int]$size) { + $src = [System.Drawing.Image]::FromFile((Resolve-Path $sourceIcon)) + $bmp = New-Object System.Drawing.Bitmap($size, $size) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $g.DrawImage($src, 0, 0, $size, $size) + $g.Dispose() + $src.Dispose() + $bmp.Save((Join-Path $imagesDir $name), [System.Drawing.Imaging.ImageFormat]::Png) + $bmp.Dispose() +} + +New-ResizedIcon "pluginIcon.png" 28 +New-ResizedIcon "pluginIcon@2x.png" 56 +New-ResizedIcon "categoryIcon.png" 28 +New-ResizedIcon "categoryIcon@2x.png" 56 +New-ResizedIcon "actionIcon.png" 20 +New-ResizedIcon "actionIcon@2x.png" 40 +New-ResizedIcon "keyIcon.png" 72 +New-ResizedIcon "keyIcon@2x.png" 144 + +if (Test-Path $output) { Remove-Item $output -Force } + +Compress-Archive -Path $pluginDir -DestinationPath "$output.zip" +Move-Item "$output.zip" $output -Force + +Write-Host "Wrote $output" diff --git a/external/streamerbot/SubathonManager_Extension.sb b/external/streamerbot/SubathonManager_Extension.sb index e9fa123f..43e2ba2b 100644 --- a/external/streamerbot/SubathonManager_Extension.sb +++ b/external/streamerbot/SubathonManager_Extension.sb @@ -1 +1 @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/external/streamerbot/SubathonManager_Generic_Example.sb b/external/streamerbot/SubathonManager_Generic_Example.sb deleted file mode 100644 index f33c597b..00000000 --- a/external/streamerbot/SubathonManager_Generic_Example.sb +++ /dev/null @@ -1 +0,0 @@ -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 \ No newline at end of file diff --git a/external/streamerbot/SubathonManager_KoFi.sb b/external/streamerbot/SubathonManager_KoFi.sb deleted file mode 100644 index 51e80fb7..00000000 --- a/external/streamerbot/SubathonManager_KoFi.sb +++ /dev/null @@ -1 +0,0 @@ -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 \ No newline at end of file diff --git a/presets/generic/wheelslots/wheel_slots.css b/presets/generic/wheelslots/wheel_slots.css index 9023c323..e0ec55c9 100644 --- a/presets/generic/wheelslots/wheel_slots.css +++ b/presets/generic/wheelslots/wheel_slots.css @@ -268,3 +268,13 @@ z-index: 7; pointer-events: none; } + +.slots-scene { + transition: transform 0.4s cubic-bezier(0.34, 1.3, 0.64, 1), opacity 0.35s ease; +} +.slots-scene.auto-hidden { + transform: scale(0); + opacity: 0; + pointer-events: none; + transition: transform 0.35s ease-in, opacity 0.3s ease-in; +} diff --git a/presets/generic/wheelslots/wheel_slots.html b/presets/generic/wheelslots/wheel_slots.html index 520a544f..bccfb179 100644 --- a/presets/generic/wheelslots/wheel_slots.html +++ b/presets/generic/wheelslots/wheel_slots.html @@ -71,6 +71,35 @@ const resultTextEl = document.getElementById('result-text'); const lcdScreenEl = document.getElementById('lcd-screen'); const slotsWindowEl = document.getElementById('slots-window'); +const sceneEl = document.getElementById('slots-scene'); + +const autoHide = typeof autoShowHide !== 'undefined' && autoShowHide; +if (autoHide) { + sceneEl.style.transition = 'none'; + sceneEl.classList.add('auto-hidden'); + const enableSceneAnim = () => requestAnimationFrame(() => + requestAnimationFrame(() => { sceneEl.style.transition = ''; })); + if (document.readyState === 'complete') enableSceneAnim(); + else window.addEventListener('load', enableSceneAnim, { once: true }); +} + +function resultDisplayMs() { + const s = typeof resultDisplaySeconds !== 'undefined' ? parseFloat(resultDisplaySeconds) : 90; + return Math.max(1, isFinite(s) ? s : 90) * 1000; +} + +function showScene() { + if (autoHide) sceneEl.classList.remove('auto-hidden'); +} + +function hideScene() { + if (!autoHide || spinState !== 'idle') return; + sceneEl.classList.add('auto-hidden'); + setTimeout(() => { + if (pendingWheelData && spinState === 'idle' + && sceneEl.classList.contains('auto-hidden')) applyWheelItems(pendingWheelData); + }, 500); +} function cssVar(name, fallback) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; @@ -181,17 +210,27 @@ resultTimer = setTimeout(() => { resultTimer = null; dismissNameplate(true); - if (pendingWheelData) applyWheelItems(pendingWheelData); - }, 90000); + if (pendingWheelData && !autoHide) applyWheelItems(pendingWheelData); + }, resultDisplayMs()); } function dismissNameplate(_animated) { if (resultTimer) { clearTimeout(resultTimer); resultTimer = null; } if (!resultShown) return; resultShown = false; + if (autoHide && spinState === 'idle' && !sceneEl.classList.contains('auto-hidden')) { + hideScene(); + setTimeout(() => { + resultDisplayEl.classList.add('hidden'); + spinsDisplayEl.classList.remove('hidden'); + applyHubVisibility(); + }, 500); + return; + } resultDisplayEl.classList.add('hidden'); spinsDisplayEl.classList.remove('hidden'); applyHubVisibility(); + hideScene(); } function startDecel(targetPos) { @@ -213,6 +252,8 @@ resultTime = Date.now(); showNameplate(pendingWinText); pendingWinText = null; + } else { + hideScene(); } } @@ -268,9 +309,12 @@ return; } const age = resultTime > 0 ? (Date.now() - resultTime) / 1000 : Infinity; - + if (autoHide && !sceneEl.classList.contains('auto-hidden')) { + pendingWheelData = data; + if (resultShown && age >= 5) dismissNameplate(true); + return; + } if (resultShown && age >= 5) dismissNameplate(true); - applyWheelItems(data); } @@ -281,6 +325,7 @@ lastTickItemIdx = Math.floor(drumPos / ITEM_H); spinState = 'spinning'; spinSpeed = 0; + showScene(); if (!rafId) startRaf(); } diff --git a/presets/generic/wheelslots/wheel_slots.html.json b/presets/generic/wheelslots/wheel_slots.html.json index 046034fe..0f1d9bd1 100644 --- a/presets/generic/wheelslots/wheel_slots.html.json +++ b/presets/generic/wheelslots/wheel_slots.html.json @@ -27,6 +27,18 @@ "Type": "Boolean", "Description": "Enable to show count of spins remaining", "Value": true + }, + "autoShowHide": { + "Name": "autoShowHide", + "Type": "Boolean", + "Description": "Hide the slot machine while idle. It pops in when a spin starts and pops away after the result", + "Value": false + }, + "resultDisplaySeconds": { + "Name": "resultDisplaySeconds", + "Type": "Float", + "Description": "How many seconds the result display stays visible before clearing or hiding", + "Value": 8 } } } diff --git a/presets/popups/alert/event-alert.html b/presets/popups/alert/event-alert.html index cf8ab896..1dbde0b3 100644 --- a/presets/popups/alert/event-alert.html +++ b/presets/popups/alert/event-alert.html @@ -171,6 +171,9 @@ if (sub == "DonationLike") return `${value} ${currency}`.trim(); if (sub == "FollowLike") return ""; if (sub == "OrderLike") { + if (data.type_true_source.toLowerCase() == "treatstream") { + return data.tertiary_value; + } if (data.type_true_source.toLowerCase() == "throne") { if (data.event_type.toLowerCase().includes("contribution")) { return data.currency + " " + data.value + " - " + data.secondary_value; diff --git a/presets/retro-pixel/goal-bar.html b/presets/retro-pixel/goal-bar.html index f08fd45b..ee7b83af 100644 --- a/presets/retro-pixel/goal-bar.html +++ b/presets/retro-pixel/goal-bar.html @@ -1,7 +1,7 @@ 
-
Waiting for goals…
+
Waiting for goals‥
@@ -52,7 +52,7 @@ function render() { if (!goals.length) { barFill.style.width = "0%"; - goalText.textContent = "Waiting for goals…"; + goalText.textContent = "Waiting for goals..."; goalProgress.textContent = ""; return; } diff --git a/presets/retro-pixel/wheel.css b/presets/retro-pixel/wheel.css index d7f174f0..831a4723 100644 --- a/presets/retro-pixel/wheel.css +++ b/presets/retro-pixel/wheel.css @@ -239,4 +239,13 @@ html, body { 75% { transform: translateX(-50%) translateY(-1px); } 90% { transform: translateX(-50%) translateY(0); } 100% { transform: translateX(-50%) translateY(0); } -} \ No newline at end of file +} + +.wheel-scene { + transition: transform 0.4s steps(6), opacity 0.35s steps(6); +} +.wheel-scene.auto-hidden { + transform: scale(0); + opacity: 0; + pointer-events: none; +} diff --git a/presets/retro-pixel/wheel.html b/presets/retro-pixel/wheel.html index 4efbaca5..80d056d7 100644 --- a/presets/retro-pixel/wheel.html +++ b/presets/retro-pixel/wheel.html @@ -63,6 +63,35 @@ const nameplatEl = document.getElementById('nameplate'); const npText = document.getElementById('nameplate-text'); const pointerNub = document.getElementById('pointer-nub'); +const sceneEl = document.getElementById('wheel-scene'); + +const autoHide = typeof autoShowHide !== 'undefined' && autoShowHide; +if (autoHide) { + sceneEl.style.transition = 'none'; + sceneEl.classList.add('auto-hidden'); + const enableSceneAnim = () => requestAnimationFrame(() => + requestAnimationFrame(() => { sceneEl.style.transition = ''; })); + if (document.readyState === 'complete') enableSceneAnim(); + else window.addEventListener('load', enableSceneAnim, { once: true }); +} + +function resultDisplayMs() { + const s = typeof resultDisplaySeconds !== 'undefined' ? parseFloat(resultDisplaySeconds) : 90; + return Math.max(1, isFinite(s) ? s : 90) * 1000; +} + +function showScene() { + if (autoHide) sceneEl.classList.remove('auto-hidden'); +} + +function hideScene() { + if (!autoHide || spinState !== 'idle') return; + sceneEl.classList.add('auto-hidden'); + setTimeout(() => { + if (pendingWheelData && spinState === 'idle' + && sceneEl.classList.contains('auto-hidden')) applyWheelItems(pendingWheelData); + }, 500); +} function cssVar(name, fallback) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; @@ -144,9 +173,9 @@ const maxWidth = SLICE_R - CX * 0.032 - startR; let label = item.text || ''; if (ctx.measureText(label).width > maxWidth) { - while (label.length > 1 && ctx.measureText(label + '…').width > maxWidth) + while (label.length > 1 && ctx.measureText(label + '‥').width > maxWidth) label = label.slice(0, -1); - label += '…'; + label += '‥'; } ctx.lineWidth = Math.max(2, CX * 0.010); @@ -211,8 +240,8 @@ nameplateTimer = setTimeout(() => { nameplateTimer = null; dismissNameplate(true); - if (pendingWheelData) applyWheelItems(pendingWheelData); - }, 90000); + if (pendingWheelData && !autoHide) applyWheelItems(pendingWheelData); + }, resultDisplayMs()); } function dismissNameplate(animated) { @@ -230,6 +259,7 @@ nameplatEl.classList.add('hidden'); nameplatEl.classList.remove('entering', 'exiting'); } + hideScene(); } function updateNubBounce() { @@ -284,6 +314,8 @@ resultTime = Date.now(); showNameplate(pendingWinText); pendingWinText = null; + } else { + hideScene(); } } @@ -341,6 +373,11 @@ } const age = resultTime > 0 ? (Date.now() - resultTime) / 1000 : Infinity; + if (autoHide && !sceneEl.classList.contains('auto-hidden')) { + pendingWheelData = data; + if (nameplateShown && age >= 5) dismissNameplate(true); + return; + } if (nameplateShown && age >= 5) dismissNameplate(true); applyWheelItems(data); } @@ -352,6 +389,7 @@ nubReset(); lastTickTime = 0; spinState = 'spinning'; spinSpeed = 0; + showScene(); if (!rafId) startRaf(); } diff --git a/presets/retro-pixel/wheel.html.json b/presets/retro-pixel/wheel.html.json index a5439fb0..699ce5f7 100644 --- a/presets/retro-pixel/wheel.html.json +++ b/presets/retro-pixel/wheel.html.json @@ -37,6 +37,18 @@ "Description": "Enable to show count of spins remaining", "Value": true }, + "autoShowHide": { + "Name": "autoShowHide", + "Type": "Boolean", + "Description": "Hide the wheel while idle. It pops in when a spin starts and pops away with the result", + "Value": false + }, + "resultDisplaySeconds": { + "Name": "resultDisplaySeconds", + "Type": "Float", + "Description": "How many seconds the result popup stays visible before hiding", + "Value": 8 + }, "GoogleFonts" : { "type": "GoogleFont", "description": "Additional google fonts to load to use in CSS Variables. Comma separated.", diff --git a/presets/simple-1/wheel.css b/presets/simple-1/wheel.css index 468804df..7cc9d69d 100644 --- a/presets/simple-1/wheel.css +++ b/presets/simple-1/wheel.css @@ -195,3 +195,14 @@ html, body { word-break: break-word; line-height: 1.4; } + + +.wheel-scene { + transition: transform 0.4s cubic-bezier(0.34, 1.3, 0.64, 1), opacity 0.35s ease; +} +.wheel-scene.auto-hidden { + transform: scale(0); + opacity: 0; + pointer-events: none; + transition: transform 0.35s ease-in, opacity 0.3s ease-in; +} diff --git a/presets/simple-1/wheel.html b/presets/simple-1/wheel.html index 5ef46690..00206c87 100644 --- a/presets/simple-1/wheel.html +++ b/presets/simple-1/wheel.html @@ -71,6 +71,36 @@ const hubLabel = document.querySelector('.hub-label'); const nameplatEl = document.getElementById('nameplate'); const npText = document.getElementById('nameplate-text'); +const sceneEl = document.getElementById('wheel-scene'); + +const autoHide = typeof autoShowHide !== 'undefined' && autoShowHide; +if (autoHide) { + sceneEl.style.transition = 'none'; + sceneEl.classList.add('auto-hidden'); + const enableSceneAnim = () => requestAnimationFrame(() => + requestAnimationFrame(() => { sceneEl.style.transition = ''; })); + if (document.readyState === 'complete') enableSceneAnim(); + else window.addEventListener('load', enableSceneAnim, { once: true }); +} + +function resultDisplayMs() { + const s = typeof resultDisplaySeconds !== 'undefined' ? parseFloat(resultDisplaySeconds) : 90; + return Math.max(1, isFinite(s) ? s : 90) * 1000; +} + +function showScene() { + if (autoHide) sceneEl.classList.remove('auto-hidden'); +} + +function hideScene() { + if (!autoHide || spinState !== 'idle') return; + sceneEl.classList.add('auto-hidden'); + setTimeout(() => { + if (pendingWheelData && spinState === 'idle' + && sceneEl.classList.contains('auto-hidden')) applyWheelItems(pendingWheelData); + }, 500); +} + function cssVar(name, fallback) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; } @@ -157,7 +187,7 @@ const avail = endR - startR; const maxChars = Math.max(3, Math.floor(avail / (fontSize * 0.62))); const raw = item.text || ''; - const label = raw.length > maxChars ? raw.slice(0, maxChars - 1) + '…' : raw; + const label = raw.length > maxChars ? raw.slice(0, maxChars - 1) + '‥' : raw; els.textEl.setAttribute('x', String(CX + startR)); els.textEl.setAttribute('y', String(CY)); @@ -187,8 +217,8 @@ nameplateTimer = setTimeout(() => { nameplateTimer = null; dismissNameplate(true); - if (pendingWheelData) applyWheelItems(pendingWheelData); - }, 90000); + if (pendingWheelData && !autoHide) applyWheelItems(pendingWheelData); + }, resultDisplayMs()); } function dismissNameplate(animated) { @@ -206,6 +236,7 @@ nameplatEl.classList.add('hidden'); nameplatEl.classList.remove('showing', 'hiding'); } + hideScene(); } function applyHubVisibility() { @@ -244,6 +275,8 @@ resultTime = Date.now(); showNameplate(pendingWinText); pendingWinText = null; + } else { + hideScene(); } } @@ -299,6 +332,11 @@ } const age = resultTime > 0 ? (Date.now() - resultTime) / 1000 : Infinity; + if (autoHide && !sceneEl.classList.contains('auto-hidden')) { + pendingWheelData = data; + if (nameplateShown && age >= 5) dismissNameplate(true); + return; + } if (nameplateShown && age >= 5) dismissNameplate(true); applyWheelItems(data); } @@ -309,6 +347,7 @@ pendingWinText = null; lastTickTime = 0; spinState = 'spinning'; spinSpeed = 0; + showScene(); if (!rafId) startRaf(); } diff --git a/presets/simple-1/wheel.html.json b/presets/simple-1/wheel.html.json index 476ce45a..b99a42e6 100644 --- a/presets/simple-1/wheel.html.json +++ b/presets/simple-1/wheel.html.json @@ -37,6 +37,18 @@ "Description": "Enable to show count of spins remaining", "Value": true }, + "autoShowHide": { + "Name": "autoShowHide", + "Type": "Boolean", + "Description": "Hide the wheel while idle. It pops in when a spin starts and pops away after the result", + "Value": false + }, + "resultDisplaySeconds": { + "Name": "resultDisplaySeconds", + "Type": "Float", + "Description": "How many seconds the result popup stays visible before hiding", + "Value": 8 + }, "CdnFonts": { "type": "CdnFont", "description": "Additional cdn fonts to load to use in CSS Variables. Comma separated.", diff --git a/presets/steampunk/wheel.css b/presets/steampunk/wheel.css index 0ee7f702..7db0da83 100644 --- a/presets/steampunk/wheel.css +++ b/presets/steampunk/wheel.css @@ -263,4 +263,14 @@ html, body { text-shadow: 0 0 8px rgba(232,200,128,0.4), 0 0 2px rgba(0,0,0,0.9); word-break: break-word; line-height: 1.3; -} \ No newline at end of file +} + +.wheel-scene { + transition: transform 0.4s cubic-bezier(0.34, 1.3, 0.64, 1), opacity 0.35s ease; +} +.wheel-scene.auto-hidden { + transform: scale(0); + opacity: 0; + pointer-events: none; + transition: transform 0.35s ease-in, opacity 0.3s ease-in; +} diff --git a/presets/steampunk/wheel.html b/presets/steampunk/wheel.html index 9041e2a6..c6cca4c7 100644 --- a/presets/steampunk/wheel.html +++ b/presets/steampunk/wheel.html @@ -81,6 +81,35 @@ const nameplatEl = document.getElementById('nameplate'); const npText = document.getElementById('nameplate-text'); const needlePivot = document.getElementById('needle-pivot-wrap'); +const sceneEl = document.getElementById('wheel-scene'); + +const autoHide = typeof autoShowHide !== 'undefined' && autoShowHide; +if (autoHide) { + sceneEl.style.transition = 'none'; + sceneEl.classList.add('auto-hidden'); + const enableSceneAnim = () => requestAnimationFrame(() => + requestAnimationFrame(() => { sceneEl.style.transition = ''; })); + if (document.readyState === 'complete') enableSceneAnim(); + else window.addEventListener('load', enableSceneAnim, { once: true }); +} + +function resultDisplayMs() { + const s = typeof resultDisplaySeconds !== 'undefined' ? parseFloat(resultDisplaySeconds) : 90; + return Math.max(1, isFinite(s) ? s : 90) * 1000; +} + +function showScene() { + if (autoHide) sceneEl.classList.remove('auto-hidden'); +} + +function hideScene() { + if (!autoHide || spinState !== 'idle') return; + sceneEl.classList.add('auto-hidden'); + setTimeout(() => { + if (pendingWheelData && spinState === 'idle' + && sceneEl.classList.contains('auto-hidden')) applyWheelItems(pendingWheelData); + }, 500); +} function cssVar(name, fallback) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; @@ -204,7 +233,7 @@ const avail = endR - startR; const maxChars = Math.max(3, Math.floor(avail / (fontSize * 0.62))); const raw = item.text || ''; - const label = raw.length > maxChars ? raw.slice(0, maxChars - 1) + '…' : raw; + const label = raw.length > maxChars ? raw.slice(0, maxChars - 1) + '‥' : raw; els.textEl.setAttribute('text-anchor', 'start'); els.textEl.setAttribute('x', String(CX + startR)); @@ -235,8 +264,8 @@ nameplateTimer = setTimeout(() => { nameplateTimer = null; dismissNameplate(true); - if (pendingWheelData) applyWheelItems(pendingWheelData); - }, 90000); + if (pendingWheelData && !autoHide) applyWheelItems(pendingWheelData); + }, resultDisplayMs()); } function dismissNameplate(animated) { @@ -254,6 +283,7 @@ nameplatEl.classList.add('hidden'); nameplatEl.classList.remove('showing', 'hiding'); } + hideScene(); } function updateNeedleBounce() { @@ -308,6 +338,8 @@ resultTime = Date.now(); showNameplate(pendingWinText); pendingWinText = null; + } else { + hideScene(); } } @@ -365,6 +397,11 @@ } const age = resultTime > 0 ? (Date.now() - resultTime) / 1000 : Infinity; + if (autoHide && !sceneEl.classList.contains('auto-hidden')) { + pendingWheelData = data; + if (nameplateShown && age >= 5) dismissNameplate(true); + return; + } if (nameplateShown && age >= 5) dismissNameplate(true); applyWheelItems(data); } @@ -377,6 +414,7 @@ needleReset(); lastTickTime = 0; spinState = 'spinning'; spinSpeed = 0; + showScene(); if (!rafId) startRaf(); } diff --git a/presets/steampunk/wheel.html.json b/presets/steampunk/wheel.html.json index 8ba8e78e..b693f7c6 100644 --- a/presets/steampunk/wheel.html.json +++ b/presets/steampunk/wheel.html.json @@ -36,6 +36,18 @@ "Type": "Boolean", "Description": "Enable to show count of spins remaining", "Value": true + }, + "autoShowHide": { + "Name": "autoShowHide", + "Type": "Boolean", + "Description": "Hide the wheel while idle. It pops in when a spin starts and pops away after the result", + "Value": false + }, + "resultDisplaySeconds": { + "Name": "resultDisplaySeconds", + "Type": "Float", + "Description": "How many seconds the result popup stays visible before hiding", + "Value": 8 } } }