From 36ae066c657d38e4b2b1cabe8f5ea2af99e54255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Domeradzki?= Date: Wed, 29 Nov 2023 00:08:16 +0100 Subject: [PATCH] Closes #3073 (#3077) * Initial implementation of announce with diff * Add missing logic pieces * Change in logic * Fix checksums * Add deduplication logic * Update SetPart.cs * Use standalone endpoint for diff * Use different hashcode impl * Update AssetForListing.cs * Misc * Push all the changes for this to finally work * Use original index rather than self-calculated ASFB makes some calculations based on index, it's better for us to have holes rather than hiding skipped items. * Handle edge case of no assets after deduplication * Remove dead code * Address trim warnings * Misc optimization --- .../Backend.cs | 85 +++- .../BotCache.cs | 124 +++++ .../Data/AnnouncementDiffRequest.cs | 66 +++ .../Data/AnnouncementRequest.cs | 25 +- .../Data/AssetForListing.cs | 11 +- .../Data/AssetForMatching.cs | 6 +- .../Data/AssetInInventory.cs | 6 +- .../Data/SetPart.cs | 54 +++ .../Data/SetPartsRequest.cs | 64 +++ .../RemoteCommunication.cs | 439 +++++++++++++----- .../Collections/ConcurrentHashSet.cs | 1 + ArchiSteamFarm/Collections/ConcurrentList.cs | 25 +- ArchiSteamFarm/Core/ASF.cs | 3 +- ArchiSteamFarm/Core/Utilities.cs | 12 +- ArchiSteamFarm/IPC/WebUtilities.cs | 2 +- ArchiSteamFarm/Program.cs | 2 +- ArchiSteamFarm/Web/WebBrowser.cs | 2 +- 17 files changed, 771 insertions(+), 156 deletions(-) create mode 100644 ArchiSteamFarm.OfficialPlugins.ItemsMatcher/BotCache.cs create mode 100644 ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AnnouncementDiffRequest.cs create mode 100644 ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/SetPart.cs create mode 100644 ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/SetPartsRequest.cs diff --git a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Backend.cs b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Backend.cs index 895d065dd..ba1208ace 100644 --- a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Backend.cs +++ b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Backend.cs @@ -22,7 +22,10 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; using System.Net; +using System.Text; +using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm.Core; using ArchiSteamFarm.IPC.Responses; @@ -38,16 +41,15 @@ using SteamKit2; namespace ArchiSteamFarm.OfficialPlugins.ItemsMatcher; internal static class Backend { - internal static async Task AnnounceForListing(ulong steamID, WebBrowser webBrowser, IReadOnlyList inventory, IReadOnlyCollection acceptedMatchableTypes, uint totalInventoryCount, bool matchEverything, string tradeToken, string? nickname = null, string? avatarHash = null) { + internal static async Task AnnounceDiffForListing(WebBrowser webBrowser, ulong steamID, ICollection inventory, string inventoryChecksum, IReadOnlyCollection acceptedMatchableTypes, uint totalInventoryCount, bool matchEverything, string tradeToken, ICollection inventoryRemoved, string? previousInventoryChecksum, string? nickname = null, string? avatarHash = null) { + ArgumentNullException.ThrowIfNull(webBrowser); + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { throw new ArgumentOutOfRangeException(nameof(steamID)); } - ArgumentNullException.ThrowIfNull(webBrowser); - - if ((inventory == null) || (inventory.Count == 0)) { - throw new ArgumentNullException(nameof(inventory)); - } + ArgumentNullException.ThrowIfNull(inventory); + ArgumentException.ThrowIfNullOrEmpty(inventoryChecksum); if ((acceptedMatchableTypes == null) || (acceptedMatchableTypes.Count == 0)) { throw new ArgumentNullException(nameof(acceptedMatchableTypes)); @@ -60,13 +62,58 @@ internal static class Backend { throw new ArgumentOutOfRangeException(nameof(tradeToken)); } - Uri request = new(ArchiNet.URL, "/Api/Listing/Announce/v3"); + ArgumentNullException.ThrowIfNull(inventoryRemoved); + ArgumentException.ThrowIfNullOrEmpty(previousInventoryChecksum); - AnnouncementRequest data = new(ASF.GlobalDatabase?.Identifier ?? Guid.NewGuid(), steamID, tradeToken, inventory, acceptedMatchableTypes, totalInventoryCount, matchEverything, ASF.GlobalConfig?.MaxTradeHoldDuration ?? GlobalConfig.DefaultMaxTradeHoldDuration, nickname, avatarHash); + Uri request = new(ArchiNet.URL, "/Api/Listing/AnnounceDiff"); + + AnnouncementDiffRequest data = new(ASF.GlobalDatabase?.Identifier ?? Guid.NewGuid(), steamID, inventory, inventoryChecksum, acceptedMatchableTypes, totalInventoryCount, matchEverything, ASF.GlobalConfig?.MaxTradeHoldDuration ?? GlobalConfig.DefaultMaxTradeHoldDuration, tradeToken, inventoryRemoved, previousInventoryChecksum, nickname, avatarHash); return await webBrowser.UrlPost(request, data: data, requestOptions: WebBrowser.ERequestOptions.ReturnRedirections | WebBrowser.ERequestOptions.ReturnClientErrors | WebBrowser.ERequestOptions.CompressRequest).ConfigureAwait(false); } + internal static async Task AnnounceForListing(WebBrowser webBrowser, ulong steamID, ICollection inventory, string inventoryChecksum, IReadOnlyCollection acceptedMatchableTypes, uint totalInventoryCount, bool matchEverything, string tradeToken, string? nickname = null, string? avatarHash = null) { + ArgumentNullException.ThrowIfNull(webBrowser); + + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if ((inventory == null) || (inventory.Count == 0)) { + throw new ArgumentNullException(nameof(inventory)); + } + + ArgumentException.ThrowIfNullOrEmpty(inventoryChecksum); + + if ((acceptedMatchableTypes == null) || (acceptedMatchableTypes.Count == 0)) { + throw new ArgumentNullException(nameof(acceptedMatchableTypes)); + } + + ArgumentOutOfRangeException.ThrowIfZero(totalInventoryCount); + ArgumentException.ThrowIfNullOrEmpty(tradeToken); + + if (tradeToken.Length != BotConfig.SteamTradeTokenLength) { + throw new ArgumentOutOfRangeException(nameof(tradeToken)); + } + + Uri request = new(ArchiNet.URL, "/Api/Listing/Announce/v4"); + + AnnouncementRequest data = new(ASF.GlobalDatabase?.Identifier ?? Guid.NewGuid(), steamID, inventory, inventoryChecksum, acceptedMatchableTypes, totalInventoryCount, matchEverything, ASF.GlobalConfig?.MaxTradeHoldDuration ?? GlobalConfig.DefaultMaxTradeHoldDuration, tradeToken, nickname, avatarHash); + + return await webBrowser.UrlPost(request, data: data, requestOptions: WebBrowser.ERequestOptions.ReturnRedirections | WebBrowser.ERequestOptions.ReturnClientErrors | WebBrowser.ERequestOptions.CompressRequest).ConfigureAwait(false); + } + + internal static string GenerateChecksumFor(IList assetsForListings) { + if ((assetsForListings == null) || (assetsForListings.Count == 0)) { + throw new ArgumentNullException(nameof(assetsForListings)); + } + + string text = string.Join('|', assetsForListings.Select(static asset => asset.BackendHashCode)); + byte[] bytes = Encoding.UTF8.GetBytes(text); + + return Utilities.GenerateChecksumFor(bytes); + } + internal static async Task<(HttpStatusCode StatusCode, ImmutableHashSet Users)?> GetListedUsersForMatching(Guid licenseID, Bot bot, WebBrowser webBrowser, IReadOnlyCollection inventory, IReadOnlyCollection acceptedMatchableTypes) { ArgumentOutOfRangeException.ThrowIfEqual(licenseID, Guid.Empty); ArgumentNullException.ThrowIfNull(bot); @@ -97,6 +144,28 @@ internal static class Backend { return (response.StatusCode, response.Content?.Result ?? ImmutableHashSet.Empty); } + internal static async Task>>?> GetSetParts(WebBrowser webBrowser, ulong steamID, ICollection matchableTypes, ICollection realAppIDs, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(webBrowser); + + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if ((matchableTypes == null) || (matchableTypes.Count == 0)) { + throw new ArgumentNullException(nameof(matchableTypes)); + } + + if ((realAppIDs == null) || (realAppIDs.Count == 0)) { + throw new ArgumentNullException(nameof(realAppIDs)); + } + + Uri request = new(ArchiNet.URL, "/Api/SetParts/Request"); + + SetPartsRequest data = new(ASF.GlobalDatabase?.Identifier ?? Guid.NewGuid(), steamID, matchableTypes, realAppIDs); + + return await webBrowser.UrlPostToJsonObject>, SetPartsRequest>(request, data: data, requestOptions: WebBrowser.ERequestOptions.ReturnRedirections | WebBrowser.ERequestOptions.ReturnClientErrors | WebBrowser.ERequestOptions.AllowInvalidBodyOnErrors | WebBrowser.ERequestOptions.CompressRequest, cancellationToken: cancellationToken).ConfigureAwait(false); + } + internal static async Task HeartBeatForListing(Bot bot, WebBrowser webBrowser) { ArgumentNullException.ThrowIfNull(bot); ArgumentNullException.ThrowIfNull(webBrowser); diff --git a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/BotCache.cs b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/BotCache.cs new file mode 100644 index 000000000..3f3c948d6 --- /dev/null +++ b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/BotCache.cs @@ -0,0 +1,124 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2023 Ł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; +using System.Globalization; +using System.IO; +using System.Threading.Tasks; +using ArchiSteamFarm.Collections; +using ArchiSteamFarm.Core; +using ArchiSteamFarm.Helpers; +using ArchiSteamFarm.Localization; +using ArchiSteamFarm.OfficialPlugins.ItemsMatcher.Data; +using JetBrains.Annotations; +using Newtonsoft.Json; + +namespace ArchiSteamFarm.OfficialPlugins.ItemsMatcher; + +internal sealed class BotCache : SerializableFile { + [JsonProperty(Required = Required.DisallowNull)] + internal readonly ConcurrentList LastAnnouncedAssetsForListing = new(); + + internal string? LastAnnouncedTradeToken { + get => BackingLastAnnouncedTradeToken; + + set { + if (BackingLastAnnouncedTradeToken == value) { + return; + } + + BackingLastAnnouncedTradeToken = value; + Utilities.InBackground(Save); + } + } + + [JsonProperty] + private string? BackingLastAnnouncedTradeToken; + + private BotCache(string filePath) : this() { + ArgumentException.ThrowIfNullOrEmpty(filePath); + + FilePath = filePath; + } + + [JsonConstructor] + private BotCache() => LastAnnouncedAssetsForListing.OnModified += OnObjectModified; + + [UsedImplicitly] + public bool ShouldSerializeBackingLastAnnouncedTradeToken() => !string.IsNullOrEmpty(BackingLastAnnouncedTradeToken); + + [UsedImplicitly] + public bool ShouldSerializeLastAnnouncedAssetsForListing() => LastAnnouncedAssetsForListing.Count > 0; + + protected override void Dispose(bool disposing) { + if (disposing) { + // Events we registered + LastAnnouncedAssetsForListing.OnModified -= OnObjectModified; + } + + // Base dispose + base.Dispose(disposing); + } + + internal static async Task CreateOrLoad(string filePath) { + ArgumentException.ThrowIfNullOrEmpty(filePath); + + if (!File.Exists(filePath)) { + return new BotCache(filePath); + } + + BotCache? botCache; + + try { + string json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); + + if (string.IsNullOrEmpty(json)) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(json))); + + return new BotCache(filePath); + } + + botCache = JsonConvert.DeserializeObject(json); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return new BotCache(filePath); + } + + if (botCache == null) { + ASF.ArchiLogger.LogNullError(botCache); + + return new BotCache(filePath); + } + + botCache.FilePath = filePath; + + return botCache; + } + + private async void OnObjectModified(object? sender, EventArgs e) { + if (string.IsNullOrEmpty(FilePath)) { + return; + } + + await Save().ConfigureAwait(false); + } +} diff --git a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AnnouncementDiffRequest.cs b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AnnouncementDiffRequest.cs new file mode 100644 index 000000000..064d40a99 --- /dev/null +++ b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AnnouncementDiffRequest.cs @@ -0,0 +1,66 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2023 Ł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; +using System.Collections.Generic; +using System.Collections.Immutable; +using ArchiSteamFarm.Steam.Data; +using ArchiSteamFarm.Steam.Storage; +using Newtonsoft.Json; +using SteamKit2; + +namespace ArchiSteamFarm.OfficialPlugins.ItemsMatcher.Data; + +internal sealed class AnnouncementDiffRequest : AnnouncementRequest { + [JsonProperty(Required = Required.Always)] + private readonly ImmutableHashSet InventoryRemoved; + + [JsonProperty(Required = Required.Always)] + private readonly string PreviousInventoryChecksum; + + internal AnnouncementDiffRequest(Guid guid, ulong steamID, ICollection inventory, string inventoryChecksum, IReadOnlyCollection matchableTypes, uint totalInventoryCount, bool matchEverything, byte maxTradeHoldDuration, string tradeToken, ICollection inventoryRemoved, string previousInventoryChecksum, string? nickname = null, string? avatarHash = null) : base(guid, steamID, inventory, inventoryChecksum, matchableTypes, totalInventoryCount, matchEverything, maxTradeHoldDuration, tradeToken, nickname, avatarHash) { + ArgumentOutOfRangeException.ThrowIfEqual(guid, Guid.Empty); + + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + ArgumentNullException.ThrowIfNull(inventory); + ArgumentException.ThrowIfNullOrEmpty(inventoryChecksum); + + if ((matchableTypes == null) || (matchableTypes.Count == 0)) { + throw new ArgumentNullException(nameof(matchableTypes)); + } + + ArgumentOutOfRangeException.ThrowIfZero(totalInventoryCount); + ArgumentException.ThrowIfNullOrEmpty(tradeToken); + + if (tradeToken.Length != BotConfig.SteamTradeTokenLength) { + throw new ArgumentOutOfRangeException(nameof(tradeToken)); + } + + ArgumentNullException.ThrowIfNull(inventoryRemoved); + ArgumentException.ThrowIfNullOrEmpty(previousInventoryChecksum); + + InventoryRemoved = inventoryRemoved.ToImmutableHashSet(); + PreviousInventoryChecksum = previousInventoryChecksum; + } +} diff --git a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AnnouncementRequest.cs b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AnnouncementRequest.cs index f62733354..828527944 100644 --- a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AnnouncementRequest.cs +++ b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AnnouncementRequest.cs @@ -30,7 +30,7 @@ using SteamKit2; namespace ArchiSteamFarm.OfficialPlugins.ItemsMatcher.Data; -internal sealed class AnnouncementRequest { +internal class AnnouncementRequest { [JsonProperty] private readonly string? AvatarHash; @@ -38,7 +38,10 @@ internal sealed class AnnouncementRequest { private readonly Guid Guid; [JsonProperty(Required = Required.Always)] - private readonly ImmutableList Inventory; + private readonly ImmutableHashSet Inventory; + + [JsonProperty(Required = Required.Always)] + private readonly string InventoryChecksum; [JsonProperty(Required = Required.Always)] private readonly ImmutableHashSet MatchableTypes; @@ -61,33 +64,35 @@ internal sealed class AnnouncementRequest { [JsonProperty(Required = Required.Always)] private readonly string TradeToken; - internal AnnouncementRequest(Guid guid, ulong steamID, string tradeToken, IReadOnlyList inventory, IReadOnlyCollection matchableTypes, uint totalInventoryCount, bool matchEverything, byte maxTradeHoldDuration, string? nickname = null, string? avatarHash = null) { + internal AnnouncementRequest(Guid guid, ulong steamID, ICollection inventory, string inventoryChecksum, IReadOnlyCollection matchableTypes, uint totalInventoryCount, bool matchEverything, byte maxTradeHoldDuration, string tradeToken, string? nickname = null, string? avatarHash = null) { ArgumentOutOfRangeException.ThrowIfEqual(guid, Guid.Empty); if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { throw new ArgumentOutOfRangeException(nameof(steamID)); } - ArgumentException.ThrowIfNullOrEmpty(tradeToken); - - if (tradeToken.Length != BotConfig.SteamTradeTokenLength) { - throw new ArgumentOutOfRangeException(nameof(tradeToken)); - } - if ((inventory == null) || (inventory.Count == 0)) { throw new ArgumentNullException(nameof(inventory)); } + ArgumentException.ThrowIfNullOrEmpty(inventoryChecksum); + if ((matchableTypes == null) || (matchableTypes.Count == 0)) { throw new ArgumentNullException(nameof(matchableTypes)); } ArgumentOutOfRangeException.ThrowIfZero(totalInventoryCount); + ArgumentException.ThrowIfNullOrEmpty(tradeToken); + + if (tradeToken.Length != BotConfig.SteamTradeTokenLength) { + throw new ArgumentOutOfRangeException(nameof(tradeToken)); + } Guid = guid; SteamID = steamID; TradeToken = tradeToken; - Inventory = inventory.ToImmutableList(); + Inventory = inventory.ToImmutableHashSet(); + InventoryChecksum = inventoryChecksum; MatchableTypes = matchableTypes.ToImmutableHashSet(); MatchEverything = matchEverything; MaxTradeHoldDuration = maxTradeHoldDuration; diff --git a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetForListing.cs b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetForListing.cs index 08826ba6d..3e0889bef 100644 --- a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetForListing.cs +++ b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetForListing.cs @@ -26,12 +26,21 @@ using Newtonsoft.Json; namespace ArchiSteamFarm.OfficialPlugins.ItemsMatcher.Data; internal sealed class AssetForListing : AssetInInventory { + [JsonProperty("i", Required = Required.Always)] + internal readonly uint Index; + [JsonProperty("l", Required = Required.Always)] internal readonly ulong PreviousAssetID; - internal AssetForListing(Asset asset, ulong previousAssetID) : base(asset) { + internal string BackendHashCode => Index + "-" + PreviousAssetID + "-" + AssetID + "-" + ClassID + "-" + Rarity + "-" + RealAppID + "-" + Tradable + "-" + Type + "-" + Amount; + + internal AssetForListing(Asset asset, uint index, ulong previousAssetID) : base(asset) { ArgumentNullException.ThrowIfNull(asset); + Index = index; PreviousAssetID = previousAssetID; } + + [JsonConstructor] + private AssetForListing() { } } diff --git a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetForMatching.cs b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetForMatching.cs index 72507b28d..bcfabffaa 100644 --- a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetForMatching.cs +++ b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetForMatching.cs @@ -26,9 +26,6 @@ using Newtonsoft.Json; namespace ArchiSteamFarm.OfficialPlugins.ItemsMatcher.Data; internal class AssetForMatching { - [JsonProperty("a", Required = Required.Always)] - internal readonly uint Amount; - [JsonProperty("c", Required = Required.Always)] internal readonly ulong ClassID; @@ -44,6 +41,9 @@ internal class AssetForMatching { [JsonProperty("p", Required = Required.Always)] internal readonly Asset.EType Type; + [JsonProperty("a", Required = Required.Always)] + internal uint Amount { get; set; } + [JsonConstructor] protected AssetForMatching() { } diff --git a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetInInventory.cs b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetInInventory.cs index 5b0092806..c23af7ebc 100644 --- a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetInInventory.cs +++ b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetInInventory.cs @@ -29,14 +29,14 @@ internal class AssetInInventory : AssetForMatching { [JsonProperty("d", Required = Required.Always)] internal readonly ulong AssetID; + [JsonConstructor] + protected AssetInInventory() { } + internal AssetInInventory(Asset asset) : base(asset) { ArgumentNullException.ThrowIfNull(asset); AssetID = asset.AssetID; } - [JsonConstructor] - private AssetInInventory() { } - internal Asset ToAsset() => new(Asset.SteamAppID, Asset.SteamCommunityContextID, ClassID, Amount, tradable: Tradable, assetID: AssetID, realAppID: RealAppID, type: Type, rarity: Rarity); } diff --git a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/SetPart.cs b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/SetPart.cs new file mode 100644 index 000000000..8a0eb50c7 --- /dev/null +++ b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/SetPart.cs @@ -0,0 +1,54 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2023 Ł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.Diagnostics.CodeAnalysis; +using ArchiSteamFarm.Steam.Data; +using Newtonsoft.Json; + +namespace ArchiSteamFarm.OfficialPlugins.ItemsMatcher.Data; + +#pragma warning disable CA1812 // False positive, the class is used during json deserialization +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +internal sealed class SetPart { +#pragma warning disable CS0649 // False positive, the field is used during json deserialization + [JsonProperty("c", Required = Required.Always)] + internal readonly ulong ClassID; +#pragma warning restore CS0649 // False positive, the field is used during json deserialization + +#pragma warning disable CS0649 // False positive, the field is used during json deserialization + [JsonProperty("r", Required = Required.Always)] + internal readonly Asset.ERarity Rarity; +#pragma warning restore CS0649 // False positive, the field is used during json deserialization + +#pragma warning disable CS0649 // False positive, the field is used during json deserialization + [JsonProperty("e", Required = Required.Always)] + internal readonly uint RealAppID; +#pragma warning restore CS0649 // False positive, the field is used during json deserialization + +#pragma warning disable CS0649 // False positive, the field is used during json deserialization + [JsonProperty("p", Required = Required.Always)] + internal readonly Asset.EType Type; +#pragma warning restore CS0649 // False positive, the field is used during json deserialization + + [JsonConstructor] + private SetPart() { } +} +#pragma warning restore CA1812 // False positive, the class is used during json deserialization diff --git a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/SetPartsRequest.cs b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/SetPartsRequest.cs new file mode 100644 index 000000000..321428326 --- /dev/null +++ b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/SetPartsRequest.cs @@ -0,0 +1,64 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2023 Ł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; +using System.Collections.Generic; +using System.Collections.Immutable; +using ArchiSteamFarm.Steam.Data; +using Newtonsoft.Json; +using SteamKit2; + +namespace ArchiSteamFarm.OfficialPlugins.ItemsMatcher.Data; + +internal sealed class SetPartsRequest { + [JsonProperty(Required = Required.Always)] + internal readonly Guid Guid; + + [JsonProperty(Required = Required.Always)] + internal readonly ImmutableHashSet MatchableTypes; + + [JsonProperty(Required = Required.Always)] + internal readonly ImmutableHashSet RealAppIDs; + + [JsonProperty(Required = Required.Always)] + internal readonly ulong SteamID; + + internal SetPartsRequest(Guid guid, ulong steamID, ICollection matchableTypes, ICollection realAppIDs) { + ArgumentOutOfRangeException.ThrowIfEqual(guid, Guid.Empty); + + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if ((matchableTypes == null) || (matchableTypes.Count == 0)) { + throw new ArgumentNullException(nameof(matchableTypes)); + } + + if ((realAppIDs == null) || (realAppIDs.Count == 0)) { + throw new ArgumentNullException(nameof(realAppIDs)); + } + + Guid = guid; + SteamID = steamID; + MatchableTypes = matchableTypes.ToImmutableHashSet(); + RealAppIDs = realAppIDs.ToImmutableHashSet(); + } +} diff --git a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/RemoteCommunication.cs b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/RemoteCommunication.cs index 7aab6bff1..4ea2040ec 100644 --- a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/RemoteCommunication.cs +++ b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/RemoteCommunication.cs @@ -23,6 +23,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Globalization; +using System.IO; using System.Linq; using System.Net; using System.Net.Http; @@ -30,6 +31,7 @@ using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm.Core; using ArchiSteamFarm.Helpers; +using ArchiSteamFarm.IPC.Responses; using ArchiSteamFarm.Localization; using ArchiSteamFarm.OfficialPlugins.ItemsMatcher.Data; using ArchiSteamFarm.Steam; @@ -54,8 +56,8 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { private const byte MaxTradeOffersActive = 5; // The actual upper limit is 30, but we should use lower amount to allow some bots to react before we hit the maximum allowed private const byte MinAnnouncementTTL = 5; // Minimum amount of minutes we must wait before the next Announcement private const byte MinHeartBeatTTL = 10; // Minimum amount of minutes we must wait before sending next HeartBeat - private const byte MinimumSteamGuardEnabledDays = 15; // As imposed by Steam limits private const byte MinimumPasswordResetCooldownDays = 5; // As imposed by Steam limits + private const byte MinimumSteamGuardEnabledDays = 15; // As imposed by Steam limits private const byte MinPersonaStateTTL = 5; // Minimum amount of minutes we must wait before requesting persona state update private static readonly ImmutableHashSet AcceptedMatchableTypes = ImmutableHashSet.Create( @@ -68,15 +70,14 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { private readonly Bot Bot; private readonly Timer? HeartBeatTimer; - // We access this collection only within a semaphore, therefore there is no need for concurrent access - private readonly Dictionary LastAnnouncedItems = new(); - private readonly SemaphoreSlim MatchActivelySemaphore = new(1, 1); private readonly Timer? MatchActivelyTimer; private readonly SemaphoreSlim RequestsSemaphore = new(1, 1); private readonly WebBrowser WebBrowser; - private string? LastAnnouncedTradeToken; + private string BotCacheFilePath => Path.Combine(SharedInfo.ConfigDirectory, $"{Bot.BotName}.{nameof(ItemsMatcher)}.cache"); + + private BotCache? BotCache; private DateTime LastAnnouncement; private DateTime LastHeartBeat; private DateTime LastPersonaStateRequest; @@ -92,7 +93,7 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { if (Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.SteamTradeMatcher) && Bot.BotConfig.RemoteCommunication.HasFlag(BotConfig.ERemoteCommunication.PublicListing)) { HeartBeatTimer = new Timer( - HeartBeat, + OnHeartBeatTimer, null, TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(ASF.LoadBalancingDelay * Bot.Bots?.Count ?? 0), TimeSpan.FromMinutes(1) @@ -114,12 +115,7 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { } public void Dispose() { - // Those are objects that are always being created if constructor doesn't throw exception - MatchActivelySemaphore.Dispose(); - RequestsSemaphore.Dispose(); - WebBrowser.Dispose(); - - // Those are objects that might be null and the check should be in-place + // Dispose timers first so we won't launch new events HeartBeatTimer?.Dispose(); if (MatchActivelyTimer != null) { @@ -128,6 +124,25 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { MatchActivelyTimer.Dispose(); } } + + // Ensure the semaphores are closed, then dispose the rest + try { + MatchActivelySemaphore.Wait(); + } catch (ObjectDisposedException) { + // Ignored, this is fine + } + + try { + RequestsSemaphore.Wait(); + } catch (ObjectDisposedException) { + // Ignored, this is fine + } + + BotCache?.Dispose(); + + MatchActivelySemaphore.Dispose(); + RequestsSemaphore.Dispose(); + WebBrowser.Dispose(); } public async ValueTask DisposeAsync() { @@ -144,8 +159,19 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { } // Ensure the semaphores are closed, then dispose the rest - await MatchActivelySemaphore.WaitAsync().ConfigureAwait(false); - await RequestsSemaphore.WaitAsync().ConfigureAwait(false); + try { + await MatchActivelySemaphore.WaitAsync().ConfigureAwait(false); + } catch (ObjectDisposedException) { + // Ignored, this is fine + } + + try { + await RequestsSemaphore.WaitAsync().ConfigureAwait(false); + } catch (ObjectDisposedException) { + // Ignored, this is fine + } + + BotCache?.Dispose(); MatchActivelySemaphore.Dispose(); RequestsSemaphore.Dispose(); @@ -250,6 +276,7 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { bool matchEverything = Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.MatchEverything); + uint index = 0; ulong previousAssetID = 0; List assetsForListing = new(); @@ -260,7 +287,7 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { if (item is { AssetID: > 0, Amount: > 0, ClassID: > 0, RealAppID: > 0, Type: > Asset.EType.Unknown, Rarity: > Asset.ERarity.Unknown } && acceptedMatchableTypes.Contains(item.Type)) { // Only tradable assets matter for MatchEverything bots if (!matchEverything || item.Tradable) { - assetsForListing.Add(new AssetForListing(item, previousAssetID)); + assetsForListing.Add(new AssetForListing(item, index, previousAssetID)); } // But even for Fair bots, we should track and skip sets where we don't have any item to trade with @@ -277,6 +304,7 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { } } + index++; previousAssetID = item.AssetID; } @@ -301,24 +329,6 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { } } - if (assetsForListing.Count > MaxItemsCount) { - // We're not eligible, record this as a valid check - LastAnnouncement = DateTime.UtcNow; - ShouldSendAnnouncementEarlier = ShouldSendHeartBeats = false; - - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"{nameof(assetsForListing)} > {MaxItemsCount}")); - - return; - } - - if (ShouldSendHeartBeats && (tradeToken == LastAnnouncedTradeToken) && (assetsForListing.Count == LastAnnouncedItems.Count) && assetsForListing.All(item => LastAnnouncedItems.TryGetValue(item.AssetID, out uint amount) && (item.Amount == amount))) { - // There is nothing new to announce, this is fine, skip the request - LastAnnouncement = DateTime.UtcNow; - ShouldSendAnnouncementEarlier = false; - - return; - } - if (!SignedInWithSteam) { HttpStatusCode? signInWithSteam = await ArchiNet.SignInWithSteam(Bot, WebBrowser).ConfigureAwait(false); @@ -340,73 +350,163 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { SignedInWithSteam = true; } - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Localization.Strings.ListingAnnouncing, Bot.SteamID, nickname ?? Bot.SteamID.ToString(CultureInfo.InvariantCulture), assetsForListing.Count)); + BotCache ??= await BotCache.CreateOrLoad(BotCacheFilePath).ConfigureAwait(false); - BasicResponse? response = await Backend.AnnounceForListing(Bot.SteamID, WebBrowser, assetsForListing, acceptedMatchableTypes, (uint) inventory.Count, matchEverything, tradeToken, nickname, avatarHash).ConfigureAwait(false); + if (!matchEverything) { + // We should deduplicate our sets before sending them to the server, for doing that we'll use ASFB set parts data + HashSet realAppIDs = new(); + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> state = new(); - if (response == null) { - // This is actually a network failure, so we'll stop sending heartbeats but not record it as valid check - ShouldSendHeartBeats = false; + foreach (AssetForListing asset in assetsForListing) { + realAppIDs.Add(asset.RealAppID); - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(response))); + (uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (asset.RealAppID, asset.Type, asset.Rarity); - return; - } + if (state.TryGetValue(key, out Dictionary? set)) { + set[asset.ClassID] = set.TryGetValue(asset.ClassID, out uint amount) ? amount + asset.Amount : asset.Amount; + } else { + state[key] = new Dictionary { { asset.ClassID, asset.Amount } }; + } + } - if (response.StatusCode.IsRedirectionCode()) { - ShouldSendHeartBeats = false; - - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.StatusCode)); - - if (response.FinalUri.Host != ArchiWebHandler.SteamCommunityURL.Host) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(response.FinalUri), response.FinalUri)); + ObjectResponse>>? setPartsResponse = await Backend.GetSetParts(WebBrowser, Bot.SteamID, acceptedMatchableTypes, realAppIDs).ConfigureAwait(false); + if (!HandleAnnounceResponse(BotCache, tradeToken, response: setPartsResponse) || (setPartsResponse?.Content?.Result == null)) { return; } - // We've expected the result, not the redirection to the sign in, we need to authenticate again - SignedInWithSteam = false; + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), HashSet> databaseSets = setPartsResponse.Content.Result.GroupBy(static setPart => (setPart.RealAppID, setPart.Type, setPart.Rarity)).ToDictionary(static group => group.Key, static group => group.Select(static setPart => setPart.ClassID).ToHashSet()); + + HashSet<(ulong ClassID, uint Amount)> setCopy = new(); + + foreach (((uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key, Dictionary set) in state) { + if (!databaseSets.TryGetValue(key, out HashSet? databaseSet)) { + // We have no clue about this set, we can't do any optimization + continue; + } + + if ((databaseSet.Count != set.Count) || !databaseSet.SetEquals(set.Keys)) { + // User either has more or less classIDs than we know about, we can't optimize this + continue; + } + + // User has all classIDs we know about, we can deduplicate his items based on lowest count + setCopy.Clear(); + + uint minimumAmount = uint.MaxValue; + + foreach ((ulong classID, uint amount) in set) { + if (amount < minimumAmount) { + minimumAmount = amount; + } + + setCopy.Add((classID, amount)); + } + + foreach ((ulong classID, uint amount) in setCopy) { + if (minimumAmount >= amount) { + set.Remove(classID); + + continue; + } + + set[classID] = amount - minimumAmount; + } + } + + HashSet assetsForListingFiltered = new(); + + foreach (AssetForListing asset in assetsForListing.Where(asset => state.TryGetValue((asset.RealAppID, asset.Type, asset.Rarity), out Dictionary? setState) && setState.TryGetValue(asset.ClassID, out uint targetAmount) && (targetAmount > 0)).OrderByDescending(static asset => asset.Tradable).ThenByDescending(static asset => asset.Index)) { + (uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (asset.RealAppID, asset.Type, asset.Rarity); + + if (!state.TryGetValue(key, out Dictionary? setState) || !setState.TryGetValue(asset.ClassID, out uint targetAmount) || (targetAmount == 0)) { + // We're not interested in this combination + continue; + } + + if (asset.Amount >= targetAmount) { + asset.Amount = targetAmount; + + if (setState.Remove(asset.ClassID) && (setState.Count == 0)) { + state.Remove(key); + } + } else { + setState[asset.ClassID] = targetAmount - asset.Amount; + } + + assetsForListingFiltered.Add(asset); + } + + assetsForListing = assetsForListingFiltered.OrderBy(static asset => asset.Index).ToList(); + + if (assetsForListing.Count == 0) { + // We're not eligible, record this as a valid check + LastAnnouncement = DateTime.UtcNow; + ShouldSendAnnouncementEarlier = ShouldSendHeartBeats = false; + + return; + } + } + + if (assetsForListing.Count > MaxItemsCount) { + // We're not eligible, record this as a valid check + LastAnnouncement = DateTime.UtcNow; + ShouldSendAnnouncementEarlier = ShouldSendHeartBeats = false; + + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"{nameof(assetsForListing)} > {MaxItemsCount}")); return; } - if (response.StatusCode.IsClientErrorCode()) { - // ArchiNet told us that we've sent a bad request, so the process should restart from the beginning at later time - ShouldSendHeartBeats = false; + string checksum = Backend.GenerateChecksumFor(assetsForListing); + string? previousChecksum = BotCache.LastAnnouncedAssetsForListing.Count > 0 ? Backend.GenerateChecksumFor(BotCache.LastAnnouncedAssetsForListing) : null; - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.StatusCode)); + if ((tradeToken == BotCache.LastAnnouncedTradeToken) && (checksum == previousChecksum)) { + // We've determined our state to be the same, we can skip announce entirely and start sending heartbeats exclusively + LastAnnouncement = DateTime.UtcNow; + ShouldSendAnnouncementEarlier = false; + ShouldSendHeartBeats = true; - switch (response.StatusCode) { - case HttpStatusCode.Forbidden: - // ArchiNet told us to stop submitting data for now - LastAnnouncement = DateTime.UtcNow.AddYears(1); + Utilities.InBackground(() => OnHeartBeatTimer()); - return; - case HttpStatusCode.TooManyRequests: - // ArchiNet told us to try again later - LastAnnouncement = DateTime.UtcNow.AddDays(1); + return; + } - return; - default: - // There is something wrong with our payload or the server, we shouldn't retry for at least several hours - LastAnnouncement = DateTime.UtcNow.AddHours(6); + if (BotCache.LastAnnouncedAssetsForListing.Count > 0) { + Dictionary previousInventoryState = BotCache.LastAnnouncedAssetsForListing.ToDictionary(static asset => asset.AssetID); - return; + HashSet inventoryAdded = new(); + HashSet inventoryRemoved = new(); + + foreach (AssetForListing asset in assetsForListing) { + if (previousInventoryState.Remove(asset.AssetID, out AssetForListing? previousAsset) && (asset.BackendHashCode == previousAsset.BackendHashCode)) { + continue; + } + + inventoryAdded.Add(asset); + } + + foreach (AssetForListing asset in previousInventoryState.Values) { + inventoryRemoved.Add(asset); + } + + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Localization.Strings.ListingAnnouncing, Bot.SteamID, nickname ?? Bot.SteamID.ToString(CultureInfo.InvariantCulture), assetsForListing.Count)); + + BasicResponse? diffResponse = await Backend.AnnounceDiffForListing(WebBrowser, Bot.SteamID, inventoryAdded, checksum, acceptedMatchableTypes, (uint) inventory.Count, matchEverything, tradeToken, inventoryRemoved, previousChecksum, nickname, avatarHash).ConfigureAwait(false); + + if (HandleAnnounceResponse(BotCache, tradeToken, previousChecksum, assetsForListing, diffResponse)) { + // Our diff announce has succeeded, we have nothing to do further + Bot.ArchiLogger.LogGenericInfo(Strings.Success); + + return; } } - LastAnnouncement = LastHeartBeat = DateTime.UtcNow; - ShouldSendAnnouncementEarlier = false; - ShouldSendHeartBeats = true; + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Localization.Strings.ListingAnnouncing, Bot.SteamID, nickname ?? Bot.SteamID.ToString(CultureInfo.InvariantCulture), assetsForListing.Count)); - LastAnnouncedTradeToken = tradeToken; - LastAnnouncedItems.Clear(); + BasicResponse? response = await Backend.AnnounceForListing(WebBrowser, Bot.SteamID, assetsForListing, checksum, acceptedMatchableTypes, (uint) inventory.Count, matchEverything, tradeToken, nickname, avatarHash).ConfigureAwait(false); - foreach (AssetForListing item in assetsForListing) { - LastAnnouncedItems[item.AssetID] = item.Amount; - } - - LastAnnouncedItems.TrimExcess(); + HandleAnnounceResponse(BotCache, tradeToken, assetsForListing: assetsForListing, response: response); } finally { RequestsSemaphore.Release(); } @@ -425,62 +525,74 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { } } - private async void HeartBeat(object? state = null) { - if (!Bot.IsConnectedAndLoggedOn || (Bot.HeartBeatFailures > 0)) { - return; + private bool HandleAnnounceResponse(BotCache botCache, string tradeToken, string? previousInventoryChecksum = null, ICollection? assetsForListing = null, BasicResponse? response = null) { + ArgumentNullException.ThrowIfNull(botCache); + ArgumentException.ThrowIfNullOrEmpty(tradeToken); + + if (response == null) { + // This is actually a network failure, so we'll stop sending heartbeats but not record it as valid check + ShouldSendHeartBeats = false; + + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(response))); + + return false; } - // Request persona update if needed - if ((DateTime.UtcNow > LastPersonaStateRequest.AddMinutes(MinPersonaStateTTL)) && (DateTime.UtcNow > LastAnnouncement.AddMinutes(ShouldSendAnnouncementEarlier ? MinAnnouncementTTL : MaxAnnouncementTTL))) { - LastPersonaStateRequest = DateTime.UtcNow; - Bot.RequestPersonaStateUpdate(); - } + if (response.StatusCode.IsRedirectionCode()) { + ShouldSendHeartBeats = false; - if (!ShouldSendHeartBeats || (DateTime.UtcNow < LastHeartBeat.AddMinutes(MinHeartBeatTTL))) { - return; - } + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.StatusCode)); - if (!await RequestsSemaphore.WaitAsync(0).ConfigureAwait(false)) { - return; - } + if (response.FinalUri.Host != ArchiWebHandler.SteamCommunityURL.Host) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(response.FinalUri), response.FinalUri)); - try { - BasicResponse? response = await Backend.HeartBeatForListing(Bot, WebBrowser).ConfigureAwait(false); - - if (response == null) { - // This is actually a network failure, we should keep sending heartbeats for now - return; + return false; } - if (response.StatusCode.IsRedirectionCode()) { - ShouldSendHeartBeats = false; + // We've expected the result, not the redirection to the sign in, we need to authenticate again + SignedInWithSteam = false; - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.StatusCode)); - - if (response.FinalUri.Host != ArchiWebHandler.SteamCommunityURL.Host) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(response.FinalUri), response.FinalUri)); - - return; - } - - // We've expected the result, not the redirection to the sign in, we need to authenticate again - SignedInWithSteam = false; - - return; - } - - if (response.StatusCode.IsClientErrorCode()) { - ShouldSendHeartBeats = false; - - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.StatusCode)); - - return; - } - - LastHeartBeat = DateTime.UtcNow; - } finally { - RequestsSemaphore.Release(); + return false; } + + if (response.StatusCode.IsClientErrorCode()) { + // ArchiNet told us that we've sent a bad request, so the process should restart from the beginning at later time + ShouldSendHeartBeats = false; + + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.StatusCode)); + + switch (response.StatusCode) { + case HttpStatusCode.Conflict when !string.IsNullOrEmpty(previousInventoryChecksum): + // ArchiNet told us to do full announcement instead + return false; + case HttpStatusCode.Forbidden: + // ArchiNet told us to stop submitting data for now + LastAnnouncement = DateTime.UtcNow.AddYears(1); + + return false; + case HttpStatusCode.TooManyRequests: + // ArchiNet told us to try again later + LastAnnouncement = DateTime.UtcNow.AddDays(1); + + return false; + default: + // There is something wrong with our payload or the server, we shouldn't retry for at least several hours + LastAnnouncement = DateTime.UtcNow.AddHours(6); + + return false; + } + } + + if (assetsForListing?.Count > 0) { + LastAnnouncement = LastHeartBeat = DateTime.UtcNow; + ShouldSendAnnouncementEarlier = false; + ShouldSendHeartBeats = true; + + botCache.LastAnnouncedAssetsForListing.ReplaceWith(assetsForListing); + botCache.LastAnnouncedTradeToken = tradeToken; + } + + return true; } private async Task IsEligibleForListing() { @@ -1089,4 +1201,83 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { return matchedSets > 0; } + + private async void OnHeartBeatTimer(object? state = null) { + if (!Bot.IsConnectedAndLoggedOn || (Bot.HeartBeatFailures > 0)) { + return; + } + + // Request persona update if needed + if ((DateTime.UtcNow > LastPersonaStateRequest.AddMinutes(MinPersonaStateTTL)) && (DateTime.UtcNow > LastAnnouncement.AddMinutes(ShouldSendAnnouncementEarlier ? MinAnnouncementTTL : MaxAnnouncementTTL))) { + LastPersonaStateRequest = DateTime.UtcNow; + Bot.RequestPersonaStateUpdate(); + } + + if (!ShouldSendHeartBeats || (DateTime.UtcNow < LastHeartBeat.AddMinutes(MinHeartBeatTTL))) { + return; + } + + if (!await RequestsSemaphore.WaitAsync(0).ConfigureAwait(false)) { + return; + } + + try { + if (!SignedInWithSteam) { + HttpStatusCode? signInWithSteam = await ArchiNet.SignInWithSteam(Bot, WebBrowser).ConfigureAwait(false); + + if (signInWithSteam == null) { + // This is actually a network failure, so we'll stop sending heartbeats but not record it as valid check + ShouldSendHeartBeats = false; + + return; + } + + if (!signInWithSteam.Value.IsSuccessCode()) { + // SignIn procedure failed and it wasn't a network error, hold off with future tries at least for a full day + LastAnnouncement = DateTime.UtcNow.AddDays(1); + ShouldSendHeartBeats = false; + + return; + } + + SignedInWithSteam = true; + } + + BasicResponse? response = await Backend.HeartBeatForListing(Bot, WebBrowser).ConfigureAwait(false); + + if (response == null) { + // This is actually a network failure, we should keep sending heartbeats for now + return; + } + + if (response.StatusCode.IsRedirectionCode()) { + ShouldSendHeartBeats = false; + + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.StatusCode)); + + if (response.FinalUri.Host != ArchiWebHandler.SteamCommunityURL.Host) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(response.FinalUri), response.FinalUri)); + + return; + } + + // We've expected the result, not the redirection to the sign in, we need to authenticate again + SignedInWithSteam = false; + + return; + } + + if (response.StatusCode.IsClientErrorCode()) { + ShouldSendHeartBeats = false; + + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.StatusCode)); + + return; + } + + LastHeartBeat = DateTime.UtcNow; + } finally { + RequestsSemaphore.Release(); + } + } } diff --git a/ArchiSteamFarm/Collections/ConcurrentHashSet.cs b/ArchiSteamFarm/Collections/ConcurrentHashSet.cs index 933925301..44799f417 100644 --- a/ArchiSteamFarm/Collections/ConcurrentHashSet.cs +++ b/ArchiSteamFarm/Collections/ConcurrentHashSet.cs @@ -29,6 +29,7 @@ using JetBrains.Annotations; namespace ArchiSteamFarm.Collections; public sealed class ConcurrentHashSet : IReadOnlyCollection, ISet where T : notnull { + [PublicAPI] public event EventHandler? OnModified; public int Count => BackingCollection.Count; diff --git a/ArchiSteamFarm/Collections/ConcurrentList.cs b/ArchiSteamFarm/Collections/ConcurrentList.cs index 38aed771a..fad186756 100644 --- a/ArchiSteamFarm/Collections/ConcurrentList.cs +++ b/ArchiSteamFarm/Collections/ConcurrentList.cs @@ -19,13 +19,18 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System; using System.Collections; using System.Collections.Generic; +using JetBrains.Annotations; using Nito.AsyncEx; namespace ArchiSteamFarm.Collections; internal sealed class ConcurrentList : IList, IReadOnlyList { + [PublicAPI] + public event EventHandler? OnModified; + public bool IsReadOnly => false; internal int Count { @@ -53,6 +58,8 @@ internal sealed class ConcurrentList : IList, IReadOnlyList { using (Lock.WriterLock()) { BackingCollection[index] = value; } + + OnModified?.Invoke(this, EventArgs.Empty); } } @@ -60,12 +67,16 @@ internal sealed class ConcurrentList : IList, IReadOnlyList { using (Lock.WriterLock()) { BackingCollection.Add(item); } + + OnModified?.Invoke(this, EventArgs.Empty); } public void Clear() { using (Lock.WriterLock()) { BackingCollection.Clear(); } + + OnModified?.Invoke(this, EventArgs.Empty); } public bool Contains(T item) { @@ -92,18 +103,28 @@ internal sealed class ConcurrentList : IList, IReadOnlyList { using (Lock.WriterLock()) { BackingCollection.Insert(index, item); } + + OnModified?.Invoke(this, EventArgs.Empty); } public bool Remove(T item) { using (Lock.WriterLock()) { - return BackingCollection.Remove(item); + if (!BackingCollection.Remove(item)) { + return false; + } } + + OnModified?.Invoke(this, EventArgs.Empty); + + return true; } public void RemoveAt(int index) { using (Lock.WriterLock()) { BackingCollection.RemoveAt(index); } + + OnModified?.Invoke(this, EventArgs.Empty); } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); @@ -113,5 +134,7 @@ internal sealed class ConcurrentList : IList, IReadOnlyList { BackingCollection.Clear(); BackingCollection.AddRange(collection); } + + OnModified?.Invoke(this, EventArgs.Empty); } } diff --git a/ArchiSteamFarm/Core/ASF.cs b/ArchiSteamFarm/Core/ASF.cs index 0613b1203..0d0507411 100644 --- a/ArchiSteamFarm/Core/ASF.cs +++ b/ArchiSteamFarm/Core/ASF.cs @@ -30,7 +30,6 @@ using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; -using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm.Helpers; @@ -323,7 +322,7 @@ public static class ASF { byte[] responseBytes = response.Content as byte[] ?? response.Content.ToArray(); - string checksum = Convert.ToHexString(SHA512.HashData(responseBytes)); + string checksum = Utilities.GenerateChecksumFor(responseBytes); if (!checksum.Equals(remoteChecksum, StringComparison.OrdinalIgnoreCase)) { ArchiLogger.LogGenericError(Strings.ChecksumWrong); diff --git a/ArchiSteamFarm/Core/Utilities.cs b/ArchiSteamFarm/Core/Utilities.cs index 1b0f8b6da..97b9f518c 100644 --- a/ArchiSteamFarm/Core/Utilities.cs +++ b/ArchiSteamFarm/Core/Utilities.cs @@ -29,6 +29,7 @@ using System.IO; using System.Linq; using System.Net; using System.Resources; +using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using AngleSharp.Dom; @@ -51,6 +52,15 @@ public static class Utilities { private static readonly JwtSecurityTokenHandler JwtSecurityTokenHandler = new(); + [PublicAPI] + public static string GenerateChecksumFor(byte[] source) { + ArgumentNullException.ThrowIfNull(source); + + byte[] hash = SHA512.HashData(source); + + return Convert.ToHexString(hash); + } + [PublicAPI] public static string GetArgsAsText(string[] args, byte argsToSkip, string delimiter) { ArgumentNullException.ThrowIfNull(args); @@ -321,7 +331,7 @@ public static class Utilities { } } - return (result.Score < 4, suggestions is { Count: > 0 } ? string.Join(" ", suggestions.Where(static suggestion => suggestion.Length > 0)) : null); + return (result.Score < 4, suggestions is { Count: > 0 } ? string.Join(' ', suggestions.Where(static suggestion => suggestion.Length > 0)) : null); } internal static void WarnAboutIncompleteTranslation(ResourceManager resourceManager) { diff --git a/ArchiSteamFarm/IPC/WebUtilities.cs b/ArchiSteamFarm/IPC/WebUtilities.cs index 87695d92c..44fb5341a 100644 --- a/ArchiSteamFarm/IPC/WebUtilities.cs +++ b/ArchiSteamFarm/IPC/WebUtilities.cs @@ -34,7 +34,7 @@ internal static class WebUtilities { internal static string? GetUnifiedName(this Type type) { ArgumentNullException.ThrowIfNull(type); - return type.GenericTypeArguments.Length == 0 ? type.FullName : $"{type.Namespace}.{type.Name}{string.Join("", type.GenericTypeArguments.Select(static innerType => $"[{innerType.GetUnifiedName()}]"))}"; + return type.GenericTypeArguments.Length == 0 ? type.FullName : $"{type.Namespace}.{type.Name}{string.Join(null, type.GenericTypeArguments.Select(static innerType => $"[{innerType.GetUnifiedName()}]"))}"; } [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2057:TypeGetType", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")] diff --git a/ArchiSteamFarm/Program.cs b/ArchiSteamFarm/Program.cs index af6d71958..b9ae3dd25 100644 --- a/ArchiSteamFarm/Program.cs +++ b/ArchiSteamFarm/Program.cs @@ -86,7 +86,7 @@ internal static class Program { IEnumerable arguments = Environment.GetCommandLineArgs().Skip(executableName.Equals(SharedInfo.AssemblyName, StringComparison.Ordinal) ? 1 : 0); try { - Process.Start(OS.ProcessFileName, string.Join(" ", arguments)); + Process.Start(OS.ProcessFileName, string.Join(' ', arguments)); } catch (Exception e) { ASF.ArchiLogger.LogGenericException(e); } diff --git a/ArchiSteamFarm/Web/WebBrowser.cs b/ArchiSteamFarm/Web/WebBrowser.cs index 7392ede39..f61a45331 100644 --- a/ArchiSteamFarm/Web/WebBrowser.cs +++ b/ArchiSteamFarm/Web/WebBrowser.cs @@ -753,7 +753,7 @@ public sealed class WebBrowser : IDisposable { try { requestMessage.Content = new FormUrlEncodedContent(nameValueCollection); } catch (UriFormatException) { - requestMessage.Content = new StringContent(string.Join("&", nameValueCollection.Select(static kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")), null, "application/x-www-form-urlencoded"); + requestMessage.Content = new StringContent(string.Join('&', nameValueCollection.Select(static kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")), null, "application/x-www-form-urlencoded"); } break;