From 8423eb8d286fac83db1ead533e5583d9b1cc1eaa Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:49:17 -0300 Subject: [PATCH 01/15] update updatum --- SubathonManager.Core/AppServices.cs | 5 ++--- SubathonManager.Core/SubathonManager.Core.csproj | 2 +- SubathonManager.UI/Views/SettingsView.axaml.cs | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/SubathonManager.Core/AppServices.cs b/SubathonManager.Core/AppServices.cs index daa67139..7b877a5d 100644 --- a/SubathonManager.Core/AppServices.cs +++ b/SubathonManager.Core/AppServices.cs @@ -2,7 +2,7 @@ using System.Reflection; using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; -using Updatum; +using StageKit.Updatum; namespace SubathonManager.Core; @@ -99,8 +99,7 @@ public static async Task InstallUpdate(UpdatumDownloadedAsset? asset, ILog return false; try { - await AppUpdater.InstallUpdateAsync(asset); - return true; + return await AppUpdater.InstallUpdateAsync(asset); } catch (Exception ex) { logger?.LogWarning(ex, "Failed to install update"); diff --git a/SubathonManager.Core/SubathonManager.Core.csproj b/SubathonManager.Core/SubathonManager.Core.csproj index 71505809..fcdc7fc4 100644 --- a/SubathonManager.Core/SubathonManager.Core.csproj +++ b/SubathonManager.Core/SubathonManager.Core.csproj @@ -13,7 +13,7 @@ - + diff --git a/SubathonManager.UI/Views/SettingsView.axaml.cs b/SubathonManager.UI/Views/SettingsView.axaml.cs index 0bf8bae5..68d02bc2 100644 --- a/SubathonManager.UI/Views/SettingsView.axaml.cs +++ b/SubathonManager.UI/Views/SettingsView.axaml.cs @@ -128,8 +128,8 @@ private async void Updater_Click(object? sender, RoutedEventArgs e) { panel.Children.Add(new TextBlock { Text = "Download and install now?", Margin = new Thickness(0, 8, 0, 0), TextWrapping = TextWrapping.Wrap }); - panel.Children.Add(new TextBlock - { Text = "You will need to start the app manually once finished.", TextWrapping = TextWrapping.Wrap }); + // panel.Children.Add(new TextBlock + // { Text = "You will need to start the app manually once finished.", TextWrapping = TextWrapping.Wrap }); var dialog = new FAContentDialog { Title = "Updater", From 61fe4f37aa8236624eb3138383fa6b85f0b83786 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:10:02 -0300 Subject: [PATCH 02/15] test windows installer --- .github/workflows/build.yml | 63 ++++++++++++ .gitignore | 4 + installer/windows/SubathonManager.iss | 142 ++++++++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 installer/windows/SubathonManager.iss diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 318edcbb..ded64aae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -302,6 +302,14 @@ jobs: echo "file_version=0.0.0.0" >> $GITHUB_OUTPUT fi + if [[ "${GITHUB_REF}" == refs/tags/* ]] && [ "${GITHUB_REF_NAME}" != "nightly" ]; then + echo "release_tag=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT + echo "asset_suffix=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT + else + echo "release_tag=nightly" >> $GITHUB_OUTPUT + echo "asset_suffix=nightly" >> $GITHUB_OUTPUT + fi + - name: Inject telemetry key shell: bash run: | @@ -380,6 +388,59 @@ jobs: shell: pwsh run: Compress-Archive -Path build-avalonia/* -DestinationPath SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }}.zip + - name: Build Windows installer + if: matrix.runtime == 'win-x64' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + choco install innosetup --no-progress -y + + $iscc = @( + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "${env:ProgramFiles}\Inno Setup 6\ISCC.exe" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $iscc) { $iscc = (Get-Command ISCC.exe).Source } + + Copy-Item LICENSE installer\windows\LICENSE.txt -Force + + $assetSuffix = "${{ steps.avars.outputs.asset_suffix }}" + $localSuffix = "${{ steps.avars.outputs.suffix }}" + $asset = "SubathonManager_${{ matrix.runtime }}_$assetSuffix.zip" + $url = "https://github.com/${{ github.repository }}/releases/download/${{ steps.avars.outputs.release_tag }}/$asset" + $appVer = $localSuffix -replace '^v', '' + + $isccArgs = @( + '/Qp' + "/DAppVer=$appVer" + "/DFileVer=${{ steps.avars.outputs.file_version }}" + "/DZipUrl=$url" + "/DZipName=$asset" + '/Oinstaller-out' + ) + + if ($assetSuffix -eq $localSuffix -and $assetSuffix -ne 'nightly') { + $sha = (Get-FileHash $asset -Algorithm SHA256).Hash + $isccArgs += "/DZipSha256=$sha" + Write-Host "Installer sha256: $sha" + } else { + Write-Host "Installer sha256: (not pinned; nightly asset rotates)" + } + Write-Host "Installer payload: $url" + + $isccArgs += 'installer\windows\SubathonManager.iss' + & $iscc @isccArgs + if ($LASTEXITCODE -ne 0) { throw "ISCC failed with exit code $LASTEXITCODE" } + + Get-ChildItem installer-out + + - name: Upload Windows installer + if: matrix.runtime == 'win-x64' + uses: actions/upload-artifact@v7 + with: + name: SubathonManager_win-x64_Setup_${{ steps.avars.outputs.suffix }} + path: installer-out/*.exe + retention-days: 3 + - name: Resolve Nextcloud upload path shell: bash run: | @@ -737,6 +798,7 @@ jobs: with: files: | dist/*.zip + dist/*.exe *.sb *.streamDeckPlugin env: @@ -768,6 +830,7 @@ jobs: prerelease: true files: | dist/*.zip + dist/*.exe *.sb *.streamDeckPlugin token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 9feeb1ff..408b0504 100644 --- a/.gitignore +++ b/.gitignore @@ -234,3 +234,7 @@ coverage-local/ # Stream Deck plugin packaging stuff *.streamDeckPlugin external/streamdeck/**/images/ + +# Windows installer build output +installer/windows/LICENSE.txt +installer-out/ diff --git a/installer/windows/SubathonManager.iss b/installer/windows/SubathonManager.iss new file mode 100644 index 00000000..6fa615d5 --- /dev/null +++ b/installer/windows/SubathonManager.iss @@ -0,0 +1,142 @@ +; Thin network installer for SubathonManager (Windows). +; +; It downloads the portable zip from the matching GitHub release +; or nightly as default + +; Built by .github/workflows/build.yml. Required ISCC defines: +; AppVer display version, e.g. 2.0.3 or nightly +; FileVer numeric version for the version resource, e.g. 2.0.3.0 +; ZipUrl full https URL of the release asset +; ZipName asset file name +; ZipSha256 hex SHA-256 of the asset; omit to skip verification, which is what +; nightly does since that asset is replaced on every build + +#ifndef AppVer + #define AppVer "0.0.0" +#endif +#ifndef FileVer + #define FileVer "0.0.0.0" +#endif +#ifndef ZipUrl + #error ZipUrl must be defined +#endif +#ifndef ZipName + #error ZipName must be defined +#endif +#ifndef ZipSha256 + #define ZipSha256 "" +#endif + +#define AppName "SubathonManager" +#define AppPublisher "WolfwithSword" +#define AppURL "https://subathonmanager.app" +#define AppExe "SubathonManager.exe" + +[Setup] +AppId={{8F3A1C2E-5D47-4B9A-9E13-6C0F2A7B4D58} +AppName={#AppName} +AppVersion={#AppVer} +VersionInfoVersion={#FileVer} +AppPublisher={#AppPublisher} +AppPublisherURL={#AppURL} +AppSupportURL={#AppURL} +AppUpdatesURL=https://github.com/WolfwithSword/SubathonManager/releases + +; Per-user install. The app writes into its own folder +PrivilegesRequired=lowest +DefaultDirName={localappdata}\Programs\{#AppName} +DefaultGroupName={#AppName} +AllowNoIcons=yes +DisableProgramGroupPage=yes +UninstallDisplayIcon={app}\{#AppExe} +UninstallDisplayName={#AppName} + +ArchiveExtraction=full + +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +OutputBaseFilename=SubathonManager_win-x64_Setup_{#AppVer} +SetupIconFile=..\..\assets\icon.ico +WizardStyle=modern +Compression=lzma2/max +SolidCompression=yes +LicenseFile=LICENSE.txt + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +; Downloaded to {tmp} by the code below, then extracted +Source: "{tmp}\{#ZipName}"; DestDir: "{app}"; Flags: external extractarchive + +[Icons] +Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExe}" +Name: "{group}\{cm:UninstallProgram,{#AppName}}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExe}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#AppExe}"; Description: "{cm:LaunchProgram,{#AppName}}"; Flags: nowait postinstall skipifsilent + +[Code] +var + DownloadPage: TDownloadWizardPage; + +function OnDownloadProgress(const Url, FileName: String; const Progress, ProgressMax: Int64): Boolean; +begin + if Progress = ProgressMax then + Log('Downloaded ' + FileName); + Result := True; +end; + +procedure InitializeWizard; +begin + DownloadPage := CreateDownloadPage( + SetupMessage(msgWizardPreparing), SetupMessage(msgPreparingDesc), @OnDownloadProgress); +end; + +function NextButtonClick(CurPageID: Integer): Boolean; +begin + Result := True; + if CurPageID <> wpReady then + Exit; + + DownloadPage.Clear; + DownloadPage.Add('{#ZipUrl}', '{#ZipName}', '{#ZipSha256}'); + DownloadPage.Show; + try + try + DownloadPage.Download; + except + if DownloadPage.AbortedByUser then + Log('Download aborted by user.') + else + SuppressibleMsgBox(AddPeriod(GetExceptionMessage), mbCriticalError, MB_OK, IDOK); + Result := False; + end; + finally + DownloadPage.Hide; + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +var + AppDir: String; +begin + if CurUninstallStep <> usPostUninstall then + Exit; + + AppDir := ExpandConstant('{app}'); + if not DirExists(AppDir) then + Exit; + + if SuppressibleMsgBox( + 'Remove your SubathonManager data as well?' + #13#10#13#10 + + 'This deletes the subathon database, settings, and imported widgets and overlays in:' + + #13#10 + AppDir, + mbConfirmation, MB_YESNO or MB_DEFBUTTON2, IDNO) = IDYES then + DelTree(AppDir, True, True, True); +end; From f760a08baf5ea6cdf21c87da188b72b0744c75f6 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:23:03 -0300 Subject: [PATCH 03/15] fix installer build --- .github/workflows/build.yml | 22 ++++++++++++++++------ installer/windows/SubathonManager.iss | 12 ++++++------ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ded64aae..22338a8d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -393,13 +393,23 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - choco install innosetup --no-progress -y - $iscc = @( - "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", - "${env:ProgramFiles}\Inno Setup 6\ISCC.exe" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $iscc) { $iscc = (Get-Command ISCC.exe).Source } + function Find-Iscc { + $hit = @( + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "${env:ProgramFiles}\Inno Setup 6\ISCC.exe" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if ($hit) { return $hit } + return (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source + } + + $iscc = Find-Iscc + if (-not $iscc) { + choco install innosetup --no-progress -y + $iscc = Find-Iscc + } + if (-not $iscc) { throw 'Inno Setup compiler (ISCC.exe) not found' } + Write-Host "ISCC: $iscc" Copy-Item LICENSE installer\windows\LICENSE.txt -Force diff --git a/installer/windows/SubathonManager.iss b/installer/windows/SubathonManager.iss index 6fa615d5..799d56cb 100644 --- a/installer/windows/SubathonManager.iss +++ b/installer/windows/SubathonManager.iss @@ -124,7 +124,7 @@ end; procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); var - AppDir: String; + AppDir, Msg: String; begin if CurUninstallStep <> usPostUninstall then Exit; @@ -133,10 +133,10 @@ begin if not DirExists(AppDir) then Exit; - if SuppressibleMsgBox( - 'Remove your SubathonManager data as well?' + #13#10#13#10 + - 'This deletes the subathon database, settings, and imported widgets and overlays in:' + - #13#10 + AppDir, - mbConfirmation, MB_YESNO or MB_DEFBUTTON2, IDNO) = IDYES then + Msg := 'Remove your SubathonManager data as well?' + #13#10 + #13#10 + + 'This deletes the subathon database, settings, and imported widgets and overlays in:' + #13#10 + + AppDir; + + if SuppressibleMsgBox(Msg, mbConfirmation, MB_YESNO or MB_DEFBUTTON2, IDNO) = IDYES then DelTree(AppDir, True, True, True); end; From 306ec45c5bddcfabb2d08ba8bd1f4239fe54566f Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:30:48 -0300 Subject: [PATCH 04/15] fix installer flags --- installer/windows/SubathonManager.iss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/installer/windows/SubathonManager.iss b/installer/windows/SubathonManager.iss index 799d56cb..2f1ed448 100644 --- a/installer/windows/SubathonManager.iss +++ b/installer/windows/SubathonManager.iss @@ -71,7 +71,7 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ [Files] ; Downloaded to {tmp} by the code below, then extracted -Source: "{tmp}\{#ZipName}"; DestDir: "{app}"; Flags: external extractarchive +Source: "{tmp}\{#ZipName}"; DestDir: "{app}"; Flags: external extractarchive ignoreversion [Icons] Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExe}" From ab612b32779c1e07417466325519a794aa31616c Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:03:56 -0300 Subject: [PATCH 05/15] tweak installer --- .github/workflows/build.yml | 5 +++++ installer/windows/SubathonManager.iss | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 22338a8d..81f0a752 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -419,12 +419,17 @@ jobs: $url = "https://github.com/${{ github.repository }}/releases/download/${{ steps.avars.outputs.release_tag }}/$asset" $appVer = $localSuffix -replace '^v', '' + $size = (Get-ChildItem build-avalonia -Recurse -File | + Measure-Object -Property Length -Sum).Sum + Write-Host "Installer payload size: $size bytes" + $isccArgs = @( '/Qp' "/DAppVer=$appVer" "/DFileVer=${{ steps.avars.outputs.file_version }}" "/DZipUrl=$url" "/DZipName=$asset" + "/DZipSize=$size" '/Oinstaller-out' ) diff --git a/installer/windows/SubathonManager.iss b/installer/windows/SubathonManager.iss index 2f1ed448..2d9370b0 100644 --- a/installer/windows/SubathonManager.iss +++ b/installer/windows/SubathonManager.iss @@ -10,6 +10,7 @@ ; ZipName asset file name ; ZipSha256 hex SHA-256 of the asset; omit to skip verification, which is what ; nightly does since that asset is replaced on every build +; ZipSize total uncompressed size of the archive contents in bytes; required #ifndef AppVer #define AppVer "0.0.0" @@ -26,6 +27,9 @@ #ifndef ZipSha256 #define ZipSha256 "" #endif +#ifndef ZipSize + #error ZipSize must be defined +#endif #define AppName "SubathonManager" #define AppPublisher "WolfwithSword" @@ -71,7 +75,8 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ [Files] ; Downloaded to {tmp} by the code below, then extracted -Source: "{tmp}\{#ZipName}"; DestDir: "{app}"; Flags: external extractarchive ignoreversion +; recursesubdirs/createallsubdirs are required or only root-level entries come out. +Source: "{tmp}\{#ZipName}"; DestDir: "{app}"; Flags: external extractarchive ignoreversion recursesubdirs createallsubdirs; ExternalSize: {#ZipSize} [Icons] Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExe}" From 01ecda184b14280aae536b636865e3e71ac4ec7a Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:11:25 -0300 Subject: [PATCH 06/15] fix makeship on load #334 --- SubathonManager.Data/DbContext.cs | 5 ++++- SubathonManager.UI/App.axaml.cs | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/SubathonManager.Data/DbContext.cs b/SubathonManager.Data/DbContext.cs index 1452b2e0..f992c86f 100644 --- a/SubathonManager.Data/DbContext.cs +++ b/SubathonManager.Data/DbContext.cs @@ -261,7 +261,10 @@ public static async Task> GetSubathonCurrencyEvents(AppDbCon var orderTypesToInclude = new List(); foreach (SubathonEventType orderEvent in Enum.GetValues().Where(et => ((SubathonEventType?)et).IsOrder() && !et.IsDisabled() - && et.GetSource() != SubathonEventSource.GoAffPro)) { + && et.GetSource() != SubathonEventSource.GoAffPro + && et.GetSource() != SubathonEventSource.MakeShip + && et.GetSource() != SubathonEventSource.JuniperCreates + && et.GetSource() != SubathonEventSource.TreatStream)) { bool asDonation = Utils.DonationSettings.TryGetValue($"{orderEvent.ToString()?.Split("Order")[0]}", out bool donation) && donation; diff --git a/SubathonManager.UI/App.axaml.cs b/SubathonManager.UI/App.axaml.cs index ec1c5976..b1e9a05b 100644 --- a/SubathonManager.UI/App.axaml.cs +++ b/SubathonManager.UI/App.axaml.cs @@ -155,8 +155,13 @@ public override void OnFrameworkInitializationCompleted() { await AppDbContext.PauseAllTimers(context1); await using AppDbContext context2 = await _factory.CreateDbContextAsync(); await AppDbContext.ResetPowerHour(context2); - await using AppDbContext context3 = await _factory.CreateDbContextAsync(); - await SetupSubathonCurrencyData(context3, false); + try { + await using AppDbContext context3 = await _factory.CreateDbContextAsync(); + await SetupSubathonCurrencyData(context3, false); + } + catch (Exception ex) { + _logger?.LogError(ex, "Failed to recalculate subathon currency data on startup"); + } await sm.StartAsync(fireAndForget: true); await sm.StartAsync(fireAndForget: true); @@ -480,8 +485,11 @@ private async Task SetupSubathonCurrencyData(AppDbContext db, bool? optionToggle string value = ev.Value; string? curr = ev.Currency; if (ev.EventType.IsOrder()) { - value = ev.SecondaryValue.Split('|')[0]; - curr = ev.SecondaryValue.Split('|')[1]; + string[] parts = ev.SecondaryValue.Split('|'); + if (parts.Length < 2 || !double.TryParse(parts[0], out _)) continue; + value = parts[0]; + curr = parts[1]; + if (!currencyService.IsValidCurrency(curr)) continue; } double amt = await currencyService.ConvertAsync(double.Parse(value), curr, currency.ToUpper()); From 1cd215cea91d4a4ad0387ddef98f22c81b896f02 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:14:13 -0300 Subject: [PATCH 07/15] mixitup integration #333 --- .gitignore | 1 + SubathonManager.Core/Enums/MixItUpTrigger.cs | 40 ++ SubathonManager.Core/Enums/ProcessSearch.cs | 5 +- .../Enums/SubathonEventSource.cs | 10 +- .../Events/IntegrationEvents.cs | 6 + .../ExternalEventService.cs | 8 + SubathonManager.Integration/MixItUpService.cs | 622 ++++++++++++++++++ SubathonManager.Server/WebServer.Api.cs | 2 + .../MixItUpServiceTests.cs | 468 +++++++++++++ SubathonManager.UI/Services/ServiceManager.cs | 3 + .../Services/ServiceRegistration.cs | 2 + .../Views/SettingsView.Settings.cs | 1 + .../ExternalSoftware/MixItUpSettings.axaml | 57 ++ .../ExternalSoftware/MixItUpSettings.axaml.cs | 237 +++++++ .../ExternalSoftwareSettings.axaml.cs | 3 + 15 files changed, 1461 insertions(+), 4 deletions(-) create mode 100644 SubathonManager.Core/Enums/MixItUpTrigger.cs create mode 100644 SubathonManager.Integration/MixItUpService.cs create mode 100644 SubathonManager.Tests/IntegrationUnitTests/MixItUpServiceTests.cs create mode 100644 SubathonManager.UI/Views/SettingsViews/ExternalSoftware/MixItUpSettings.axaml create mode 100644 SubathonManager.UI/Views/SettingsViews/ExternalSoftware/MixItUpSettings.axaml.cs diff --git a/.gitignore b/.gitignore index 408b0504..516b7675 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ riderModule.iml exports/ imports/ cache/ +external/mixitup/ *.smo *.smw diff --git a/SubathonManager.Core/Enums/MixItUpTrigger.cs b/SubathonManager.Core/Enums/MixItUpTrigger.cs new file mode 100644 index 00000000..6396bfc7 --- /dev/null +++ b/SubathonManager.Core/Enums/MixItUpTrigger.cs @@ -0,0 +1,40 @@ +namespace SubathonManager.Core.Enums; + +public enum MixItUpTrigger { + [EnumMeta(Label = "Subathon Event", Order = 0, + Description = "Any subathon event (subs, donations, orders, etc.), and commands if enabled")] + SubathonEvent, + + [EnumMeta(Label = "Timer Paused", Order = 1, Description = "The subathon timer was paused")] + TimerPaused, + + [EnumMeta(Label = "Timer Resumed", Order = 2, Description = "The subathon timer was resumed")] + TimerResumed, + + [EnumMeta(Label = "Timer Locked", Order = 3, Description = "The subathon was locked")] + TimerLocked, + + [EnumMeta(Label = "Timer Unlocked", Order = 4, Description = "The subathon was unlocked")] + TimerUnlocked, + + [EnumMeta(Label = "Multiplier Started", Order = 5, Description = "A multiplier started")] + MultiplierStarted, + + [EnumMeta(Label = "Multiplier Ended", Order = 6, Description = "A multiplier ended or was stopped")] + MultiplierEnded, + + [EnumMeta(Label = "Goal Completed", Order = 7, Description = "A goal was reached")] + GoalCompleted, + + [EnumMeta(Label = "Wheel Spin Start", Order = 8, Description = "A wheel spin started")] + WheelSpinStart, + + [EnumMeta(Label = "Wheel Spin End", Order = 9, Description = "A wheel spin landed on a result")] + WheelSpinEnd, + + [EnumMeta(Label = "Prompt Started", Order = 10, Description = "A prompt run started")] + PromptStarted, + + [EnumMeta(Label = "Prompt Ended", Order = 11, Description = "A prompt run completed, expired or was cancelled")] + PromptEnded +} diff --git a/SubathonManager.Core/Enums/ProcessSearch.cs b/SubathonManager.Core/Enums/ProcessSearch.cs index 7d3cebab..8f89c436 100644 --- a/SubathonManager.Core/Enums/ProcessSearch.cs +++ b/SubathonManager.Core/Enums/ProcessSearch.cs @@ -11,5 +11,8 @@ public enum ProcessSearch { StreamerBot, [ProcessSearchMeta(QueryNames = ["Stream Deck", "StreamDeck"])] - StreamDeck + StreamDeck, + + [ProcessSearchMeta(QueryNames = ["MixItUp", "Mix It Up"])] + MixItUp } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonEventSource.cs b/SubathonManager.Core/Enums/SubathonEventSource.cs index 8de249d1..03785357 100644 --- a/SubathonManager.Core/Enums/SubathonEventSource.cs +++ b/SubathonManager.Core/Enums/SubathonEventSource.cs @@ -50,8 +50,8 @@ public enum SubathonEventSource { SourceOrder = 42, Visible = false, TrueSource = KoFi, Order = 41)] KoFiTunnel, - [EventSourceMeta(Description = "Dev Tunnels", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder = 904, - Visible = false, Order = 903)] + [EventSourceMeta(Description = "Dev Tunnels", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder = 994, + Visible = false, Order = 993)] DevTunnels, [EventSourceMeta(Description = "FourthWall", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder = 62, @@ -104,7 +104,11 @@ public enum SubathonEventSource { [EventSourceMeta(Description = "VTube Studio", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder = 905, Order = 904, Visible = false)] - VTubeStudio + VTubeStudio, + + [EventSourceMeta(Description = "Mix It Up", SourceGroup = SubathonSourceGroup.ExternalSoftware, + SourceOrder = 904, Order = 903, Visible = false, IsExternalSource = true)] + MixItUp } [ExcludeFromCodeCoverage] diff --git a/SubathonManager.Core/Events/IntegrationEvents.cs b/SubathonManager.Core/Events/IntegrationEvents.cs index 5f41bed1..a48323d1 100644 --- a/SubathonManager.Core/Events/IntegrationEvents.cs +++ b/SubathonManager.Core/Events/IntegrationEvents.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Enums; using SubathonManager.Core.Objects; namespace SubathonManager.Core.Events; @@ -8,12 +9,17 @@ public static class IntegrationEvents { public static event Action? ConnectionUpdated; // status, src, acc name, service public static event Action>? FourthWallMembershipsSynced; public static event Action? DevTunnelLegacyNotification; + public static event Action? ExternalSourceSeen; public static void RaiseConnectionUpdate(IntegrationConnection connection) { Utils.UpdateConnection(connection); ConnectionUpdated?.Invoke(connection); } + public static void RaiseExternalSourceSeen(SubathonEventSource source) { + ExternalSourceSeen?.Invoke(source); + } + public static void RaiseFourthWallMembershipsSynced(Dictionary synced) { FourthWallMembershipsSynced?.Invoke(synced); } diff --git a/SubathonManager.Integration/ExternalEventService.cs b/SubathonManager.Integration/ExternalEventService.cs index 4c0c03ee..679a3f65 100644 --- a/SubathonManager.Integration/ExternalEventService.cs +++ b/SubathonManager.Integration/ExternalEventService.cs @@ -12,6 +12,14 @@ namespace SubathonManager.Integration; public static class ExternalEventService { + public static void NotifySourceSeen(Dictionary data) { + if (data.TryGetValue("source", out JsonElement elemSrc) && elemSrc.ValueKind == JsonValueKind.String + && Enum.TryParse(elemSrc.GetString(), true, + out SubathonEventSource source) + && source.IsExternalSource()) + IntegrationEvents.RaiseExternalSourceSeen(source); + } + public static bool ProcessExternalCommand(Dictionary data) { data.TryGetValue("command", out JsonElement elemCmd); if (elemCmd.ValueKind == JsonValueKind.String && Enum.TryParse diff --git a/SubathonManager.Integration/MixItUpService.cs b/SubathonManager.Integration/MixItUpService.cs new file mode 100644 index 00000000..2e29d818 --- /dev/null +++ b/SubathonManager.Integration/MixItUpService.cs @@ -0,0 +1,622 @@ +using System.Globalization; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +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; + +namespace SubathonManager.Integration; + +public sealed record MixItUpCommandInfo(Guid Id, string Name, string Type, string GroupName, bool IsEnabled) { + public string DisplayName => string.IsNullOrWhiteSpace(GroupName) ? Name : $"{GroupName} / {Name}"; +} + +public class MixItUpService( + ILogger? logger, + IConfig config, + IHttpClientFactory httpClientFactory, + ITimerService timerService) : IAppService, IDisposable { + + public const string ConfigSection = "MixItUp"; + public const string DefaultApiUrl = "http://localhost:8911/api/v2"; + public const string ServiceName = "MixItUp"; + public const string IdentifierPrefix = "subathonmanager"; + public const string IncludeCommandsKey = "SubathonEvent.IncludeCommands"; + + internal static readonly TimeSpan SeenWindow = TimeSpan.FromMinutes(90); + internal static readonly TimeSpan ProbeInterval = TimeSpan.FromMinutes(10); + private const int CommandPageSize = 100; + private const int MaxCommandPages = 50; + private const string SeenTimerKey = "mixitup-seen"; + private const string ProbeTimerKey = "mixitup-probe"; + + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + private static readonly JsonSerializerOptions RequestJsonOptions = new(); + + private readonly Lock _lock = new(); + + private bool _lastLocked; + private MultiplierSnapshot? _lastMultiplier; + private bool _lastPaused; + private Guid? _trackedSubathonId; + + public static string CommandsFolder => Path.GetFullPath(Path.Combine("external", "mixitup")); + + public DateTime? LastSeen { get; private set; } + public string? Version { get; private set; } + public bool Connected => LastSeen != null; + + public bool Enabled => config.GetBool(ConfigSection, "Enabled"); + public bool IncludeCommands => config.GetBool(ConfigSection, IncludeCommandsKey, false); + + public string ApiUrl { + get { + string url = (config.Get(ConfigSection, "ApiUrl", DefaultApiUrl) ?? "").Trim().TrimEnd('/'); + return string.IsNullOrWhiteSpace(url) ? DefaultApiUrl : url; + } + } + + public static string CommandConfigKey(MixItUpTrigger trigger) { + return $"Command.{trigger}"; + } + + public string GetCommandId(MixItUpTrigger trigger) { + return (config.Get(ConfigSection, CommandConfigKey(trigger), "") ?? "").Trim(); + } + + public Task StartAsync(CancellationToken ct = default) { + Unsubscribe(); + Subscribe(); + BroadcastStatus(); + timerService.Register(ProbeTimerKey, ProbeInterval, () => _ = ProbeAsync(ct)); + _ = Task.Run(() => ProbeAsync(ct), ct); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken ct = default) { + Unsubscribe(); + timerService.Unregister(ProbeTimerKey); + timerService.Unregister(SeenTimerKey); + lock (_lock) { + LastSeen = null; + _trackedSubathonId = null; + _lastMultiplier = null; + } + + BroadcastStatus(); + return Task.CompletedTask; + } + + public void Dispose() { + Unsubscribe(); + timerService.Unregister(ProbeTimerKey); + timerService.Unregister(SeenTimerKey); + GC.SuppressFinalize(this); + } + + private void Subscribe() { + IntegrationEvents.ExternalSourceSeen += OnExternalSourceSeen; + SubathonEvents.SubathonEventProcessed += OnSubathonEventProcessed; + SubathonEvents.SubathonDataUpdate += OnSubathonDataUpdate; + SubathonEvents.SubathonGoalCompleted += OnGoalCompleted; + SubathonEvents.PromptRunStarted += OnPromptRunStarted; + SubathonEvents.PromptRunUpdate += OnPromptRunUpdate; + WheelEvents.WheelSpinStarted += OnWheelSpinStarted; + WheelEvents.WheelSpinResult += OnWheelSpinResult; + } + + private void Unsubscribe() { + IntegrationEvents.ExternalSourceSeen -= OnExternalSourceSeen; + SubathonEvents.SubathonEventProcessed -= OnSubathonEventProcessed; + SubathonEvents.SubathonDataUpdate -= OnSubathonDataUpdate; + SubathonEvents.SubathonGoalCompleted -= OnGoalCompleted; + SubathonEvents.PromptRunStarted -= OnPromptRunStarted; + SubathonEvents.PromptRunUpdate -= OnPromptRunUpdate; + WheelEvents.WheelSpinStarted -= OnWheelSpinStarted; + WheelEvents.WheelSpinResult -= OnWheelSpinResult; + } + + public async Task ProbeAsync(CancellationToken ct = default) { + try { + using HttpClient client = CreateClient(); + using HttpResponseMessage response = await client.GetAsync($"{ApiUrl}/status/version", ct); + if (!response.IsSuccessStatusCode) return false; + + string body = (await response.Content.ReadAsStringAsync(ct)).Trim(); + MarkSeen(DateTime.Now, ParseVersion(body)); + return true; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { + throw; + } + catch (Exception ex) { + logger?.LogDebug("[MixItUp] Probe failed: {Message}", ex.Message); + return false; + } + } + + internal static string? ParseVersion(string body) { + if (string.IsNullOrWhiteSpace(body)) return null; + try { + if (body.StartsWith('"')) return JsonSerializer.Deserialize(body); + } + catch (JsonException) { + /**/ + } + + return body; + } + + private void OnExternalSourceSeen(SubathonEventSource source) { + if (source == SubathonEventSource.MixItUp) MarkSeen(DateTime.Now); + } + + internal void MarkSeen(DateTime now, string? version = null) { + bool changed; + lock (_lock) { + changed = LastSeen == null; + LastSeen = now; + if (!string.IsNullOrWhiteSpace(version) && version != Version) { + Version = version; + changed = true; + } + } + + timerService.Register(SeenTimerKey, SeenWindow, Expire); + if (changed) BroadcastStatus(); + } + + internal void Expire() { + timerService.Unregister(SeenTimerKey); + lock (_lock) { + if (LastSeen == null) return; + LastSeen = null; + } + + BroadcastStatus(); + } + + private void BroadcastStatus() { + bool seen = Connected; + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { + Source = SubathonEventSource.MixItUp, + Service = ServiceName, + Name = seen && !string.IsNullOrWhiteSpace(Version) ? $"v{Version}" : "", + Status = seen, + Configured = seen + }); + } + + private HttpClient CreateClient() { + HttpClient client = httpClientFactory.CreateClient(nameof(MixItUpService)); + client.Timeout = TimeSpan.FromSeconds(5); + return client; + } + + public async Task RunCommandAsync(Guid commandId, IReadOnlyDictionary identifiers, + CancellationToken ct = default) { + try { + var payload = new { + Arguments = "", + SpecialIdentifiers = identifiers, + IgnoreRequirements = false + }; + using HttpClient client = CreateClient(); + using HttpResponseMessage response = + await client.PostAsJsonAsync($"{ApiUrl}/commands/{commandId}", payload, RequestJsonOptions, ct); + if (response.IsSuccessStatusCode) { + MarkSeen(DateTime.Now); + return true; + } + + string detail = await response.Content.ReadAsStringAsync(ct); + logger?.LogWarning("[MixItUp] Running command {CommandId} failed with {Status}: {Detail}", commandId, + (int)response.StatusCode, detail); + return false; + } + catch (Exception ex) { + logger?.LogDebug("[MixItUp] Running command {CommandId} failed: {Message}", commandId, ex.Message); + return false; + } + } + + public async Task?> GetCommandsAsync(CancellationToken ct = default) { + var result = new List(); + try { + using HttpClient client = CreateClient(); + for (var page = 0; page < MaxCommandPages; page++) { + string url = $"{ApiUrl}/commands?skip={page * CommandPageSize}&pageSize={CommandPageSize}"; + using HttpResponseMessage response = await client.GetAsync(url, ct); + if (!response.IsSuccessStatusCode) return null; + + CommandListResponse? list = await response.Content.ReadFromJsonAsync( + JsonOptions, ct); + if (list?.Commands == null || list.Commands.Count == 0) break; + + result.AddRange(list.Commands.Select(c => new MixItUpCommandInfo(c.Id, c.Name ?? "", + c.Type ?? "", c.GroupName ?? "", c.IsEnabled))); + if (result.Count >= list.TotalCount) break; + } + + result = await KeepRunnableAsync(client, result, ct); + MarkSeen(DateTime.Now); + } + catch (Exception ex) { + logger?.LogDebug("[MixItUp] Fetching commands failed: {Message}", ex.Message); + return null; + } + + return result.OrderBy(c => c.GroupName, StringComparer.OrdinalIgnoreCase) + .ThenBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + + private async Task> KeepRunnableAsync(HttpClient client, + List commands, CancellationToken ct) { + using var gate = new SemaphoreSlim(8); + bool[] runnable = await Task.WhenAll(commands.Select(async c => { + await gate.WaitAsync(ct); + try { + using HttpResponseMessage response = await client.GetAsync($"{ApiUrl}/commands/{c.Id}", ct); + return response.IsSuccessStatusCode; + } + catch (HttpRequestException) { + return false; + } + finally { + gate.Release(); + } + })); + return commands.Where((_, i) => runnable[i]).ToList(); + } + + private sealed class CommandListResponse { + public int TotalCount { get; set; } + public List? Commands { get; set; } + } + + private sealed class CommandEntry { + [System.Text.Json.Serialization.JsonPropertyName("ID")] + public Guid Id { get; set; } + + public string? Name { get; set; } + public string? Type { get; set; } + public string? GroupName { get; set; } + public bool IsEnabled { get; set; } + } + + internal bool Fire(MixItUpTrigger trigger, Dictionary identifiers) { + if (!Enabled) return false; + if (!Guid.TryParse(GetCommandId(trigger), out Guid commandId)) return false; + + identifiers[$"{IdentifierPrefix}trigger"] = trigger.ToString(); + _ = Task.Run(() => RunCommandAsync(commandId, identifiers)); + return true; + } + + public Task TestTriggerAsync(MixItUpTrigger trigger, string commandIdText, CancellationToken ct = default) { + if (!Guid.TryParse(commandIdText.Trim(), out Guid commandId)) return Task.FromResult(false); + Dictionary identifiers = SampleIdentifiers(trigger); + identifiers[$"{IdentifierPrefix}trigger"] = trigger.ToString(); + return RunCommandAsync(commandId, identifiers, ct); + } + + private void OnSubathonEventProcessed(SubathonEvent subathonEvent, bool effective) { + if (!config.GetBool("App", "ShowLockedEvents", false) && !subathonEvent.ProcessedToSubathon) return; + if (subathonEvent.EventType == SubathonEventType.Command && !IncludeCommands) return; + Fire(MixItUpTrigger.SubathonEvent, EventIdentifiers(subathonEvent)); + } + + private void OnSubathonDataUpdate(SubathonData subathon, DateTime timestamp) { + // check for multiplier and pause/lock info + var fired = new List<(MixItUpTrigger, Dictionary)>(3); + MultiplierSnapshot? multiplier = subathon.Multiplier?.SubathonId == subathon.Id + ? MultiplierSnapshot.From(subathon.Multiplier) + : null; + lock (_lock) { + if (_trackedSubathonId != subathon.Id) { + _trackedSubathonId = subathon.Id; + _lastPaused = subathon.IsPaused; + _lastLocked = subathon.IsLocked; + _lastMultiplier = multiplier; + return; + } + + if (subathon.IsPaused != _lastPaused) { + _lastPaused = subathon.IsPaused; + fired.Add((subathon.IsPaused ? MixItUpTrigger.TimerPaused : MixItUpTrigger.TimerResumed, + TimerIdentifiers(subathon))); + } + + if (subathon.IsLocked != _lastLocked) { + _lastLocked = subathon.IsLocked; + fired.Add((subathon.IsLocked ? MixItUpTrigger.TimerLocked : MixItUpTrigger.TimerUnlocked, + TimerIdentifiers(subathon))); + } + + if (multiplier != null) { + MultiplierSnapshot? previous = _lastMultiplier; + _lastMultiplier = multiplier; + if (previous != null) { + if (multiplier.Running && multiplier != previous) + fired.Add((MixItUpTrigger.MultiplierStarted, MultiplierIdentifiers(multiplier))); + else if (!multiplier.Running && previous.Running) + fired.Add((MixItUpTrigger.MultiplierEnded, MultiplierIdentifiers(previous))); + } + } + } + + foreach ((MixItUpTrigger trigger, Dictionary identifiers) in fired) + Fire(trigger, identifiers); + } + + private void OnGoalCompleted(SubathonGoal goal, long currentValue) { + Fire(MixItUpTrigger.GoalCompleted, new Dictionary { + [$"{IdentifierPrefix}goaltext"] = goal.Text, + [$"{IdentifierPrefix}goaltarget"] = ToValueStr(goal.Points), + [$"{IdentifierPrefix}goalcurrent"] = ToValueStr(currentValue) + }); + } + + private void OnWheelSpinStarted(WheelSet wheel, int delaySeconds) { + Fire(MixItUpTrigger.WheelSpinStart, new Dictionary { + [$"{IdentifierPrefix}wheelname"] = wheel.Name, + [$"{IdentifierPrefix}wheelid"] = wheel.Id.ToString(), + [$"{IdentifierPrefix}spindelay"] = ToValueStr(delaySeconds) + }); + } + + private void OnWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int spinsOwed) { + Fire(MixItUpTrigger.WheelSpinEnd, new Dictionary { + [$"{IdentifierPrefix}wheelname"] = wheel.Name, + [$"{IdentifierPrefix}wheelid"] = wheel.Id.ToString(), + [$"{IdentifierPrefix}wheelitem"] = item?.Text ?? "", + [$"{IdentifierPrefix}spinstatus"] = history.Status.ToString(), + [$"{IdentifierPrefix}spinsowed"] = ToValueStr(spinsOwed) + }); + } + + private void OnPromptRunStarted(SubathonPromptRun run, SubathonPrompt? prompt) { + Fire(MixItUpTrigger.PromptStarted, PromptIdentifiers(run, prompt)); + } + + private void OnPromptRunUpdate(SubathonPromptRun run, SubathonPrompt? prompt) { + if (run.IsActive) return; + Fire(MixItUpTrigger.PromptEnded, PromptIdentifiers(run, prompt)); + } + + private static string ToValueStr(IFormattable value) { + return value.ToString(null, CultureInfo.InvariantCulture); + } + + internal static Dictionary EventIdentifiers(SubathonEvent subathonEvent) { + var eventType = $"{subathonEvent.EventType}"; + string? trueSource = subathonEvent.EventType.GetTypeTrueSource(subathonEvent.EventTypeMeta); + if (subathonEvent.EventType == SubathonEventType.GoAffProOrder + && GoAffProOrderHelper.TryGetStore(subathonEvent.EventTypeMeta, out GoAffProStore? store)) { + trueSource = store.InternalName; + eventType = store.InternalEventName; + } + + double seconds = subathonEvent.GetFinalSecondsValueRaw() < 0.5 ? 0 : subathonEvent.GetFinalSecondsValue(); + return new Dictionary { + [$"{IdentifierPrefix}eventtype"] = eventType, + [$"{IdentifierPrefix}source"] = $"{subathonEvent.Source}", + [$"{IdentifierPrefix}truesource"] = trueSource ?? "", + [$"{IdentifierPrefix}subtype"] = $"{subathonEvent.EventType.GetSubType()}", + [$"{IdentifierPrefix}user"] = subathonEvent.User ?? "", + [$"{IdentifierPrefix}value"] = subathonEvent.Value, + [$"{IdentifierPrefix}amount"] = ToValueStr(subathonEvent.Amount), + [$"{IdentifierPrefix}currency"] = subathonEvent.Currency ?? "", + [$"{IdentifierPrefix}command"] = $"{subathonEvent.Command}", + [$"{IdentifierPrefix}secondsadded"] = ToValueStr(seconds), + [$"{IdentifierPrefix}pointsadded"] = ToValueStr(subathonEvent.GetFinalPointsValue()), + [$"{IdentifierPrefix}secondaryvalue"] = subathonEvent.SecondaryValue, + [$"{IdentifierPrefix}tertiaryvalue"] = subathonEvent.TertiaryValue, + [$"{IdentifierPrefix}reversed"] = $"{subathonEvent.WasReversed}" + }; + } + + internal static Dictionary TimerIdentifiers(SubathonData subathon) { + TimeSpan remaining = subathon.TimeRemainingRounded(); + return new Dictionary { + [$"{IdentifierPrefix}timeremaining"] = + $"{(int)remaining.TotalHours:00}:{remaining.Minutes:00}:{remaining.Seconds:00}", + [$"{IdentifierPrefix}secondsremaining"] = ToValueStr((long)remaining.TotalSeconds), + [$"{IdentifierPrefix}points"] = ToValueStr(subathon.Points), + [$"{IdentifierPrefix}paused"] = $"{subathon.IsPaused}", + [$"{IdentifierPrefix}locked"] = $"{subathon.IsLocked}" + }; + } + + internal sealed record MultiplierSnapshot( + bool Running, + double Multiplier, + bool Time, + bool Points, + TimeSpan? Duration, + DateTime? Started, + bool FromHypeTrain) { + public static MultiplierSnapshot From(MultiplierData data) { + return new MultiplierSnapshot(data.IsRunning(), data.Multiplier, data.ApplyToSeconds, + data.ApplyToPoints, data.Duration, data.Started, data.FromHypeTrain); + } + } + + internal static Dictionary MultiplierIdentifiers(MultiplierSnapshot multiplier) { + return new Dictionary { + [$"{IdentifierPrefix}multiplier"] = ToValueStr(multiplier.Multiplier), + [$"{IdentifierPrefix}multipliertime"] = $"{multiplier.Time}", + [$"{IdentifierPrefix}multiplierpoints"] = $"{multiplier.Points}", + [$"{IdentifierPrefix}multiplierduration"] = ToValueStr((long)(multiplier.Duration?.TotalSeconds ?? 0)), + [$"{IdentifierPrefix}multiplierhypetrain"] = $"{multiplier.FromHypeTrain}" + }; + } + + internal static Dictionary PromptIdentifiers(SubathonPromptRun run, SubathonPrompt? prompt) { + prompt ??= run.LinkedPrompt; + return new Dictionary { + [$"{IdentifierPrefix}prompttext"] = prompt?.Text ?? "", + [$"{IdentifierPrefix}prompttype"] = $"{prompt?.Type}", + [$"{IdentifierPrefix}prompttarget"] = ToValueStr(prompt?.Value ?? run.SnapshotTargetValue), + [$"{IdentifierPrefix}promptduration"] = + ToValueStr((long)(prompt?.CompletionDuration.TotalSeconds ?? (run.ExpiresAt - run.StartedAt).TotalSeconds)), + [$"{IdentifierPrefix}promptstatus"] = $"{run.Status}" + }; + } + + public static IReadOnlyList IdentifierNames(MixItUpTrigger trigger) { + return SampleIdentifiers(trigger).Keys.Prepend($"{IdentifierPrefix}trigger").Select(k => $"${k}").ToList(); + } + + internal static Dictionary SampleIdentifiers(MixItUpTrigger trigger) { + switch (trigger) { + case MixItUpTrigger.SubathonEvent: + return EventIdentifiers(new SubathonEvent { + Source = SubathonEventSource.Simulated, + EventType = SubathonEventType.ExternalDonation, + User = "TestUser", + Value = "5", + Currency = "USD", + SecondsValue = 600, + PointsValue = 5, + ProcessedToSubathon = true + }); + case MixItUpTrigger.MultiplierStarted: + case MixItUpTrigger.MultiplierEnded: + return MultiplierIdentifiers(new MultiplierSnapshot(trigger == MixItUpTrigger.MultiplierStarted, + 2, true, true, TimeSpan.FromMinutes(10), DateTime.Now, false)); + case MixItUpTrigger.TimerPaused: + case MixItUpTrigger.TimerResumed: + case MixItUpTrigger.TimerLocked: + case MixItUpTrigger.TimerUnlocked: + return new Dictionary { + [$"{IdentifierPrefix}timeremaining"] = "12:34:56", + [$"{IdentifierPrefix}secondsremaining"] = "45296", + [$"{IdentifierPrefix}points"] = "250", + [$"{IdentifierPrefix}paused"] = (trigger == MixItUpTrigger.TimerPaused).ToString(), + [$"{IdentifierPrefix}locked"] = (trigger == MixItUpTrigger.TimerLocked).ToString() + }; + case MixItUpTrigger.GoalCompleted: + return new Dictionary { + [$"{IdentifierPrefix}goaltext"] = "Test Goal", + [$"{IdentifierPrefix}goaltarget"] = "100", + [$"{IdentifierPrefix}goalcurrent"] = "100" + }; + case MixItUpTrigger.WheelSpinStart: + return new Dictionary { + [$"{IdentifierPrefix}wheelname"] = "Test Wheel", + [$"{IdentifierPrefix}wheelid"] = Guid.Empty.ToString(), + [$"{IdentifierPrefix}spindelay"] = "0" + }; + case MixItUpTrigger.WheelSpinEnd: + return new Dictionary { + [$"{IdentifierPrefix}wheelname"] = "Test Wheel", + [$"{IdentifierPrefix}wheelid"] = Guid.Empty.ToString(), + [$"{IdentifierPrefix}wheelitem"] = "Test Item", + [$"{IdentifierPrefix}spinstatus"] = "Pending", + [$"{IdentifierPrefix}spinsowed"] = "0" + }; + case MixItUpTrigger.PromptStarted: + case MixItUpTrigger.PromptEnded: + return new Dictionary { + [$"{IdentifierPrefix}prompttext"] = "Test Prompt", + [$"{IdentifierPrefix}prompttype"] = $"{SubathonPromptType.Points}", + [$"{IdentifierPrefix}prompttarget"] = "10", + [$"{IdentifierPrefix}promptduration"] = "300", + [$"{IdentifierPrefix}promptstatus"] = trigger == MixItUpTrigger.PromptStarted + ? $"{SubathonPromptRunStatus.Active}" + : $"{SubathonPromptRunStatus.Completed}" + }; + default: + return new Dictionary(); + } + } +} + +public static class MixItUpCommandExporter { + public const string FileExtension = ".miucommand"; + public const string GroupName = "Subathon Manager"; + + private const string ActionGroupCommandType = "MixItUp.Base.Model.Commands.ActionGroupCommandModel, MixItUp.Base"; + private const string RequirementsSetType = "MixItUp.Base.Model.Requirements.RequirementsSetModel, MixItUp.Base"; + private const string WebRequestActionType = "MixItUp.Base.Model.Actions.WebRequestActionModel, MixItUp.Base"; + + private const int CommandTypeActionGroup = 4; + private const int ActionTypeWebRequest = 11; + private const int ResponseTypePlainText = 0; + private const int HttpMethodPost = 1; + + public static IEnumerable ExportableCommands => + Enum.GetValues() + .Where(c => c is not (SubathonCommandType.None or SubathonCommandType.Unknown)); + + public static string CommandName(SubathonCommandType command) { + return $"Subathon - {command.GetDescription()}"; + } + + public static string BuildRequestBody(SubathonCommandType command) { + return JsonSerializer.Serialize(new Dictionary { + ["type"] = $"{SubathonEventType.Command}", + ["command"] = $"{command}", + ["message"] = command.IsParametersRequired() ? "$allargs" : "", + ["user"] = "$username", + ["source"] = $"{SubathonEventSource.MixItUp}" + }); + } + + public static string BuildCommandJson(SubathonCommandType command, int port) { + var webRequest = new JsonObject { + ["$type"] = WebRequestActionType, + ["Url"] = $"http://localhost:{port}/api/data/control", + ["ResponseType"] = ResponseTypePlainText, + ["JSONToSpecialIdentifiers"] = new JsonObject(), + ["HttpMethod"] = HttpMethodPost, + ["CustomHeaders"] = new JsonObject(), + ["RequestBody"] = BuildRequestBody(command), + ["ID"] = Guid.NewGuid().ToString(), + ["Name"] = "Web Request", + ["Type"] = ActionTypeWebRequest, + ["Enabled"] = true + }; + + var root = new JsonObject { + ["$type"] = ActionGroupCommandType, + ["RunOneRandomly"] = false, + ["ID"] = Guid.NewGuid().ToString(), + ["Name"] = CommandName(command), + ["Type"] = CommandTypeActionGroup, + ["IsEnabled"] = true, + ["Unlocked"] = false, + ["IsEmbedded"] = false, + ["GroupName"] = GroupName, + ["Triggers"] = new JsonArray(), + ["Requirements"] = new JsonObject { + ["$type"] = RequirementsSetType, + ["Requirements"] = new JsonArray() + }, + ["Actions"] = new JsonArray(webRequest) + }; + + return root.ToJsonString(); + } + + public static IReadOnlyList WriteAll(string folder, int port) { + Directory.CreateDirectory(folder); + foreach (string stale in Directory.GetFiles(folder, $"*{FileExtension}")) + File.Delete(stale); + + var written = new List(); + foreach (SubathonCommandType command in ExportableCommands) { + string path = Path.Combine(folder, $"{CommandName(command)}{FileExtension}"); + File.WriteAllText(path, BuildCommandJson(command, port)); + written.Add(path); + } + + return written; + } +} diff --git a/SubathonManager.Server/WebServer.Api.cs b/SubathonManager.Server/WebServer.Api.cs index d1ff8093..1b88bc7e 100644 --- a/SubathonManager.Server/WebServer.Api.cs +++ b/SubathonManager.Server/WebServer.Api.cs @@ -148,6 +148,8 @@ private async Task HandleDataControlRequestAsync(IHttpContext ctx) { return; } + ExternalEventService.NotifySourceSeen(data); + var type = SubathonEventType.Unknown; if (!data.ContainsKey("type") || !data.TryGetValue("type", out JsonElement elem) || !Enum.TryParse(elem.GetString()!, true, out type)) { diff --git a/SubathonManager.Tests/IntegrationUnitTests/MixItUpServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/MixItUpServiceTests.cs new file mode 100644 index 00000000..72322334 --- /dev/null +++ b/SubathonManager.Tests/IntegrationUnitTests/MixItUpServiceTests.cs @@ -0,0 +1,468 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using Moq; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; +using SubathonManager.Integration; +using SubathonManager.Tests.Utility; + +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Tests.IntegrationUnitTests; + +[Collection("GlobalState")] +public class MixItUpServiceTests { + private static readonly Guid CommandId = Guid.Parse("11111111-2222-3333-4444-555555555555"); + + public MixItUpServiceTests() { + foreach (string field in new[] { "ConnectionUpdated", "ExternalSourceSeen" }) + typeof(IntegrationEvents).GetField(field, BindingFlags.Static | BindingFlags.NonPublic) + ?.SetValue(null, null); + foreach (string field in new[] { "SubathonDataUpdate", "PromptRunUpdate", "SubathonEventProcessed" }) + typeof(SubathonEvents).GetField(field, BindingFlags.Static | BindingFlags.NonPublic) + ?.SetValue(null, null); + } + + private static string Id(string name) { + return $"{MixItUpService.IdentifierPrefix}{name}"; + } + + private static MixItUpService MakeService(RecordingHandler? handler = null, + Dictionary<(string, string), string>? config = null, ITimerService? timerService = null) { + handler ??= new RecordingHandler(HttpStatusCode.ServiceUnavailable); + var factory = new Mock(); + factory.Setup(f => f.CreateClient(It.IsAny())).Returns(() => new HttpClient(handler, false)); + return new MixItUpService(new Mock>().Object, + MockConfig.MakeMockConfig(config), factory.Object, timerService ?? new Mock().Object); + } + + private static Dictionary<(string, string), string> EnabledConfig(MixItUpTrigger trigger) { + return new Dictionary<(string, string), string> { + [(MixItUpService.ConfigSection, "Enabled")] = "True", + [(MixItUpService.ConfigSection, MixItUpService.CommandConfigKey(trigger))] = CommandId.ToString() + }; + } + + private static List CaptureConnections(Action trigger) { + var captured = new List(); + + void Handler(IntegrationConnection c) { + if (c.Source == SubathonEventSource.MixItUp) captured.Add(c); + } + + IntegrationEvents.ConnectionUpdated += Handler; + try { + trigger(); + } + finally { + IntegrationEvents.ConnectionUpdated -= Handler; + } + + return captured; + } + + [Fact] + public void BuildCommandJson_ProducesImportableActionGroup() { + JsonObject root = JsonNode.Parse(MixItUpCommandExporter.BuildCommandJson(SubathonCommandType.AddTime, 15000))! + .AsObject(); + + Assert.Equal("$type", root.First().Key); + Assert.Equal("MixItUp.Base.Model.Commands.ActionGroupCommandModel, MixItUp.Base", + root["$type"]!.GetValue()); + Assert.Equal("Subathon - Add Time", root["Name"]!.GetValue()); + Assert.Equal(4, root["Type"]!.GetValue()); + + JsonObject action = root["Actions"]!.AsArray().Single()!.AsObject(); + Assert.Equal("$type", action.First().Key); + Assert.Equal("MixItUp.Base.Model.Actions.WebRequestActionModel, MixItUp.Base", + action["$type"]!.GetValue()); + Assert.Equal("http://localhost:15000/api/data/control", action["Url"]!.GetValue()); + Assert.Equal(1, action["HttpMethod"]!.GetValue()); + Assert.Equal(11, action["Type"]!.GetValue()); + } + + [Theory] + [InlineData(SubathonCommandType.AddTime, "$allargs")] + [InlineData(SubathonCommandType.Pause, "")] + public void BuildRequestBody_ParameterOnlyWhenRequired(SubathonCommandType command, string expectedMessage) { + var body = JsonSerializer.Deserialize>( + MixItUpCommandExporter.BuildRequestBody(command))!; + + Assert.Equal("Command", body["type"]); + Assert.Equal(command.ToString(), body["command"]); + Assert.Equal(expectedMessage, body["message"]); + Assert.Equal("$username", body["user"]); + Assert.Equal("MixItUp", body["source"]); + } + + [Fact] + public void WriteAll_WritesEveryCommandAndRemovesStaleFiles() { + string folder = Path.Combine(Path.GetTempPath(), "SubathonManagerTests", $"miu-{Guid.NewGuid():N}"); + try { + Directory.CreateDirectory(folder); + string stale = Path.Combine(folder, $"Old{MixItUpCommandExporter.FileExtension}"); + File.WriteAllText(stale, "{}"); + + IReadOnlyList written = MixItUpCommandExporter.WriteAll(folder, 14040); + + Assert.False(File.Exists(stale)); + Assert.Equal(MixItUpCommandExporter.ExportableCommands.Count(), written.Count); + Assert.DoesNotContain(written, p => p.Contains(nameof(SubathonCommandType.None))); + Assert.All(written, p => Assert.True(File.Exists(p))); + } + finally { + try { + Directory.Delete(folder, true); + } + catch { + /**/ + } + } + } + + [Fact] + public void NotifySourceSeen_RaisesForExternalSourceOnly() { + var seen = new List(); + IntegrationEvents.ExternalSourceSeen += seen.Add; + try { + foreach (string src in new[] { "MixItUp", "Twitch", "NotASource" }) { + var data = JsonSerializer.Deserialize>( + $$"""{ "source": "{{src}}" }""")!; + ExternalEventService.NotifySourceSeen(data); + } + + ExternalEventService.NotifySourceSeen(new Dictionary()); + } + finally { + IntegrationEvents.ExternalSourceSeen -= seen.Add; + } + + Assert.Equal(new[] { SubathonEventSource.MixItUp }, seen); + } + + [Fact] + public void MarkSeen_GoesGreen_ThenGreyWhenSeenTimerFires() { + var timer = new Mock(); + Action? expire = null; + timer.Setup(t => t.Register("mixitup-seen", MixItUpService.SeenWindow, It.IsAny())) + .Callback((string _, TimeSpan _, Action cb) => expire = cb) + .Returns(Mock.Of()); + MixItUpService service = MakeService(timerService: timer.Object); + + List seen = CaptureConnections(() => service.MarkSeen(DateTime.Now)); + Assert.Single(seen); + Assert.True(seen[0].Status); + Assert.True(seen[0].Configured); + Assert.NotNull(expire); + + List expired = CaptureConnections(expire!); + Assert.Single(expired); + Assert.False(expired[0].Status); + Assert.False(expired[0].Configured); + Assert.False(service.Connected); + timer.Verify(t => t.Unregister("mixitup-seen")); + } + + [Fact] + public void MarkSeen_AgainReRegistersWindowWithoutRebroadcasting() { + var timer = new Mock(); + MixItUpService service = MakeService(timerService: timer.Object); + service.MarkSeen(DateTime.Now); + + List again = CaptureConnections(() => service.MarkSeen(DateTime.Now)); + + Assert.Empty(again); + timer.Verify(t => t.Register("mixitup-seen", MixItUpService.SeenWindow, It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task StartAsync_RegistersProbe_StopUnregistersAll() { + var timer = new Mock(); + MixItUpService service = MakeService(timerService: timer.Object); + await service.StartAsync(TestContext.Current.CancellationToken); + await service.StopAsync(TestContext.Current.CancellationToken); + timer.Verify(t => t.Register("mixitup-probe", MixItUpService.ProbeInterval, It.IsAny())); + timer.Verify(t => t.Unregister("mixitup-probe")); + timer.Verify(t => t.Unregister("mixitup-seen")); + } + + [Fact] + public async Task ProbeAsync_Success_MarksSeenWithVersion() { + var handler = new RecordingHandler(HttpStatusCode.OK, "\"1.2.3.4\""); + MixItUpService service = MakeService(handler); + + bool ok = await service.ProbeAsync(TestContext.Current.CancellationToken); + Assert.True(ok); + Assert.True(service.Connected); + Assert.Equal("1.2.3.4", service.Version); + Assert.EndsWith("/api/v2/status/version", handler.Requests.Single().Uri); + service.Dispose(); + } + + [Fact] + public async Task ProbeAsync_Unreachable_FailsSilently() { + MixItUpService service = MakeService(new RecordingHandler(HttpStatusCode.OK, throwConnect: true)); + + List updates = []; + bool ok = true; + Exception? ex = await Record.ExceptionAsync(async () => { + IntegrationEvents.ConnectionUpdated += updates.Add; + try { + ok = await service.ProbeAsync(TestContext.Current.CancellationToken); + } + finally { + IntegrationEvents.ConnectionUpdated -= updates.Add; + } + }); + + Assert.Null(ex); + Assert.False(ok); + Assert.False(service.Connected); + Assert.Empty(updates); + } + + [Theory] + [InlineData("\"1.0.0\"", "1.0.0")] + [InlineData("1.0.0", "1.0.0")] + [InlineData("", null)] + public void ParseVersion_HandlesJsonAndRaw(string body, string? expected) { + Assert.Equal(expected, MixItUpService.ParseVersion(body)); + } + + [Fact] + public void Fire_Disabled_DoesNothing() { + Dictionary<(string, string), string> config = EnabledConfig(MixItUpTrigger.GoalCompleted); + config[(MixItUpService.ConfigSection, "Enabled")] = "False"; + MixItUpService service = MakeService(config: config); + + Assert.False(service.Fire(MixItUpTrigger.GoalCompleted, new Dictionary())); + } + + [Fact] + public void Fire_InvalidCommandId_DoesNothing() { + Dictionary<(string, string), string> config = EnabledConfig(MixItUpTrigger.GoalCompleted); + config[(MixItUpService.ConfigSection, MixItUpService.CommandConfigKey(MixItUpTrigger.GoalCompleted))] = + "not-a-guid"; + MixItUpService service = MakeService(config: config); + + Assert.False(service.Fire(MixItUpTrigger.GoalCompleted, new Dictionary())); + } + + [Fact] + public async Task Fire_PostsCommandWithSpecialIdentifiers() { + var handler = new RecordingHandler(HttpStatusCode.Accepted); + MixItUpService service = MakeService(handler, EnabledConfig(MixItUpTrigger.GoalCompleted)); + + bool fired = service.Fire(MixItUpTrigger.GoalCompleted, + new Dictionary { [Id("goaltext")] = "Hat on" }); + RecordedRequest request = await handler.WaitForRequestAsync(TestContext.Current.CancellationToken); + + Assert.True(fired); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.EndsWith($"/api/v2/commands/{CommandId}", request.Uri); + + JsonObject body = JsonNode.Parse(request.Body)!.AsObject(); + JsonObject identifiers = body["SpecialIdentifiers"]!.AsObject(); + Assert.Equal("GoalCompleted", identifiers[Id("trigger")]!.GetValue()); + Assert.Equal("Hat on", identifiers[Id("goaltext")]!.GetValue()); + Assert.False(body["IgnoreRequirements"]!.GetValue()); + } + + [Fact] + public async Task DataUpdate_FiresOnPauseChangeOnly() { + var handler = new RecordingHandler(HttpStatusCode.Accepted); + Dictionary<(string, string), string> config = EnabledConfig(MixItUpTrigger.TimerPaused); + MixItUpService service = MakeService(handler, config); + await service.StartAsync(TestContext.Current.CancellationToken); + try { + var subathon = new SubathonData { IsPaused = false, IsLocked = false }; + SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); + SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); + subathon.IsPaused = true; + SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); + + RecordedRequest request = await handler.WaitForRequestAsync(TestContext.Current.CancellationToken, + r => r.Method == HttpMethod.Post); + + JsonObject identifiers = JsonNode.Parse(request.Body)!["SpecialIdentifiers"]!.AsObject(); + Assert.Equal("TimerPaused", identifiers[Id("trigger")]!.GetValue()); + Assert.Equal("True", identifiers[Id("paused")]!.GetValue()); + Assert.Single(handler.Requests, r => r.Method == HttpMethod.Post); + } + finally { + await service.StopAsync(TestContext.Current.CancellationToken); + } + } + + [Fact] + public async Task PromptEnded_FiresOnlyForEndedRuns() { + var handler = new RecordingHandler(HttpStatusCode.Accepted); + MixItUpService service = MakeService(handler, EnabledConfig(MixItUpTrigger.PromptEnded)); + await service.StartAsync(TestContext.Current.CancellationToken); + try { + var run = new SubathonPromptRun { Status = SubathonPromptRunStatus.Active }; + SubathonEvents.RaisePromptRunUpdate(run, null); + run.Status = SubathonPromptRunStatus.Expired; + SubathonEvents.RaisePromptRunUpdate(run, null); + + RecordedRequest request = await handler.WaitForRequestAsync(TestContext.Current.CancellationToken, + r => r.Method == HttpMethod.Post); + await Task.Delay(100, TestContext.Current.CancellationToken); + + JsonObject identifiers = JsonNode.Parse(request.Body)!["SpecialIdentifiers"]!.AsObject(); + Assert.Equal("Expired", identifiers[Id("promptstatus")]!.GetValue()); + Assert.Single(handler.Requests, r => r.Method == HttpMethod.Post); + } + finally { + await service.StopAsync(TestContext.Current.CancellationToken); + } + } + + [Theory] + [InlineData(SubathonEventType.ExternalDonation, false, true)] + [InlineData(SubathonEventType.Command, false, false)] + [InlineData(SubathonEventType.Command, true, true)] + public async Task EventProcessed_CommandsOnlyWhenIncluded(SubathonEventType type, bool includeCommands, + bool expectSent) { + var handler = new RecordingHandler(HttpStatusCode.Accepted); + Dictionary<(string, string), string> config = EnabledConfig(MixItUpTrigger.SubathonEvent); + config[(MixItUpService.ConfigSection, MixItUpService.IncludeCommandsKey)] = includeCommands.ToString(); + MixItUpService service = MakeService(handler, config); + await service.StartAsync(TestContext.Current.CancellationToken); + try { + + SubathonEvents.RaiseSubathonEventProcessed(new SubathonEvent { + EventType = type, Command = SubathonCommandType.AddTime, User = "Tester", ProcessedToSubathon = true + }, true); + + if (!expectSent) { + await Task.Delay(200, TestContext.Current.CancellationToken); + Assert.DoesNotContain(handler.Requests, r => r.Method == HttpMethod.Post); + return; + } + + RecordedRequest request = await handler.WaitForRequestAsync(TestContext.Current.CancellationToken, + r => r.Method == HttpMethod.Post); + + JsonObject identifiers = JsonNode.Parse(request.Body)!["SpecialIdentifiers"]!.AsObject(); + Assert.Equal("SubathonEvent", identifiers[Id("trigger")]!.GetValue()); + Assert.Equal(type.ToString(), identifiers[Id("eventtype")]!.GetValue()); + Assert.Equal("Tester", identifiers[Id("user")]!.GetValue()); + } + finally { + await service.StopAsync(TestContext.Current.CancellationToken); + } + } + + private static SubathonData WithMultiplier(Guid id, double multiplier, bool time, bool points) { + return new SubathonData { + Id = id, IsPaused = false, IsLocked = false, + Multiplier = new MultiplierData { + SubathonId = id, Multiplier = multiplier, ApplyToSeconds = time, ApplyToPoints = points, + Duration = TimeSpan.FromMinutes(5), Started = DateTime.Today + } + }; + } + + [Fact] + public async Task DataUpdate_FiresMultiplierStartAndEnd() { + var handler = new RecordingHandler(HttpStatusCode.Accepted); + Dictionary<(string, string), string> config = EnabledConfig(MixItUpTrigger.MultiplierStarted); + config[(MixItUpService.ConfigSection, MixItUpService.CommandConfigKey(MixItUpTrigger.MultiplierEnded))] = + CommandId.ToString(); + MixItUpService service = MakeService(handler, config); + await service.StartAsync(TestContext.Current.CancellationToken); + try { + Guid id = Guid.NewGuid(); + SubathonEvents.RaiseSubathonDataUpdate(WithMultiplier(id, 1, false, false), DateTime.Now); + SubathonEvents.RaiseSubathonDataUpdate(WithMultiplier(id, 2, true, false), DateTime.Now); + SubathonEvents.RaiseSubathonDataUpdate(WithMultiplier(id, 2, true, false), DateTime.Now); + SubathonEvents.RaiseSubathonDataUpdate(new SubathonData { Id = id, IsPaused = false, IsLocked = false }, + DateTime.Now); + SubathonEvents.RaiseSubathonDataUpdate(WithMultiplier(id, 1, true, false), DateTime.Now); + + await handler.WaitForRequestAsync(TestContext.Current.CancellationToken, + r => r.Method == HttpMethod.Post && r.Body.Contains("MultiplierEnded")); + await Task.Delay(100, TestContext.Current.CancellationToken); + + List sent = handler.Requests.Where(r => r.Method == HttpMethod.Post) + .Select(r => JsonNode.Parse(r.Body)!["SpecialIdentifiers"]!.AsObject()).ToList(); + Assert.Equal(2, sent.Count); + + JsonObject started = sent.Single(i => i[Id("trigger")]!.GetValue() == "MultiplierStarted"); + Assert.Equal("2", started[Id("multiplier")]!.GetValue()); + Assert.Equal("True", started[Id("multipliertime")]!.GetValue()); + Assert.Equal("False", started[Id("multiplierpoints")]!.GetValue()); + Assert.Equal("300", started[Id("multiplierduration")]!.GetValue()); + + JsonObject ended = sent.Single(i => i[Id("trigger")]!.GetValue() == "MultiplierEnded"); + Assert.Equal("2", ended[Id("multiplier")]!.GetValue()); + } + finally { + await service.StopAsync(TestContext.Current.CancellationToken); + } + } + + [Theory] + [InlineData(true, true)] + [InlineData(false, true)] + public void MultiplierIdentifiers_SendTimeAndPointsFlags(bool time, bool points) { + Dictionary ids = MixItUpService.MultiplierIdentifiers( + new MixItUpService.MultiplierSnapshot(true, 1.5, time, points, null, null, false)); + + Assert.Equal(time.ToString(), ids[Id("multipliertime")]); + Assert.Equal(points.ToString(), ids[Id("multiplierpoints")]); + Assert.Equal("1.5", ids[Id("multiplier")]); + Assert.Equal("0", ids[Id("multiplierduration")]); + } + + [Fact] + public void SampleIdentifiers_CoverEveryTrigger() { + foreach (MixItUpTrigger trigger in Enum.GetValues()) { + IReadOnlyList names = MixItUpService.IdentifierNames(trigger); + Assert.Contains($"${Id("trigger")}", names); + Assert.True(names.Count > 1, $"{trigger} has no identifiers"); + Assert.All(names, n => Assert.Matches($"^\\${MixItUpService.IdentifierPrefix}[a-z]+$", n)); + } + } + + private sealed record RecordedRequest(HttpMethod Method, string Uri, string Body); + + private sealed class RecordingHandler(HttpStatusCode statusCode, string? body = null, bool throwConnect = false) + : HttpMessageHandler { + private readonly SemaphoreSlim _signal = new(0); + public ConcurrentQueue Requests { get; } = new(); + + public async Task WaitForRequestAsync(CancellationToken ct, + Func? match = null) { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(5)); + while (true) { + RecordedRequest? found = Requests.FirstOrDefault(r => match?.Invoke(r) ?? true); + if (found != null) return found; + await _signal.WaitAsync(timeout.Token); + } + } + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken ct) { + if (throwConnect) throw new HttpRequestException("Connection refused"); + string content = request.Content == null ? "" : await request.Content.ReadAsStringAsync(ct); + Requests.Enqueue(new RecordedRequest(request.Method, request.RequestUri!.ToString(), content)); + _signal.Release(); + + var response = new HttpResponseMessage(statusCode); + if (body != null) response.Content = new StringContent(body, Encoding.UTF8, "application/json"); + return response; + } + } +} diff --git a/SubathonManager.UI/Services/ServiceManager.cs b/SubathonManager.UI/Services/ServiceManager.cs index 1db49614..15843f04 100644 --- a/SubathonManager.UI/Services/ServiceManager.cs +++ b/SubathonManager.UI/Services/ServiceManager.cs @@ -68,6 +68,7 @@ private static IServiceProvider Provider { public static TreatStreamService TreatStream => Provider.GetRequiredService(); public static OBSService OBS => Provider.GetRequiredService(); public static VTSService VTubeStudio => Provider.GetRequiredService(); + public static MixItUpService MixItUp => Provider.GetRequiredService(); public static WebServer Server => Provider.GetRequiredService(); @@ -92,6 +93,7 @@ public async Task StartIntegrationsAsync() { await StartAsync(); await StartAsync(); await StartAsync(); + await StartAsync(); await StartAsync(); await StartAsync(); } @@ -112,6 +114,7 @@ public async Task StopIntegrationsAsync() { await StopAsync(); await StopAsync(); await StopAsync(); + await StopAsync(); await StopAsync(); await StopAsync(); await StopAsync(); diff --git a/SubathonManager.UI/Services/ServiceRegistration.cs b/SubathonManager.UI/Services/ServiceRegistration.cs index 7280bf15..dcc77d56 100644 --- a/SubathonManager.UI/Services/ServiceRegistration.cs +++ b/SubathonManager.UI/Services/ServiceRegistration.cs @@ -92,6 +92,8 @@ public static void AddIntegrations(this IServiceCollection services) { services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddHttpClient(nameof(MixItUpService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan); + services.AddSingleton(); } private static void ConfigureLogging(ILoggingBuilder builder) { diff --git a/SubathonManager.UI/Views/SettingsView.Settings.cs b/SubathonManager.UI/Views/SettingsView.Settings.cs index bca280d1..66e14f74 100644 --- a/SubathonManager.UI/Views/SettingsView.Settings.cs +++ b/SubathonManager.UI/Views/SettingsView.Settings.cs @@ -170,6 +170,7 @@ private void SaveAllSubathonValuesButton_Click(object? sender, RoutedEventArgs e ExternalServiceSettingsControl.RefreshTierCombo(SubathonEventSource.External); StreamingSettingsControl.RefreshTierCombo(SubathonEventSource.YouTube); hasUpdated |= ExternalServiceSettingsControl.UpdateConfigValueSettings(); + hasUpdated |= ExternalSoftwareSettingsControl.UpdateConfigValueSettings(); hasUpdated |= CommandsSettingsControl.UpdateConfigValueSettings(); hasUpdated |= WebhookLogSettingsControl.UpdateConfigValueSettings(); diff --git a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/MixItUpSettings.axaml b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/MixItUpSettings.axaml new file mode 100644 index 00000000..0ccf139e --- /dev/null +++ b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/MixItUpSettings.axaml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SubathonManager.UI/MainWindow.axaml.cs b/SubathonManager.UI/MainWindow.axaml.cs index 596f6eca..f4fe58a7 100644 --- a/SubathonManager.UI/MainWindow.axaml.cs +++ b/SubathonManager.UI/MainWindow.axaml.cs @@ -27,6 +27,9 @@ public MainWindow() { InitHome(); InitOverlays(); + RecentEventsList.HiddenTypesChanged += UpdateRecentEventsFilterTip; + UpdateRecentEventsFilterTip(); + HomeScheduleList.ItemRequested += (date, id) => { MainWindowTabs.SelectedItem = ScheduleTabItem; SchedulePage.ShowItem(date, id); @@ -39,6 +42,23 @@ public MainWindow() { }; } + private void HomeListTabs_SelectionChanged(object? sender, SelectionChangedEventArgs e) { + if (!ReferenceEquals(e.Source, HomeListTabs)) return; + RecentEventsFilterBtn.IsVisible = ReferenceEquals(HomeListTabs.SelectedItem, RecentEventsTab); + } + + private void RecentEventsFilterBtn_Click(object? sender, RoutedEventArgs e) { + RecentEventsList.OpenTypeFilter(RecentEventsFilterBtn); + } + + private void UpdateRecentEventsFilterTip() { + int hidden = RecentEventsList.HiddenTypeCount; + ToolTip.SetTip(RecentEventsFilterBtn, hidden == 0 + ? "Choose which event types show here" + : $"Choose which event types show here ({hidden} hidden)"); + RecentEventsFilterBtn.Opacity = hidden == 0 ? 0.7 : 1; + } + private async void CopyVersion_Click(object? sender, RoutedEventArgs e) { if (string.IsNullOrEmpty(_fullVersion)) return; await UiHelpers.TrySetClipboardTextAsync(_fullVersion); diff --git a/SubathonManager.UI/Views/EventListView.axaml b/SubathonManager.UI/Views/EventListView.axaml index 8587f2bd..95628e1c 100644 --- a/SubathonManager.UI/Views/EventListView.axaml +++ b/SubathonManager.UI/Views/EventListView.axaml @@ -48,6 +48,8 @@ + diff --git a/SubathonManager.UI/Views/EventListView.axaml.cs b/SubathonManager.UI/Views/EventListView.axaml.cs index f595a85c..7f21fe58 100644 --- a/SubathonManager.UI/Views/EventListView.axaml.cs +++ b/SubathonManager.UI/Views/EventListView.axaml.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Numerics; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Threading; @@ -10,6 +11,7 @@ using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; using SubathonManager.Data; +using SubathonManager.UI.Controls; using SubathonManager.UI.Services; namespace SubathonManager.UI.Views; @@ -19,11 +21,17 @@ public partial class EventListView : UserControl { private readonly IDbContextFactory _factory; private readonly int _maxItems = 20; + private HashSet _hiddenTypes = []; + public EventListView() { _factory = AppServices.Provider.GetRequiredService>(); InitializeComponent(); EventListPanel.ItemsSource = EventItems; _config = AppServices.Provider.GetRequiredService(); + + LoadHiddenTypes(); + TypeFilter.SetOptions(FilterOption.EventTypes(true)); + TypeFilter.Closed += (_, _) => SaveHiddenTypesFromPicker(); Task.Run(async () => await LoadRecentEvents()); SubathonEvents.SubathonEventProcessed += OnSubathonEventProcessed; @@ -33,6 +41,40 @@ public EventListView() { public ObservableCollection EventItems { get; set; } = new(); + public int HiddenTypeCount => _hiddenTypes.Count; + + public void OpenTypeFilter(Control anchor) { + TypeFilter.SetSelected(Enum.GetValues() + .Where(t => !_hiddenTypes.Contains(t)) + .Select(t => t.ToString())); + TypeFilter.Open(anchor); + } + + public event Action? HiddenTypesChanged; + + private void LoadHiddenTypes() { + using AppDbContext db = _factory.CreateDbContext(); + string raw = StateValueHelper.Get(db, StateKeys.RecentEventsHiddenTypes); + if (!BigInteger.TryParse(raw, out BigInteger mask) || mask.IsZero) return; + _hiddenTypes = Enum.GetValues() + .Where(t => !((mask >> (int)t) & BigInteger.One).IsZero) + .ToHashSet(); + } + + private void SaveHiddenTypesFromPicker() { + HashSet hidden = TypeFilter.Options + .Where(o => !o.Selected) + .Select(o => Enum.Parse(o.Value)) + .ToHashSet(); + if (hidden.SetEquals(_hiddenTypes)) return; + + _hiddenTypes = hidden; + BigInteger mask = hidden.Aggregate(BigInteger.Zero, (m, t) => m | (BigInteger.One << (int)t)); + _ = StateValueHelper.SetAsync(_factory, StateKeys.RecentEventsHiddenTypes, mask.ToString()); + HiddenTypesChanged?.Invoke(); + Task.Run(async () => await LoadRecentEvents()); + } + private void OnSubathonEventsDeleted(List events) { Task.Run(async () => await LoadRecentEvents()); } @@ -49,6 +91,7 @@ private async void OnSubathonEventProcessed(SubathonEvent subathonEvent, bool wa && subathonEvent.EventType != SubathonEventType.Command && subathonEvent.EventType != SubathonEventType.DonationAdjustment && subathonEvent.EventType != SubathonEventType.TwitchHypeTrain) return; + if (subathonEvent.EventType is { } type && _hiddenTypes.Contains(type)) return; await Dispatcher.UIThread.InvokeAsync(() => { SubathonEvent? existing = EventItems.FirstOrDefault(x => x.Id == subathonEvent.Id); @@ -66,8 +109,10 @@ private async Task LoadRecentEvents() { await using AppDbContext db = await _factory.CreateDbContextAsync(); SubathonData? subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); List events = new(); + List hidden = _hiddenTypes.Select(t => (SubathonEventType?)t).ToList(); if (subathon != null) events = await db.SubathonEvents.Where(ev => ev.SubathonId == subathon.Id + && !hidden.Contains(ev.EventType) && (showOverride || ev.SecondsValue > 0 || ev.PointsValue >= 1 || ev.Command != SubathonCommandType.None || ev.EventType == SubathonEventType.TwitchHypeTrain diff --git a/SubathonManager.UI/Views/SubathonSummaryWindow.Leaderboard.cs b/SubathonManager.UI/Views/SubathonSummaryWindow.Leaderboard.cs index 4df917bb..01903513 100644 --- a/SubathonManager.UI/Views/SubathonSummaryWindow.Leaderboard.cs +++ b/SubathonManager.UI/Views/SubathonSummaryWindow.Leaderboard.cs @@ -6,6 +6,7 @@ using System.Web; using Avalonia.Interactivity; using Microsoft.Extensions.Logging; +using SubathonManager.UI.Controls; using SubathonManager.UI.UiUtils; // ReSharper disable NullableWarningSuppressionIsUsed @@ -24,7 +25,7 @@ public partial class SubathonSummaryWindow { private void InitLeaderboardTab() { LbTypePopout.EmptyText = "Pick at least one event type"; - LbTypePopout.SetOptions(BuildTypeOptions()); + LbTypePopout.SetOptions(FilterOption.EventTypes()); LbMethodBox.ItemsSource = LeaderboardMethods; LbMethodBox.SelectedIndex = 0; diff --git a/SubathonManager.UI/Views/SubathonSummaryWindow.axaml.cs b/SubathonManager.UI/Views/SubathonSummaryWindow.axaml.cs index 76292c8a..185be649 100644 --- a/SubathonManager.UI/Views/SubathonSummaryWindow.axaml.cs +++ b/SubathonManager.UI/Views/SubathonSummaryWindow.axaml.cs @@ -73,7 +73,7 @@ public SubathonSummaryWindow() { SourcePopout.EmptyText = "All sources"; TypePopout.EmptyText = "All event types"; SourcePopout.SetOptions(BuildSourceOptions()); - TypePopout.SetOptions(BuildTypeOptions()); + TypePopout.SetOptions(FilterOption.EventTypes()); InitLeaderboardTab(); @@ -122,19 +122,6 @@ await Dispatcher.UIThread.InvokeAsync(() => { }); } - private static List BuildTypeOptions() { - return Enum.GetValues() - .Where(t => t is not (SubathonEventType.Unknown or SubathonEventType.Command)) - .OrderBy(t => SubathonEventSourceHelper.GetSourceOrder(((SubathonEventType?)t).GetSource())) - .ThenBy(t => ((SubathonEventType?)t).GetLabel(), StringComparer.OrdinalIgnoreCase) - .Select(t => new FilterOption { - Label = ((SubathonEventType?)t).GetLabel(), - Value = t.ToString(), - Group = ((SubathonEventType?)t).GetSource().ToString() - }) - .ToList(); - } - private static List BuildSourceOptions() { return Enum.GetValues() .Where(s => s != SubathonEventSource.Unknown) From c786e2e17e72545f68b5eaeb7bc8e1af4eaedf3d Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:44:47 -0300 Subject: [PATCH 13/15] fix fourthwall not starting devtunnel --- .../FourthWallService.cs | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/SubathonManager.Integration/FourthWallService.cs b/SubathonManager.Integration/FourthWallService.cs index 2df4111b..e50b9a21 100644 --- a/SubathonManager.Integration/FourthWallService.cs +++ b/SubathonManager.Integration/FourthWallService.cs @@ -43,6 +43,7 @@ public class FourthWallService( private readonly string _configSection = "FourthWall"; private readonly FourthwallWebhookHandler _handler = new(new FourthwallWebhookSignatureVerifier()); + private readonly SemaphoreSlim _initLock = new(1, 1); internal readonly string _oAuthURl = "https://oauth.subathonmanager.app/auth/fourthwall/login"; internal readonly string _refreshURl = "https://oauth.subathonmanager.app/auth/fourthwall/refresh"; @@ -153,8 +154,32 @@ private async Task CheckForTokenAsync(CancellationToken ct = default) { [ExcludeFromCodeCoverage] public async Task Initialize(CancellationToken ct = default) { + if (!await _initLock.WaitAsync(0, ct)) return; + try { + await InitializeCoreAsync(ct); + } + finally { + _initLock.Release(); + } + } + + [ExcludeFromCodeCoverage] + private async Task InitializeCoreAsync(CancellationToken ct) { IntegrationConnection tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); - if (!tunnelConn.Status) return; + if (!tunnelConn.Status) { + await devTunnels.StartTunnelAsync(ct); + tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); + if (!tunnelConn.Status) { + string reason = !devTunnels.IsCliInstalled ? "the DevTunnels CLI isn't installed" + : !devTunnels.IsLoggedIn ? "DevTunnels isn't logged in" + : "the tunnel failed to start"; + logger?.LogWarning("[FourthWall] Can't connect: {Reason}", reason); + ErrorMessageEvents.RaiseErrorEvent("WARN", nameof(SubathonEventSource.FourthWall), + $"FourthWall needs a DevTunnel but {reason}. Check the DevTunnels settings.", DateTime.Now); + BroadcastStatus(HasTokenFile(), null); + return; + } + } bool canConnect = await CheckForTokenAsync(ct); if (!canConnect || string.IsNullOrWhiteSpace(AccessToken)) { @@ -280,9 +305,10 @@ private async Task StartOAuthFlowAsync() { logger?.LogDebug("Opening FourthWall OAuth..."); OpenBrowser(_oAuthURl); (string? newAccess, string? newRefresh) = await WaitForProtocolCallbackAsync(); - if (!string.IsNullOrEmpty(AccessToken) || string.IsNullOrEmpty(RefreshToken)) { - secureStorage.Set(StorageKeys.FourthWallAccessToken, newAccess!); - secureStorage.Set(StorageKeys.FourthWallRefreshToken, newRefresh!); + + if (!string.IsNullOrEmpty(newAccess) && !string.IsNullOrEmpty(newRefresh)) { + secureStorage.Set(StorageKeys.FourthWallAccessToken, newAccess); + secureStorage.Set(StorageKeys.FourthWallRefreshToken, newRefresh); } } From 2ec0d0db75b8abd6facf44662773534356fded88 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:27:19 -0300 Subject: [PATCH 14/15] soft-support fourthwall order cancellations --- SubathonManager.Core/Events/SubathonEvents.cs | 5 +++ .../FourthWallService.cs | 45 ++++++++++++++++++- SubathonManager.Services/EventService.cs | 35 +++++++++++++++ .../FourthWallUnitTests.cs | 39 ++++++++++++++++ .../ServicesUnitTests/EventServiceTests.cs | 42 +++++++++++++++++ .../External/FourthWallSettings.axaml | 2 + .../External/FourthWallSettings.axaml.cs | 3 ++ 7 files changed, 170 insertions(+), 1 deletion(-) diff --git a/SubathonManager.Core/Events/SubathonEvents.cs b/SubathonManager.Core/Events/SubathonEvents.cs index 66df15f8..0f37470b 100644 --- a/SubathonManager.Core/Events/SubathonEvents.cs +++ b/SubathonManager.Core/Events/SubathonEvents.cs @@ -13,6 +13,7 @@ public static event Action? SubathonEventProcessed; // Run through queue, processed or not to subathon public static event Action>? SubathonEventsDeleted; + public static event Action? SubathonEventCancelled; public static event Action? SubathonDataUpdate; public static event Action, long, GoalsType>? SubathonGoalListUpdated; @@ -77,6 +78,10 @@ public static void RaiseSubathonEventsDeleted(List subathonEvent) SubathonEventsDeleted?.Invoke(subathonEvent); } + public static void RaiseSubathonEventCancelled(Guid id, SubathonEventType type, string reference) { + SubathonEventCancelled?.Invoke(id, type, reference); + } + public static void RaiseSubathonEventCreated(SubathonEvent subathonEvent) { SubathonEventCreated?.Invoke(subathonEvent); } diff --git a/SubathonManager.Integration/FourthWallService.cs b/SubathonManager.Integration/FourthWallService.cs index e50b9a21..14366af2 100644 --- a/SubathonManager.Integration/FourthWallService.cs +++ b/SubathonManager.Integration/FourthWallService.cs @@ -56,6 +56,7 @@ public class FourthWallService( private string? RefreshToken => secureStorage.GetOrDefault(StorageKeys.FourthWallRefreshToken, string.Empty); private string? ShopName { get; set; } public string WebhookPath => "/api/webhooks/fourthwall"; + public const string AutoDeleteCancelledKey = "AutoDeleteCancelledOrders"; public async Task StartAsync(CancellationToken ct = default) { IntegrationEvents.ConnectionUpdated += OnTunnelUpdated; @@ -114,6 +115,11 @@ public async Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary types = + (webhookConfigurationV1.AllowedTypes ?? []) + .Select(t => Enum.TryParse($"{t}", out WebhookConfigurationUpdateRequest_allowedTypes u) + ? u + : (WebhookConfigurationUpdateRequest_allowedTypes?)null) + .Where(t => t != null) + .Append(WebhookConfigurationUpdateRequest_allowedTypes.ORDER_UPDATED) + .ToList(); + await client.OpenApi.V10.Webhooks[webhookConfigurationV1.Id].PutAsync( + new WebhookConfigurationUpdateRequest { + Url = webhookConfigurationV1.Url, AllowedTypes = types + }, cancellationToken: ct); + logger?.LogInformation("[FourthWall] Added order updates to existing webhook"); + } + catch (Exception ex) { + logger?.LogWarning(ex, "[FourthWall] Couldn't add order updates to existing webhook"); + } + break; } @@ -226,6 +268,7 @@ private async Task InitializeCoreAsync(CancellationToken ct) { Url = fullUrl, AllowedTypes = [ WebhookConfigurationCreateRequest_allowedTypes.ORDER_PLACED, + WebhookConfigurationCreateRequest_allowedTypes.ORDER_UPDATED, WebhookConfigurationCreateRequest_allowedTypes.DONATION, WebhookConfigurationCreateRequest_allowedTypes.SUBSCRIPTION_PURCHASED, WebhookConfigurationCreateRequest_allowedTypes.SUBSCRIPTION_CHANGED, diff --git a/SubathonManager.Services/EventService.cs b/SubathonManager.Services/EventService.cs index 6a7574b8..bb9d5b9c 100644 --- a/SubathonManager.Services/EventService.cs +++ b/SubathonManager.Services/EventService.cs @@ -37,6 +37,7 @@ public EventService(IDbContextFactory factory, ILogger _logger?.LogError("Event loop crashed: {AggregateException}", t.Exception), @@ -47,6 +48,8 @@ public Task StartAsync(CancellationToken ct = default) { } public async Task StopAsync(CancellationToken ct = default) { + SubathonEvents.SubathonEventCreated -= AddSubathonEvent; + SubathonEvents.SubathonEventCancelled -= OnSubathonEventCancelled; if (!_cts.IsCancellationRequested) _cts.Cancel(); _signal.Release(); @@ -587,6 +590,38 @@ private static async Task CheckForGoalChange(AppDbContext db, long newPoints, lo await Task.CompletedTask; } + private void OnSubathonEventCancelled(Guid id, SubathonEventType type, string reference) { + Task.Run(async () => { + try { + await DeleteCancelledEventAsync(id, type, reference); + } + catch (Exception ex) { + _logger?.LogError(ex, "Failed to remove cancelled {EventType} {Reference}", type, reference); + } + }); + } + + public async Task DeleteCancelledEventAsync(Guid id, SubathonEventType type, string reference) { + await using AppDbContext db = await _factory.CreateDbContextAsync(); + SubathonEvent? ev = await db.SubathonEvents.FirstOrDefaultAsync(e => e.Id == id && e.EventType == type); + if (ev == null) { + _logger?.LogDebug("Cancelled {EventType} {Reference} was never tracked, nothing to remove", type, reference); + return false; + } + + bool inActive = ev.SubathonId != null && + await db.SubathonDatas.AnyAsync(s => s.IsActive && s.Id == ev.SubathonId); + string label = ((SubathonEventType?)type).GetLabel(); + string msg = inActive + ? $"{label} {reference} from {ev.User} was cancelled. Removed its event ({ev.Value} {ev.Currency})." + : $"{label} {reference} from {ev.User} was cancelled, but it's from a past subathon, so it will be kept"; + _logger?.LogWarning("{Message}", msg); + ErrorMessageEvents.RaiseErrorEvent("WARN", ev.Source.ToString(), msg, DateTime.Now); + + if (inActive) await DeleteSubathonEvent(db, ev); + return inActive; + } + public async Task DeleteSubathonEvent(AppDbContext db, SubathonEvent ev) { if (ev.SubathonId == null) return; diff --git a/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs b/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs index 7ae953bd..2a743dd5 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/FourthWallUnitTests.cs @@ -14,6 +14,7 @@ using Fourthwall.Client.Generated.Models.Openapi.Model.OfferAbstractV1.OfferVariantAbstractV1; using Fourthwall.Client.Generated.Models.Openapi.Model.OrderV1; using Fourthwall.Client.Generated.Models.Openapi.Model.OrderV1.Source; +using Fourthwall.Client.Models; using Microsoft.Extensions.Logging; using Moq; using SubathonManager.Core.Enums; @@ -554,6 +555,44 @@ public void MapToSubathonEvent_MembershipPurchased_TestMode_ProducesUniqueGuidEa Assert.NotEqual(ev1!.Id, ev2!.Id); } + [Theory] + [InlineData(true, OrderV1_status.CANCELLED, true)] + [InlineData(false, OrderV1_status.CANCELLED, false)] + [InlineData(true, OrderV1_status.SHIPPED, false)] + public void HandleOrderUpdated_CancelledOrder_RaisesCancelForPlacedEventId(bool enabled, OrderV1_status status, + bool expectRaised) { + (FourthWallService service, _) = MakeService(new Dictionary<(string, string), string> { + { ("FourthWall", FourthWallService.AutoDeleteCancelledKey), $"{enabled}" } + }); + FourthwallOrderPlacedWebhookEvent placed = MakeOrderEvent(id: "ord_abc123"); + SubathonEvent? placedEv = service.MapToSubathonEvent(placed); + + placed.Data.Status = status; + placed.Data.FriendlyId = "FW-1234"; + var updated = new FourthwallOrderUpdatedWebhookEvent { + Id = Guid.NewGuid().ToString(), + Data = new OrderUpdatedV1 { Order = placed.Data, Update = new OrderUpdatedV1Update { Type = "STATUS" } }, + CreatedAt = DateTimeOffset.UtcNow, + TestMode = false, + WebhookId = "", ShopId = "", Type = "ORDER_UPDATED", ApiVersion = "" + }; + + (Guid Id, SubathonEventType Type, string Reference)? raised = null; + void OnCancelled(Guid id, SubathonEventType type, string reference) { + raised = (id, type, reference); + } + SubathonEvents.SubathonEventCancelled += OnCancelled; + bool result = service.HandleOrderUpdated(updated); + SubathonEvents.SubathonEventCancelled -= OnCancelled; + + Assert.Equal(expectRaised, result); + Assert.Equal(expectRaised, raised != null); + if (!expectRaised) return; + Assert.Equal(placedEv!.Id, raised!.Value.Id); + Assert.Equal(SubathonEventType.FourthWallOrder, raised.Value.Type); + Assert.Equal("#FW-1234", raised.Value.Reference); + } + [Fact] public async Task HandleWebhookAsync_ForwardsToConfiguredUrl() { await using MockWebServerHost mockServer = new MockWebServerHost().OnPost("/fw-forward", ""); diff --git a/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs b/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs index d456305a..4106ca1b 100644 --- a/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs +++ b/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs @@ -1317,6 +1317,48 @@ public async Task DonationCrossingGoal_RaisesGoalCompletedOnce() { await conn.CloseAsync(); } + [Fact] + public async Task CancelledOrder_RemovesTrackedEventAndWarns() { + (EventService service, DbContextOptions options, SqliteConnection conn) = + await SetupServiceWithDb(0, false); + + var order = new SubathonEvent { + Id = Guid.NewGuid(), EventType = SubathonEventType.FourthWallOrder, Source = SubathonEventSource.FourthWall, + User = "Buyer", Currency = "USD", Value = "25.00", Amount = 1, SecondaryValue = "10.00|USD" + }; + (bool processed, _) = await service.ProcessSubathonEvent(order); + Assert.True(processed); + + var deleted = new TaskCompletionSource(); + var warned = false; + void OnDeleted(List evs) { + if (evs.Any(e => e.Id == order.Id)) deleted.TrySetResult(true); + } + void OnError(string level, string source, string message, DateTime _) { + if (level == "WARN" && message.Contains("#1234")) warned = true; + } + SubathonEvents.SubathonEventsDeleted += OnDeleted; + ErrorMessageEvents.ErrorEventOccured += OnError; + + SubathonEvents.RaiseSubathonEventCancelled(order.Id, SubathonEventType.FourthWallOrder, "#1234"); + await Task.WhenAny(deleted.Task, Task.Delay(5000, TestContext.Current.CancellationToken)); + + SubathonEvents.SubathonEventsDeleted -= OnDeleted; + ErrorMessageEvents.ErrorEventOccured -= OnError; + + Assert.True(deleted.Task.IsCompleted, "Cancelled order's event was never removed"); + Assert.True(warned); + await using (var db = new AppDbContext(options)) { + Assert.False(await db.SubathonEvents.AnyAsync(e => e.Id == order.Id, + TestContext.Current.CancellationToken)); + } + + Assert.False(await service.DeleteCancelledEventAsync(Guid.NewGuid(), SubathonEventType.FourthWallOrder, "#9")); + + await service.StopAsync(TestContext.Current.CancellationToken); + await conn.CloseAsync(); + } + [Fact] public async Task ProcessSubathonEvent_MoneyChangedAfterSave_RaisesDataUpdate() { (EventService service, DbContextOptions options, SqliteConnection conn) = diff --git a/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml b/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml index ea60d748..79680b67 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml @@ -159,6 +159,8 @@ + diff --git a/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml.cs index 6d75170f..4d8003a6 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml.cs @@ -95,6 +95,7 @@ private void LoadConfigValues() { $"{SubathonEventType.FourthWallGiftOrder}", OrderTypeModes.Dollar)}"; GiftCommissionBox.IsChecked = config.GetBool(configSection, $"{nameof(SubathonEventType.FourthWallGiftOrder).Split("Order")[0]}.CommissionAsDonation"); + AutoDeleteCancelledBox.IsChecked = config.GetBool(configSection, FourthWallService.AutoDeleteCancelledKey, false); } public override bool UpdateValueSettings(AppDbContext db) { @@ -222,6 +223,8 @@ protected internal override bool UpdateConfigValueSettings() { hasUpdated |= config.SetBool(configSection, $"{nameof(SubathonEventType.FourthWallOrder).Split("Order")[0]}.CommissionAsDonation", OrderCommissionBox.IsChecked ?? false); + hasUpdated |= config.SetBool(configSection, FourthWallService.AutoDeleteCancelledKey, + AutoDeleteCancelledBox.IsChecked ?? false); return hasUpdated; } From 23ef9e5c43ca531afd7216c04226dfe84ec0a946 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:50:01 -0300 Subject: [PATCH 15/15] allow widget drag reorder --- .../EditRouteWindow.Handlers.cs | 87 ++++++++++++++++--- SubathonManager.UI/EditRouteWindow.axaml | 26 ++++-- SubathonManager.UI/EditRouteWindow.axaml.cs | 22 +++++ 3 files changed, 120 insertions(+), 15 deletions(-) diff --git a/SubathonManager.UI/EditRouteWindow.Handlers.cs b/SubathonManager.UI/EditRouteWindow.Handlers.cs index 8b0fc1d6..a651cf41 100644 --- a/SubathonManager.UI/EditRouteWindow.Handlers.cs +++ b/SubathonManager.UI/EditRouteWindow.Handlers.cs @@ -31,7 +31,7 @@ namespace SubathonManager.UI; public partial class EditRouteWindow { private MenuFlyout? _statusFlyout; - + #region GeneralHandlers private void WidgetDirtyBorder_Loaded(object? sender, RoutedEventArgs e) { @@ -148,11 +148,11 @@ private async void CopyOverlayUrl_Click(object? sender, RoutedEventArgs e) { _logger?.LogError(ex, "Failed to copy overlay URL"); } } - + private void ShowObsCanvasSelection_Click(object? sender, RoutedEventArgs e) { var flyout = new MenuFlyout { Placement = PlacementMode.Bottom }; - - FillObsCanvases(flyout.Items);/////// + + FillObsCanvases(flyout.Items); /////// flyout.Closed += (_, _) => { if (ReferenceEquals(_statusFlyout, flyout)) _statusFlyout = null; }; @@ -162,12 +162,12 @@ private void ShowObsCanvasSelection_Click(object? sender, RoutedEventArgs e) { private void FillObsCanvases(ItemCollection items) { items.Clear(); - var canvases = ServiceManager.OBS.GetCanvases(); + Dictionary> canvases = ServiceManager.OBS.GetCanvases(); if (canvases.Count == 0) return; - - foreach (var canvasData in canvases) { - var width = canvasData.Value["Width"]; - var height = canvasData.Value["Height"]; + + foreach (KeyValuePair> canvasData in canvases) { + int width = canvasData.Value["Width"]; + int height = canvasData.Value["Height"]; var name = $"{canvasData.Key} (WxH {width}x{height})"; var groupItem = new MenuItem { Header = name @@ -934,6 +934,73 @@ private async void MoveDown_Click(object? sender, RoutedEventArgs e) { } } + private void WidgetGrip_PointerPressed(object? sender, PointerPressedEventArgs e) { + if (sender is not Control { Tag: Widget w } grip || !e.GetCurrentPoint(grip).Properties.IsLeftButtonPressed) + return; + _dragWidget = w; + _dragTargetIndex = _widgets.IndexOf(w); + WidgetsList.ContainerFromItem(w)?.SetValue(OpacityProperty, 0.6); + e.Pointer.Capture(grip); + e.Handled = true; + } + + private void WidgetGrip_PointerMoved(object? sender, PointerEventArgs e) { + if (_dragWidget == null) return; + int from = _widgets.IndexOf(_dragWidget); + if (from < 0) return; + + double viewY = e.GetPosition(WidgetsScroll).Y; + if (viewY < 24) WidgetsScroll.Offset = WidgetsScroll.Offset.WithY(Math.Max(0, WidgetsScroll.Offset.Y - 12)); + else if (viewY > WidgetsScroll.Bounds.Height - 24) + WidgetsScroll.Offset = WidgetsScroll.Offset.WithY(WidgetsScroll.Offset.Y + 12); + + double y = e.GetPosition(WidgetsList).Y; + int target = from; + Rect? targetBounds = null; + for (var i = 0; i < _widgets.Count; i++) { + if (WidgetsList.ContainerFromIndex(i) is not { } c) continue; + Rect b = c.Bounds; + if ((i == 0 && y < b.Top) || (y >= b.Top && y <= b.Bottom) || (i == _widgets.Count - 1 && y > b.Bottom)) { + target = i; + targetBounds = b; + break; + } + } + + if (targetBounds is not { } tb) return; + _dragTargetIndex = target; + WidgetDropLine.IsVisible = target != from; + WidgetDropLine.Margin = new Thickness(0, target < from ? Math.Max(0, tb.Top - 5) : tb.Bottom - 6, 0, 0); + } + + private async void WidgetGrip_PointerReleased(object? sender, PointerReleasedEventArgs e) { + e.Pointer.Capture(null); + await FinishWidgetDragAsync(); + } + + private async void WidgetGrip_PointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) { + await FinishWidgetDragAsync(); + } + + private async Task FinishWidgetDragAsync() { + if (_dragWidget == null) return; + Widget moving = _dragWidget; + int to = _dragTargetIndex; + _dragWidget = null; + _dragTargetIndex = -1; + WidgetDropLine.IsVisible = false; + WidgetsList.ContainerFromItem(moving)?.SetValue(OpacityProperty, 1.0); + + int from = _widgets.IndexOf(moving); + if (from < 0 || to < 0 || to >= _widgets.Count || from == to) return; + try { + await MoveWidgetZAsync(from, to); + } + catch (Exception ex) { + _logger?.LogError(ex, "Failed to reorder widget Z-Index"); + } + } + private async void SaveRouteButton_Click(object? sender, RoutedEventArgs e) { if (_route == null) return; await SaveCurrentRoute(); @@ -1232,7 +1299,7 @@ private static void SetJsEventTypeButtonLabel(Button btn, string? value) { private static string JsEventTypeDisplay(string? value) { if (string.IsNullOrWhiteSpace(value)) return "- none -"; - if (Enum.TryParse(value, out SubathonEventType et)) + if (Enum.TryParse(value, out SubathonEventType et)) return $"{et.GetSource()} - {et.GetLabel()}"; GoAffProStore? store = GoAffProStoreRegistry.All().FirstOrDefault(s => s.InternalEventName == value); return store != null ? $"{SubathonEventSource.GoAffPro} - {store.EventName}" : value; diff --git a/SubathonManager.UI/EditRouteWindow.axaml b/SubathonManager.UI/EditRouteWindow.axaml index cafaa09b..a725c437 100644 --- a/SubathonManager.UI/EditRouteWindow.axaml +++ b/SubathonManager.UI/EditRouteWindow.axaml @@ -421,7 +421,11 @@ - + + + @@ -458,8 +462,18 @@ BorderBrush="Transparent" Padding="10" Tag="{Binding Id}" Loaded="WidgetDirtyBorder_Loaded" Unloaded="WidgetDirtyBorder_Unloaded"> - - + + + + + - -