diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SharedInfo.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SharedInfo.cs index fa5e2503a..08781f785 100644 --- a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SharedInfo.cs +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SharedInfo.cs @@ -25,7 +25,6 @@ namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper; internal static class SharedInfo { internal const byte ApiVersion = 2; - internal const byte AppInfosPerSingleRequest = byte.MaxValue; internal const byte HoursBetweenUploads = 24; internal const byte MaximumHoursBetweenRefresh = 8; // Per single bot account, makes sense to be 2 or 3 times less than MinimumHoursBetweenUploads internal const byte MaximumMinutesBeforeFirstUpload = 60; // Must be greater or equal to MinimumMinutesBeforeFirstUpload diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperPlugin.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperPlugin.cs index 58197aa18..013d5e5b0 100644 --- a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperPlugin.cs +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperPlugin.cs @@ -360,7 +360,7 @@ internal sealed class SteamTokenDumperPlugin : OfficialPlugin, IASF, IBot, IBotC bot.ArchiLogger.LogGenericInfo(Strings.FormatBotRetrievingTotalAppAccessTokens(appIDsToRefresh.Count)); - HashSet appIDsThisRound = new(Math.Min(appIDsToRefresh.Count, SharedInfo.AppInfosPerSingleRequest)); + HashSet appIDsThisRound = new(Math.Min(appIDsToRefresh.Count, Bot.EntriesPerSinglePICSRequest)); using (HashSet.Enumerator enumerator = appIDsToRefresh.GetEnumerator()) { while (true) { @@ -368,7 +368,7 @@ internal sealed class SteamTokenDumperPlugin : OfficialPlugin, IASF, IBot, IBotC return; } - while ((appIDsThisRound.Count < SharedInfo.AppInfosPerSingleRequest) && enumerator.MoveNext()) { + while ((appIDsThisRound.Count < Bot.EntriesPerSinglePICSRequest) && enumerator.MoveNext()) { appIDsThisRound.Add(enumerator.Current); } @@ -409,7 +409,7 @@ internal sealed class SteamTokenDumperPlugin : OfficialPlugin, IASF, IBot, IBotC return; } - while ((appIDsThisRound.Count < SharedInfo.AppInfosPerSingleRequest) && enumerator.MoveNext()) { + while ((appIDsThisRound.Count < Bot.EntriesPerSinglePICSRequest) && enumerator.MoveNext()) { appIDsThisRound.Add(enumerator.Current); } diff --git a/ArchiSteamFarm/Steam/Bot.cs b/ArchiSteamFarm/Steam/Bot.cs index db1079432..cd6aaf785 100644 --- a/ArchiSteamFarm/Steam/Bot.cs +++ b/ArchiSteamFarm/Steam/Bot.cs @@ -30,6 +30,7 @@ using System.Collections.Immutable; using System.Collections.Specialized; using System.ComponentModel; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; @@ -65,9 +66,11 @@ namespace ArchiSteamFarm.Steam; public sealed class Bot : IAsyncDisposable, IDisposable { internal const ushort CallbackSleep = 500; // In milliseconds + internal const byte EntriesPerSinglePICSRequest = byte.MaxValue; internal const byte MinCardsPerBadge = 5; private const char DefaultBackgroundKeysRedeemerSeparator = '\t'; + private const byte ExtraStorePackagesValidForDays = 7; private const byte LoginCooldownInMinutes = 25; // Captcha disappears after around 20 minutes, so we make it 25 private const uint LoginID = 1242; // This must be the same for all ASF bots and all ASF processes private const byte MaxLoginFailures = WebBrowser.MaxTries; // Max login failures in a row before we determine that our credentials are invalid (because Steam wrongly returns those, of course)course) @@ -2100,6 +2103,24 @@ public sealed class Bot : IAsyncDisposable, IDisposable { UnpackBoosterPacksSemaphore.Dispose(); } + private async Task ExtendWithStoreData([SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")] Dictionary ownedPackages, HashSet allPackages, Dictionary packagesToRefresh) { + ArgumentNullException.ThrowIfNull(ownedPackages); + ArgumentNullException.ThrowIfNull(allPackages); + ArgumentNullException.ThrowIfNull(packagesToRefresh); + + if (BotDatabase.ExtraStorePackagesRefreshedAt.AddDays(ExtraStorePackagesValidForDays) < DateTime.UtcNow) { + await RefreshStoreData(allPackages, packagesToRefresh).ConfigureAwait(false); + } + + foreach (uint packageID in BotDatabase.ExtraStorePackages) { + ownedPackages[packageID] = new LicenseData { + PackageID = packageID, + PaymentMethod = EPaymentMethod.None, + TimeCreated = DateTime.UnixEpoch + }; + } + } + private async Task?> GetKeysFromFile(string filePath) { ArgumentException.ThrowIfNullOrEmpty(filePath); @@ -3171,15 +3192,22 @@ public sealed class Bot : IAsyncDisposable, IDisposable { Commands.OnNewLicenseList(); - Dictionary ownedPackages = new(); + Dictionary ownedPackages = []; + HashSet allPackages = []; - Dictionary packageAccessTokens = new(); - Dictionary packagesToRefresh = new(); + Dictionary packageAccessTokens = []; + Dictionary packagesToRefresh = []; bool hasNewEntries = false; // We want to record only the most relevant entry from non-borrowed games, therefore we also apply ordering here - foreach (SteamApps.LicenseListCallback.License license in callback.LicenseList.Where(static license => !license.LicenseFlags.HasFlag(ELicenseFlags.Borrowed)).OrderByDescending(static license => license.TimeCreated).Where(license => !ownedPackages.ContainsKey(license.PackageID))) { + foreach (SteamApps.LicenseListCallback.License license in callback.LicenseList.OrderByDescending(static license => license.TimeCreated)) { + allPackages.Add(license.PackageID); + + if (license.LicenseFlags.HasFlag(ELicenseFlags.Borrowed) || ownedPackages.ContainsKey(license.PackageID)) { + continue; + } + ownedPackages[license.PackageID] = new LicenseData { PackageID = license.PackageID, PaymentMethod = license.PaymentMethod, @@ -3200,6 +3228,8 @@ public sealed class Bot : IAsyncDisposable, IDisposable { } } + await ExtendWithStoreData(ownedPackages, allPackages, packagesToRefresh).ConfigureAwait(false); + OwnedPackages = ownedPackages.ToFrozenDictionary(); if (packageAccessTokens.Count > 0) { @@ -3687,6 +3717,45 @@ public sealed class Bot : IAsyncDisposable, IDisposable { } } + private async Task RefreshStoreData([SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")] HashSet allPackages, [SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")] Dictionary packagesToRefresh) { + ArgumentNullException.ThrowIfNull(allPackages); + ArgumentNullException.ThrowIfNull(packagesToRefresh); + + if (ASF.GlobalDatabase == null) { + throw new InvalidOperationException(nameof(ASF.GlobalDatabase)); + } + + StoreUserData? storeData = await ArchiWebHandler.GetStoreUserData().ConfigureAwait(false); + + if (storeData == null) { + return; + } + + BotDatabase.ExtraStorePackages.ReplaceWith(storeData.OwnedPackages.Where(packageID => !allPackages.Contains(packageID))); + BotDatabase.ExtraStorePackagesRefreshedAt = DateTime.UtcNow; + + foreach (uint[] packageIDs in BotDatabase.ExtraStorePackages.Chunk(EntriesPerSinglePICSRequest)) { + try { + SteamApps.PICSTokensCallback accessTokens = await SteamApps.PICSGetAccessTokens([], packageIDs); + + if (accessTokens.PackageTokens.Count > 0) { + ASF.GlobalDatabase.RefreshPackageAccessTokens(accessTokens.PackageTokens); + } + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + } + } + + // Wait up to 5 seconds for initialization, we can work with any change number, although non-zero is preferred + for (byte i = 0; (i < WebBrowser.MaxTries) && (SteamPICSChanges.LastChangeNumber == 0); i++) { + await Task.Delay(1000).ConfigureAwait(false); + } + + foreach (uint packageID in BotDatabase.ExtraStorePackages) { + packagesToRefresh.Add(packageID, SteamPICSChanges.LastChangeNumber); + } + } + private async Task ResetGamesPlayed() { if (!IsConnectedAndLoggedOn || CardsFarmer.NowFarming) { return; diff --git a/ArchiSteamFarm/Steam/Data/StoreUserData.cs b/ArchiSteamFarm/Steam/Data/StoreUserData.cs new file mode 100644 index 000000000..a74177d33 --- /dev/null +++ b/ArchiSteamFarm/Steam/Data/StoreUserData.cs @@ -0,0 +1,39 @@ +// ---------------------------------------------------------------------------------------------- +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// ---------------------------------------------------------------------------------------------- +// | +// Copyright 2015-2025 Ɓukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// | +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// | +// http://www.apache.org/licenses/LICENSE-2.0 +// | +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace ArchiSteamFarm.Steam.Data; + +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +internal sealed class StoreUserData { + [JsonInclude] + [JsonPropertyName("rgOwnedPackages")] + [JsonRequired] + internal ImmutableHashSet OwnedPackages { get; private init; } = ImmutableHashSet.Empty; + + [JsonConstructor] + private StoreUserData() { } +} diff --git a/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs b/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs index 751cf3350..c61fe56bb 100644 --- a/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs +++ b/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs @@ -1875,6 +1875,14 @@ public sealed class ArchiWebHandler : IDisposable { return response?.Content; } + internal async Task GetStoreUserData() { + Uri request = new(SteamStoreURL, "/dynamicstore/userdata?l=english"); + + ObjectResponse? response = await UrlGetToJsonObjectWithSession(request).ConfigureAwait(false); + + return response?.Content; + } + internal async Task GetTradeHoldDurationForTrade(ulong tradeID) { ArgumentOutOfRangeException.ThrowIfZero(tradeID); diff --git a/ArchiSteamFarm/Steam/Integration/SteamPICSChanges.cs b/ArchiSteamFarm/Steam/Integration/SteamPICSChanges.cs index abdc8db91..547753fff 100644 --- a/ArchiSteamFarm/Steam/Integration/SteamPICSChanges.cs +++ b/ArchiSteamFarm/Steam/Integration/SteamPICSChanges.cs @@ -35,12 +35,12 @@ namespace ArchiSteamFarm.Steam.Integration; internal static class SteamPICSChanges { private const byte RefreshTimerInMinutes = 5; + internal static uint LastChangeNumber { get; private set; } internal static bool LiveUpdate { get; private set; } private static readonly SemaphoreSlim RefreshSemaphore = new(1, 1); private static readonly Timer RefreshTimer = new(RefreshChanges); - private static uint LastChangeNumber; private static bool TimerAlreadySet; internal static void Init(uint changeNumberToStartFrom) => LastChangeNumber = changeNumberToStartFrom; diff --git a/ArchiSteamFarm/Steam/Storage/BotDatabase.cs b/ArchiSteamFarm/Steam/Storage/BotDatabase.cs index 645c22114..899351ce6 100644 --- a/ArchiSteamFarm/Steam/Storage/BotDatabase.cs +++ b/ArchiSteamFarm/Steam/Storage/BotDatabase.cs @@ -63,6 +63,23 @@ public sealed class BotDatabase : GenericDatabase { } } + [JsonDisallowNull] + [JsonInclude] + internal ConcurrentHashSet ExtraStorePackages { get; private init; } = []; + + internal DateTime ExtraStorePackagesRefreshedAt { + get => BackingExtraStorePackagesRefreshedAt; + + set { + if (BackingExtraStorePackagesRefreshedAt == value) { + return; + } + + BackingExtraStorePackagesRefreshedAt = value; + Utilities.InBackground(Save); + } + } + [JsonDisallowNull] [JsonInclude] internal ConcurrentHashSet FarmingBlacklistAppIDs { get; private init; } = []; @@ -129,6 +146,9 @@ public sealed class BotDatabase : GenericDatabase { [JsonInclude] private string? BackingAccessToken { get; set; } + [JsonInclude] + private DateTime BackingExtraStorePackagesRefreshedAt { get; set; } + [JsonInclude] [JsonPropertyName($"_{nameof(MobileAuthenticator)}")] private MobileAuthenticator? BackingMobileAuthenticator { get; set; } @@ -151,6 +171,7 @@ public sealed class BotDatabase : GenericDatabase { [JsonConstructor] private BotDatabase() { + ExtraStorePackages.OnModified += OnObjectModified; FarmingBlacklistAppIDs.OnModified += OnObjectModified; FarmingPriorityQueueAppIDs.OnModified += OnObjectModified; FarmingRiskyIgnoredAppIDs.OnModified += OnObjectModified; @@ -188,6 +209,9 @@ public sealed class BotDatabase : GenericDatabase { [UsedImplicitly] public bool ShouldSerializeBackingAccessToken() => !string.IsNullOrEmpty(BackingAccessToken); + [UsedImplicitly] + public bool ShouldSerializeBackingExtraStorePackagesRefreshedAt() => BackingExtraStorePackagesRefreshedAt > DateTime.MinValue; + [UsedImplicitly] public bool ShouldSerializeBackingMobileAuthenticator() => BackingMobileAuthenticator != null; @@ -197,6 +221,9 @@ public sealed class BotDatabase : GenericDatabase { [UsedImplicitly] public bool ShouldSerializeBackingSteamGuardData() => !string.IsNullOrEmpty(BackingSteamGuardData); + [UsedImplicitly] + public bool ShouldSerializeExtraStorePackages() => ExtraStorePackages.Count > 0; + [UsedImplicitly] public bool ShouldSerializeFarmingBlacklistAppIDs() => FarmingBlacklistAppIDs.Count > 0; @@ -221,6 +248,7 @@ public sealed class BotDatabase : GenericDatabase { protected override void Dispose(bool disposing) { if (disposing) { // Events we registered + ExtraStorePackages.OnModified -= OnObjectModified; FarmingBlacklistAppIDs.OnModified -= OnObjectModified; FarmingPriorityQueueAppIDs.OnModified -= OnObjectModified; FarmingRiskyIgnoredAppIDs.OnModified -= OnObjectModified;