From 184232995d19391869ec7f31177f85431b88a752 Mon Sep 17 00:00:00 2001 From: Vita Chumakova Date: Sun, 17 Mar 2024 02:57:25 +0400 Subject: [PATCH] Inventory fetching through CM (#3155) * New inventory fetching * use new method everywhere * Store description in the asset, add protobuf body as a backing field for InventoryDescription, add properties to description * parse trade offers as json, stub descriptions, fix build * formatting, misc fixes * fix pragma comments * fix passing tradable property * fix convesion of assets, add compatibility method * fix fetching tradeoffers * use 40k as default count per request * throw an exception instead of silencing the error --- .../Data/AssetInInventory.cs | 2 +- .../RemoteCommunication.cs | 8 +- ArchiSteamFarm.Tests/Bot.cs | 2 +- ArchiSteamFarm.Tests/Trading.cs | 2 +- .../Helpers/Json/BooleanNumberConverter.cs | 44 +++ ArchiSteamFarm/Steam/Bot.cs | 4 +- .../Steam/Data/APIWrappedResponse.cs | 31 ++ ArchiSteamFarm/Steam/Data/Asset.cs | 87 ++--- .../Steam/Data/InventoryDescription.cs | 364 ++++++++++++++++++ .../Steam/Data/InventoryResponse.cs | 206 +--------- ArchiSteamFarm/Steam/Data/ItemAction.cs | 45 +++ ArchiSteamFarm/Steam/Data/ItemDescription.cs | 60 +++ ArchiSteamFarm/Steam/Data/Tag.cs | 22 +- ArchiSteamFarm/Steam/Data/TradeOffer.cs | 39 +- .../Steam/Data/TradeOffersResponse.cs | 43 +++ ArchiSteamFarm/Steam/Exchange/Trading.cs | 2 +- .../Steam/Integration/ArchiHandler.cs | 96 +++++ .../Steam/Integration/ArchiWebHandler.cs | 285 +++----------- ArchiSteamFarm/Steam/Interaction/Actions.cs | 2 +- ArchiSteamFarm/Steam/Interaction/Commands.cs | 2 +- 20 files changed, 835 insertions(+), 511 deletions(-) create mode 100644 ArchiSteamFarm/Helpers/Json/BooleanNumberConverter.cs create mode 100644 ArchiSteamFarm/Steam/Data/APIWrappedResponse.cs create mode 100644 ArchiSteamFarm/Steam/Data/InventoryDescription.cs create mode 100644 ArchiSteamFarm/Steam/Data/ItemAction.cs create mode 100644 ArchiSteamFarm/Steam/Data/ItemDescription.cs create mode 100644 ArchiSteamFarm/Steam/Data/TradeOffersResponse.cs diff --git a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetInInventory.cs b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetInInventory.cs index a0404f51a..514566e34 100644 --- a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetInInventory.cs +++ b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/AssetInInventory.cs @@ -40,5 +40,5 @@ internal class AssetInInventory : AssetForMatching { AssetID = asset.AssetID; } - internal Asset ToAsset() => new(Asset.SteamAppID, Asset.SteamCommunityContextID, ClassID, Amount, tradable: Tradable, assetID: AssetID, realAppID: RealAppID, type: Type, rarity: Rarity); + internal Asset ToAsset() => new(Asset.SteamAppID, Asset.SteamCommunityContextID, ClassID, Amount, new InventoryDescription { ProtobufBody = { tradable = Tradable } }, assetID: AssetID, realAppID: RealAppID, type: Type, rarity: Rarity); } diff --git a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/RemoteCommunication.cs b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/RemoteCommunication.cs index a02fa115f..d972e5d80 100644 --- a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/RemoteCommunication.cs +++ b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/RemoteCommunication.cs @@ -250,7 +250,7 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { List inventory; try { - inventory = await Bot.ArchiWebHandler.GetInventoryAsync().ToListAsync().ConfigureAwait(false); + inventory = await Bot.ArchiHandler.GetMyInventoryAsync().ToListAsync().ConfigureAwait(false); } catch (HttpRequestException e) { // This is actually a network failure, so we'll stop sending heartbeats but not record it as valid check ShouldSendHeartBeats = false; @@ -937,7 +937,7 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { HashSet assetsForMatching; try { - assetsForMatching = await Bot.ArchiWebHandler.GetInventoryAsync().Where(item => item is { AssetID: > 0, Amount: > 0, ClassID: > 0, RealAppID: > 0, Type: > Asset.EType.Unknown, Rarity: > Asset.ERarity.Unknown, IsSteamPointsShopItem: false } && acceptedMatchableTypes.Contains(item.Type) && !Bot.BotDatabase.MatchActivelyBlacklistAppIDs.Contains(item.RealAppID)).ToHashSetAsync().ConfigureAwait(false); + assetsForMatching = await Bot.ArchiHandler.GetMyInventoryAsync().Where(item => item is { AssetID: > 0, Amount: > 0, ClassID: > 0, RealAppID: > 0, Type: > Asset.EType.Unknown, Rarity: > Asset.ERarity.Unknown, IsSteamPointsShopItem: false } && acceptedMatchableTypes.Contains(item.Type) && !Bot.BotDatabase.MatchActivelyBlacklistAppIDs.Contains(item.RealAppID)).ToHashSetAsync().ConfigureAwait(false); } catch (HttpRequestException e) { Bot.ArchiLogger.LogGenericWarningException(e); Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(assetsForMatching))); @@ -1504,10 +1504,10 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { // However, since this is only an assumption, we must mark newly acquired items as untradable so we're sure that they're not considered for trading, only for matching foreach (Asset itemToReceive in itemsToReceive) { if (ourInventory.TryGetValue(itemToReceive.AssetID, out Asset? item)) { - item.Tradable = false; + item.Description.ProtobufBody.tradable = false; item.Amount += itemToReceive.Amount; } else { - itemToReceive.Tradable = false; + itemToReceive.Description.ProtobufBody.tradable = false; ourInventory[itemToReceive.AssetID] = itemToReceive; } diff --git a/ArchiSteamFarm.Tests/Bot.cs b/ArchiSteamFarm.Tests/Bot.cs index a3491029a..36aa18a92 100644 --- a/ArchiSteamFarm.Tests/Bot.cs +++ b/ArchiSteamFarm.Tests/Bot.cs @@ -489,7 +489,7 @@ public sealed class Bot { Assert.IsTrue(expectedResult.All(expectation => realResult.TryGetValue(expectation.Key, out long reality) && (expectation.Value == reality))); } - private static Asset CreateCard(ulong classID, uint realAppID, uint amount = 1, Asset.EType type = Asset.EType.TradingCard, Asset.ERarity rarity = Asset.ERarity.Common) => new(Asset.SteamAppID, Asset.SteamCommunityContextID, classID, amount, realAppID: realAppID, type: type, rarity: rarity); + private static Asset CreateCard(ulong classID, uint realAppID, uint amount = 1, Asset.EType type = Asset.EType.TradingCard, Asset.ERarity rarity = Asset.ERarity.Common) => new(Asset.SteamAppID, Asset.SteamCommunityContextID, classID, amount, new InventoryDescription(), realAppID, type, rarity); private static HashSet GetItemsForFullBadge(IReadOnlyCollection inventory, byte cardsPerSet, uint appID, ushort maxItems = Steam.Exchange.Trading.MaxItemsPerTrade) => GetItemsForFullBadge(inventory, new Dictionary { { appID, cardsPerSet } }, maxItems); diff --git a/ArchiSteamFarm.Tests/Trading.cs b/ArchiSteamFarm.Tests/Trading.cs index a27c4d8a5..f622c57e5 100644 --- a/ArchiSteamFarm.Tests/Trading.cs +++ b/ArchiSteamFarm.Tests/Trading.cs @@ -426,5 +426,5 @@ public sealed class Trading { Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); } - private static Asset CreateItem(ulong classID, uint amount = 1, uint realAppID = Asset.SteamAppID, Asset.EType type = Asset.EType.TradingCard, Asset.ERarity rarity = Asset.ERarity.Common) => new(Asset.SteamAppID, Asset.SteamCommunityContextID, classID, amount, realAppID: realAppID, type: type, rarity: rarity); + private static Asset CreateItem(ulong classID, uint amount = 1, uint realAppID = Asset.SteamAppID, Asset.EType type = Asset.EType.TradingCard, Asset.ERarity rarity = Asset.ERarity.Common) => new(Asset.SteamAppID, Asset.SteamCommunityContextID, classID, amount, new InventoryDescription(), realAppID, type, rarity); } diff --git a/ArchiSteamFarm/Helpers/Json/BooleanNumberConverter.cs b/ArchiSteamFarm/Helpers/Json/BooleanNumberConverter.cs new file mode 100644 index 000000000..66ea911d0 --- /dev/null +++ b/ArchiSteamFarm/Helpers/Json/BooleanNumberConverter.cs @@ -0,0 +1,44 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2024 Ł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.Text.Json; +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace ArchiSteamFarm.Helpers.Json; + +[PublicAPI] +public sealed class BooleanNumberConverter : JsonConverter { + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType switch { + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.Number => reader.GetByte() == 1, + _ => throw new JsonException() + }; + + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) { + ArgumentNullException.ThrowIfNull(writer); + + writer.WriteNumberValue(value ? 1 : 0); + } +} diff --git a/ArchiSteamFarm/Steam/Bot.cs b/ArchiSteamFarm/Steam/Bot.cs index 4c92ea1be..b0e0401f6 100644 --- a/ArchiSteamFarm/Steam/Bot.cs +++ b/ArchiSteamFarm/Steam/Bot.cs @@ -3640,8 +3640,8 @@ public sealed class Bot : IAsyncDisposable, IDisposable { HashSet inventory; try { - inventory = await ArchiWebHandler.GetInventoryAsync() - .Where(item => item.Tradable && appIDs.Contains(item.RealAppID) && BotConfig.CompleteTypesToSend.Contains(item.Type)) + inventory = await ArchiHandler.GetMyInventoryAsync(tradableOnly: true) + .Where(item => appIDs.Contains(item.RealAppID) && BotConfig.CompleteTypesToSend.Contains(item.Type)) .ToHashSetAsync() .ConfigureAwait(false); } catch (HttpRequestException e) { diff --git a/ArchiSteamFarm/Steam/Data/APIWrappedResponse.cs b/ArchiSteamFarm/Steam/Data/APIWrappedResponse.cs new file mode 100644 index 000000000..d86b8665c --- /dev/null +++ b/ArchiSteamFarm/Steam/Data/APIWrappedResponse.cs @@ -0,0 +1,31 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2024 Ł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.Text.Json.Serialization; + +namespace ArchiSteamFarm.Steam.Data; + +public class APIWrappedResponse where T : class { + [JsonInclude] + [JsonPropertyName("response")] + [JsonRequired] + public T Response { get; private init; } = null!; +} diff --git a/ArchiSteamFarm/Steam/Data/Asset.cs b/ArchiSteamFarm/Steam/Data/Asset.cs index 8bec38e0a..b494ab3c6 100644 --- a/ArchiSteamFarm/Steam/Data/Asset.cs +++ b/ArchiSteamFarm/Steam/Data/Asset.cs @@ -20,9 +20,7 @@ // limitations under the License. using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Text.Json; using System.Text.Json.Serialization; using JetBrains.Annotations; @@ -41,11 +39,40 @@ public sealed class Asset { [JsonIgnore] [PublicAPI] - public IReadOnlyDictionary? AdditionalPropertiesReadOnly => AdditionalProperties; + public bool IsSteamPointsShopItem => !Tradable && (InstanceID == SteamPointsShopInstanceID); [JsonIgnore] [PublicAPI] - public bool IsSteamPointsShopItem => !Tradable && (InstanceID == SteamPointsShopInstanceID); + public bool Marketable => Description.Marketable; + + [JsonIgnore] + [PublicAPI] + public ERarity Rarity => OverriddenRarity ?? Description.Rarity; + + [JsonIgnore] + [PublicAPI] + public uint RealAppID => OverriddenRealAppID ?? Description.RealAppID; + + [JsonIgnore] + [PublicAPI] + public ImmutableHashSet Tags => Description.Tags; + + [JsonIgnore] + [PublicAPI] + public bool Tradable => Description.Tradable; + + [JsonIgnore] + [PublicAPI] + public EType Type => OverriddenType ?? Description.Type; + + [JsonIgnore] + private ERarity? OverriddenRarity { get; } + + [JsonIgnore] + private uint? OverriddenRealAppID { get; } + + [JsonIgnore] + private EType? OverriddenType { get; } [JsonInclude] [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] @@ -76,40 +103,15 @@ public sealed class Asset { [PublicAPI] public ulong ContextID { get; private init; } + [PublicAPI] + public InventoryDescription Description { get; internal set; } = null!; + [JsonInclude] [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] [JsonPropertyName("instanceid")] [PublicAPI] public ulong InstanceID { get; private init; } - [JsonIgnore] - [PublicAPI] - public bool Marketable { get; internal set; } - - [JsonIgnore] - [PublicAPI] - public ERarity Rarity { get; internal set; } - - [JsonIgnore] - [PublicAPI] - public uint RealAppID { get; internal set; } - - [JsonIgnore] - [PublicAPI] - public ImmutableHashSet? Tags { get; internal set; } - - [JsonIgnore] - [PublicAPI] - public bool Tradable { get; internal set; } - - [JsonIgnore] - [PublicAPI] - public EType Type { get; internal set; } - - [JsonExtensionData] - [JsonInclude] - internal Dictionary? AdditionalProperties { get; set; } - [JsonInclude] [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] [JsonPropertyName("id")] @@ -118,8 +120,15 @@ public sealed class Asset { init => AssetID = value; } - // Constructed from trades being received or plugins - public Asset(uint appID, ulong contextID, ulong classID, uint amount, ulong instanceID = 0, ulong assetID = 0, bool marketable = true, bool tradable = true, ImmutableHashSet? tags = null, uint realAppID = 0, EType type = EType.Unknown, ERarity rarity = ERarity.Unknown) { + internal Asset(uint appID, ulong contextID, ulong classID, uint amount, InventoryDescription description, uint realAppID, EType? type, ERarity? rarity, ulong assetID = 0, ulong instanceID = 0) : this(appID, contextID, classID, amount, description, assetID, instanceID) { + ArgumentOutOfRangeException.ThrowIfZero(realAppID); + + OverriddenRealAppID = realAppID; + OverriddenType = type; + OverriddenRarity = rarity; + } + + internal Asset(uint appID, ulong contextID, ulong classID, uint amount, InventoryDescription description, ulong assetID = 0, ulong instanceID = 0) { ArgumentOutOfRangeException.ThrowIfZero(appID); ArgumentOutOfRangeException.ThrowIfZero(contextID); ArgumentOutOfRangeException.ThrowIfZero(classID); @@ -129,17 +138,9 @@ public sealed class Asset { ContextID = contextID; ClassID = classID; Amount = amount; + Description = description; InstanceID = instanceID; AssetID = assetID; - Marketable = marketable; - Tradable = tradable; - RealAppID = realAppID; - Type = type; - Rarity = rarity; - - if (tags?.Count > 0) { - Tags = tags; - } } [JsonConstructor] diff --git a/ArchiSteamFarm/Steam/Data/InventoryDescription.cs b/ArchiSteamFarm/Steam/Data/InventoryDescription.cs new file mode 100644 index 000000000..75b88845f --- /dev/null +++ b/ArchiSteamFarm/Steam/Data/InventoryDescription.cs @@ -0,0 +1,364 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2024 Ł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 System.Globalization; +using System.Linq; +using System.Text.Json.Serialization; +using ArchiSteamFarm.Core; +using ArchiSteamFarm.Helpers.Json; +using ArchiSteamFarm.Localization; +using JetBrains.Annotations; +using SteamKit2.Internal; + +namespace ArchiSteamFarm.Steam.Data; + +[PublicAPI] +public sealed class InventoryDescription { + [JsonIgnore] + public CEconItem_Description ProtobufBody { get; } = new(); + + internal Asset.ERarity Rarity { + get { + foreach (Tag tag in Tags) { + switch (tag.Identifier) { + case "droprate": + switch (tag.Value) { + case "droprate_0": + return Asset.ERarity.Common; + case "droprate_1": + return Asset.ERarity.Uncommon; + case "droprate_2": + return Asset.ERarity.Rare; + default: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); + + break; + } + + break; + } + } + + return Asset.ERarity.Unknown; + } + } + + internal uint RealAppID { + get { + foreach (Tag tag in Tags) { + switch (tag.Identifier) { + case "Game": + if (string.IsNullOrEmpty(tag.Value) || (tag.Value.Length <= 4) || !tag.Value.StartsWith("app_", StringComparison.Ordinal)) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); + + break; + } + + string appIDText = tag.Value[4..]; + + if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { + ASF.ArchiLogger.LogNullError(appID); + + break; + } + + return appID; + } + } + + return 0; + } + } + + internal Asset.EType Type { + get { + Asset.EType type = Asset.EType.Unknown; + + foreach (Tag tag in Tags) { + switch (tag.Identifier) { + case "cardborder": + switch (tag.Value) { + case "cardborder_0": + return Asset.EType.TradingCard; + case "cardborder_1": + return Asset.EType.FoilTradingCard; + default: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); + + return Asset.EType.Unknown; + } + case "item_class": + switch (tag.Value) { + case "item_class_2": + if (type == Asset.EType.Unknown) { + // This is a fallback in case we'd have no cardborder available to interpret + type = Asset.EType.TradingCard; + } + + continue; + case "item_class_3": + return Asset.EType.ProfileBackground; + case "item_class_4": + return Asset.EType.Emoticon; + case "item_class_5": + return Asset.EType.BoosterPack; + case "item_class_6": + return Asset.EType.Consumable; + case "item_class_7": + return Asset.EType.SteamGems; + case "item_class_8": + return Asset.EType.ProfileModifier; + case "item_class_10": + return Asset.EType.SaleItem; + case "item_class_11": + return Asset.EType.Sticker; + case "item_class_12": + return Asset.EType.ChatEffect; + case "item_class_13": + return Asset.EType.MiniProfileBackground; + case "item_class_14": + return Asset.EType.AvatarProfileFrame; + case "item_class_15": + return Asset.EType.AnimatedAvatar; + case "item_class_16": + return Asset.EType.KeyboardSkin; + case "item_class_17": + return Asset.EType.StartupVideo; + default: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); + + return Asset.EType.Unknown; + } + } + } + + return type; + } + } + + [JsonInclude] + [JsonPropertyName("appid")] + [JsonRequired] + public uint AppID { + get => (uint) ProtobufBody.appid; + private init => ProtobufBody.appid = (int) value; + } + + [JsonInclude] + [JsonPropertyName("background_color")] + public string BackgroundColor { + get => ProtobufBody.background_color; + private init => ProtobufBody.background_color = value; + } + + [JsonInclude] + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + [JsonPropertyName("classid")] + [JsonRequired] + public ulong ClassID { + get => ProtobufBody.classid; + private init => ProtobufBody.classid = value; + } + + [JsonInclude] + [JsonPropertyName("commodity")] + [JsonConverter(typeof(BooleanNumberConverter))] + public bool Commodity { + get => ProtobufBody.commodity; + private init => ProtobufBody.commodity = value; + } + + [JsonInclude] + [JsonPropertyName("currency")] + [JsonConverter(typeof(BooleanNumberConverter))] + public bool Currency { + get => ProtobufBody.currency; + private init => ProtobufBody.currency = value; + } + + [JsonInclude] + [JsonPropertyName("descriptions")] + public ImmutableHashSet Descriptions { + get => ProtobufBody.descriptions.Select(static description => new ItemDescription(description.type, description.value, description.color, description.label)).ToImmutableHashSet(); + private init { + ProtobufBody.descriptions.Clear(); + + foreach (ItemDescription description in value) { + ProtobufBody.descriptions.Add( + new CEconItem_DescriptionLine { + color = description.Color, + label = description.Label, + type = description.Type, + value = description.Value + } + ); + } + } + } + + [JsonInclude] + [JsonPropertyName("icon_url")] +#pragma warning disable CA1056 // this is a JSON/Protobuf field, and even then it doesn't contain full URL + public string IconURL { +#pragma warning restore CA1056 // this is a JSON/Protobuf field, and even then it doesn't contain full URL + get => ProtobufBody.icon_url; + private init => ProtobufBody.icon_url = value; + } + + [JsonInclude] + [JsonPropertyName("icon_url_large")] +#pragma warning disable CA1056 // this is a JSON/Protobuf field, and even then it doesn't contain full URL + public string IconURLLarge { +#pragma warning restore CA1056 // this is a JSON/Protobuf field, and even then it doesn't contain full URL + get => ProtobufBody.icon_url_large; + private init => ProtobufBody.icon_url_large = value; + } + + [JsonInclude] + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + [JsonPropertyName("instanceid")] + public ulong InstanceID { + get => ProtobufBody.instanceid; + private init => ProtobufBody.instanceid = value; + } + + [JsonInclude] + [JsonPropertyName("marketable")] + [JsonRequired] + [JsonConverter(typeof(BooleanNumberConverter))] + public bool Marketable { + get => ProtobufBody.marketable; + private init => ProtobufBody.marketable = value; + } + + [JsonInclude] + [JsonPropertyName("market_fee_app")] + public uint MarketFeeApp { + get => (uint) ProtobufBody.market_fee_app; + private init => ProtobufBody.market_fee_app = (int) value; + } + + [JsonInclude] + [JsonPropertyName("market_hash_name")] + public string MarketHashName { + get => ProtobufBody.market_hash_name; + private init => ProtobufBody.market_hash_name = value; + } + + [JsonInclude] + [JsonPropertyName("market_name")] + public string MarketName { + get => ProtobufBody.market_name; + private init => ProtobufBody.market_name = value; + } + + [JsonInclude] + [JsonPropertyName("name")] + public string Name { + get => ProtobufBody.name; + private init => ProtobufBody.name = value; + } + + [JsonInclude] + [JsonPropertyName("owner_actions")] + public ImmutableHashSet OwnerActions { + get => ProtobufBody.owner_actions.Select(static action => new ItemAction(action.link, action.name)).ToImmutableHashSet(); + private init { + ProtobufBody.owner_actions.Clear(); + + foreach (ItemAction action in value) { + ProtobufBody.owner_actions.Add( + new CEconItem_Action { + link = action.Link, + name = action.Name + } + ); + } + } + } + + [JsonInclude] + [JsonPropertyName("type")] + public string TypeText { + get => ProtobufBody.type; + private init => ProtobufBody.type = value; + } + + [JsonDisallowNull] + [JsonInclude] + [JsonPropertyName("tags")] + internal ImmutableHashSet Tags { + get => ProtobufBody.tags.Select(static x => new Tag(x.category, x.internal_name, x.localized_category_name, x.localized_tag_name)).ToImmutableHashSet(); + private init { + ProtobufBody.tags.Clear(); + + foreach (Tag tag in value) { + ProtobufBody.tags.Add( + new CEconItem_Tag { + appid = AppID, + category = tag.Identifier, + color = tag.Color, + internal_name = tag.Value, + localized_category_name = tag.LocalizedIdentifier, + localized_tag_name = tag.LocalizedValue + } + ); + } + } + } + + [JsonInclude] + [JsonPropertyName("tradable")] + [JsonRequired] + [JsonConverter(typeof(BooleanNumberConverter))] + internal bool Tradable { + get => ProtobufBody.tradable; + private init => ProtobufBody.tradable = value; + } + + // For stubs and deserialization + [JsonConstructor] + internal InventoryDescription() { } + + // Constructed from trades being received/sent + internal InventoryDescription(uint appID, ulong classID, ulong instanceID, bool marketable, bool tradable, IReadOnlyCollection? tags = null) { + ArgumentOutOfRangeException.ThrowIfZero(appID); + ArgumentOutOfRangeException.ThrowIfZero(classID); + + AppID = appID; + ClassID = classID; + InstanceID = instanceID; + Marketable = marketable; + Tradable = tradable; + + if (tags?.Count > 0) { + Tags = tags.ToImmutableHashSet(); + } + } + + internal InventoryDescription(CEconItem_Description description) => ProtobufBody = description; + + [UsedImplicitly] + public static bool ShouldSerializeAdditionalProperties() => false; +} diff --git a/ArchiSteamFarm/Steam/Data/InventoryResponse.cs b/ArchiSteamFarm/Steam/Data/InventoryResponse.cs index b4bfa9fc4..46b20bbe7 100644 --- a/ArchiSteamFarm/Steam/Data/InventoryResponse.cs +++ b/ArchiSteamFarm/Steam/Data/InventoryResponse.cs @@ -20,17 +20,11 @@ // limitations under the License. using System; -using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Text.Json; using System.Text.Json.Serialization; -using ArchiSteamFarm.Core; using ArchiSteamFarm.Helpers.Json; -using ArchiSteamFarm.Localization; using ArchiSteamFarm.Steam.Integration; -using JetBrains.Annotations; using SteamKit2; namespace ArchiSteamFarm.Steam.Data; @@ -45,7 +39,7 @@ internal sealed class InventoryResponse : OptionalResultResponse { [JsonDisallowNull] [JsonInclude] [JsonPropertyName("descriptions")] - internal ImmutableHashSet Descriptions { get; private init; } = ImmutableHashSet.Empty; + internal ImmutableHashSet Descriptions { get; private init; } = ImmutableHashSet.Empty; internal EResult? ErrorCode { get; private init; } internal string? ErrorText { get; private init; } @@ -55,6 +49,9 @@ internal sealed class InventoryResponse : OptionalResultResponse { [JsonPropertyName("last_assetid")] internal ulong LastAssetID { get; private init; } + [JsonInclude] + [JsonPropertyName("more_items")] + [JsonConverter(typeof(BooleanNumberConverter))] internal bool MoreItems { get; private init; } [JsonInclude] @@ -75,201 +72,6 @@ internal sealed class InventoryResponse : OptionalResultResponse { } } - [JsonInclude] - [JsonPropertyName("more_items")] - private byte MoreItemsNumber { - get => MoreItems ? (byte) 1 : (byte) 0; - init => MoreItems = value > 0; - } - [JsonConstructor] private InventoryResponse() { } - - internal sealed class Description { - internal Asset.ERarity Rarity { - get { - foreach (Tag tag in Tags) { - switch (tag.Identifier) { - case "droprate": - switch (tag.Value) { - case "droprate_0": - return Asset.ERarity.Common; - case "droprate_1": - return Asset.ERarity.Uncommon; - case "droprate_2": - return Asset.ERarity.Rare; - default: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); - - break; - } - - break; - } - } - - return Asset.ERarity.Unknown; - } - } - - internal uint RealAppID { - get { - foreach (Tag tag in Tags) { - switch (tag.Identifier) { - case "Game": - if (string.IsNullOrEmpty(tag.Value) || (tag.Value.Length <= 4) || !tag.Value.StartsWith("app_", StringComparison.Ordinal)) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); - - break; - } - - string appIDText = tag.Value[4..]; - - if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { - ASF.ArchiLogger.LogNullError(appID); - - break; - } - - return appID; - } - } - - return 0; - } - } - - internal Asset.EType Type { - get { - Asset.EType type = Asset.EType.Unknown; - - foreach (Tag tag in Tags) { - switch (tag.Identifier) { - case "cardborder": - switch (tag.Value) { - case "cardborder_0": - return Asset.EType.TradingCard; - case "cardborder_1": - return Asset.EType.FoilTradingCard; - default: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); - - return Asset.EType.Unknown; - } - case "item_class": - switch (tag.Value) { - case "item_class_2": - if (type == Asset.EType.Unknown) { - // This is a fallback in case we'd have no cardborder available to interpret - type = Asset.EType.TradingCard; - } - - continue; - case "item_class_3": - return Asset.EType.ProfileBackground; - case "item_class_4": - return Asset.EType.Emoticon; - case "item_class_5": - return Asset.EType.BoosterPack; - case "item_class_6": - return Asset.EType.Consumable; - case "item_class_7": - return Asset.EType.SteamGems; - case "item_class_8": - return Asset.EType.ProfileModifier; - case "item_class_10": - return Asset.EType.SaleItem; - case "item_class_11": - return Asset.EType.Sticker; - case "item_class_12": - return Asset.EType.ChatEffect; - case "item_class_13": - return Asset.EType.MiniProfileBackground; - case "item_class_14": - return Asset.EType.AvatarProfileFrame; - case "item_class_15": - return Asset.EType.AnimatedAvatar; - case "item_class_16": - return Asset.EType.KeyboardSkin; - case "item_class_17": - return Asset.EType.StartupVideo; - default: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); - - return Asset.EType.Unknown; - } - } - } - - return type; - } - } - - [JsonExtensionData] - [JsonInclude] - internal Dictionary? AdditionalProperties { get; private init; } - - [JsonInclude] - [JsonPropertyName("appid")] - [JsonRequired] - internal uint AppID { get; private init; } - - [JsonInclude] - [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] - [JsonPropertyName("classid")] - [JsonRequired] - internal ulong ClassID { get; private init; } - - [JsonInclude] - [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] - [JsonPropertyName("instanceid")] - internal ulong InstanceID { get; private init; } - - internal bool Marketable { get; private init; } - - [JsonDisallowNull] - [JsonInclude] - [JsonPropertyName("tags")] - internal ImmutableHashSet Tags { get; private init; } = ImmutableHashSet.Empty; - - internal bool Tradable { get; private init; } - - [JsonInclude] - [JsonPropertyName("marketable")] - [JsonRequired] - private byte MarketableNumber { - get => Marketable ? (byte) 1 : (byte) 0; - init => Marketable = value > 0; - } - - [JsonInclude] - [JsonPropertyName("tradable")] - [JsonRequired] - private byte TradableNumber { - get => Tradable ? (byte) 1 : (byte) 0; - init => Tradable = value > 0; - } - - // Constructed from trades being received/sent - internal Description(uint appID, ulong classID, ulong instanceID, bool marketable, IReadOnlyCollection? tags = null) { - ArgumentOutOfRangeException.ThrowIfZero(appID); - ArgumentOutOfRangeException.ThrowIfZero(classID); - - AppID = appID; - ClassID = classID; - InstanceID = instanceID; - Marketable = marketable; - Tradable = true; - - if (tags?.Count > 0) { - Tags = tags.ToImmutableHashSet(); - } - } - - [JsonConstructor] - private Description() { } - - [UsedImplicitly] - public static bool ShouldSerializeAdditionalProperties() => false; - } } diff --git a/ArchiSteamFarm/Steam/Data/ItemAction.cs b/ArchiSteamFarm/Steam/Data/ItemAction.cs new file mode 100644 index 000000000..b5d4580c4 --- /dev/null +++ b/ArchiSteamFarm/Steam/Data/ItemAction.cs @@ -0,0 +1,45 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2024 Ł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.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace ArchiSteamFarm.Steam.Data; + +public class ItemAction { + [JsonInclude] + [JsonPropertyName("link")] + [PublicAPI] + public string Link { get; private init; } = null!; + + [JsonInclude] + [JsonPropertyName("name")] + [PublicAPI] + public string Name { get; private init; } = null!; + + internal ItemAction(string link, string name) { + Link = link; + Name = name; + } + + [JsonConstructor] + private ItemAction() { } +} diff --git a/ArchiSteamFarm/Steam/Data/ItemDescription.cs b/ArchiSteamFarm/Steam/Data/ItemDescription.cs new file mode 100644 index 000000000..3e245670b --- /dev/null +++ b/ArchiSteamFarm/Steam/Data/ItemDescription.cs @@ -0,0 +1,60 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2024 Ł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.Text.Json.Serialization; +using ArchiSteamFarm.Helpers.Json; +using JetBrains.Annotations; + +namespace ArchiSteamFarm.Steam.Data; + +public class ItemDescription { + [JsonInclude] + [JsonPropertyName("color")] + [PublicAPI] + public string? Color { get; private init; } + + [JsonInclude] + [JsonPropertyName("label")] + [PublicAPI] + public string? Label { get; private init; } + + [JsonDisallowNull] + [JsonInclude] + [JsonPropertyName("type")] + [PublicAPI] + public string Type { get; private init; } = null!; + + [JsonDisallowNull] + [JsonInclude] + [JsonPropertyName("value")] + [PublicAPI] + public string Value { get; private init; } = null!; + + internal ItemDescription(string type, string value, string color, string label) { + Type = type; + Value = value; + Color = color; + Label = label; + } + + [JsonConstructor] + private ItemDescription() { } +} diff --git a/ArchiSteamFarm/Steam/Data/Tag.cs b/ArchiSteamFarm/Steam/Data/Tag.cs index aca6f524b..b553c4bfc 100644 --- a/ArchiSteamFarm/Steam/Data/Tag.cs +++ b/ArchiSteamFarm/Steam/Data/Tag.cs @@ -32,18 +32,38 @@ public sealed class Tag { [PublicAPI] public string Identifier { get; private init; } = ""; + [JsonInclude] + [JsonPropertyName("localized_category_name")] + [JsonRequired] + [PublicAPI] + public string LocalizedIdentifier { get; private init; } = ""; + + [JsonInclude] + [JsonPropertyName("localized_tag_name")] + [JsonRequired] + [PublicAPI] + public string LocalizedValue { get; private init; } = ""; + [JsonInclude] [JsonPropertyName("internal_name")] [JsonRequired] [PublicAPI] public string Value { get; private init; } = ""; - internal Tag(string identifier, string value) { + [JsonInclude] + [JsonPropertyName("color")] + [PublicAPI] + public string? Color { get; private init; } + + internal Tag(string identifier, string value, string localizedIdentifier, string localizedValue, string? color = null) { ArgumentException.ThrowIfNullOrEmpty(identifier); ArgumentNullException.ThrowIfNull(value); Identifier = identifier; Value = value; + LocalizedIdentifier = localizedIdentifier; + LocalizedValue = localizedValue; + Color = color; } [JsonConstructor] diff --git a/ArchiSteamFarm/Steam/Data/TradeOffer.cs b/ArchiSteamFarm/Steam/Data/TradeOffer.cs index 7570dd05b..196497276 100644 --- a/ArchiSteamFarm/Steam/Data/TradeOffer.cs +++ b/ArchiSteamFarm/Steam/Data/TradeOffer.cs @@ -21,14 +21,17 @@ using System; using System.Collections.Generic; -using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Text.Json.Serialization; +using ArchiSteamFarm.Helpers.Json; using JetBrains.Annotations; using SteamKit2; namespace ArchiSteamFarm.Steam.Data; // REF: https://developer.valvesoftware.com/wiki/Steam_Web_API/IEconService#CEcon_TradeOffer +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] public sealed class TradeOffer { [PublicAPI] public IReadOnlyCollection ItemsToGiveReadOnly => ItemsToGive; @@ -36,31 +39,37 @@ public sealed class TradeOffer { [PublicAPI] public IReadOnlyCollection ItemsToReceiveReadOnly => ItemsToReceive; - internal readonly HashSet ItemsToGive = []; - internal readonly HashSet ItemsToReceive = []; - [PublicAPI] public ulong OtherSteamID64 { get; private set; } + [JsonInclude] + [JsonPropertyName("trade_offer_state")] [PublicAPI] public ETradeOfferState State { get; private set; } + [JsonInclude] + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + [JsonPropertyName("tradeofferid")] [PublicAPI] public ulong TradeOfferID { get; private set; } - // Constructed from trades being received - internal TradeOffer(ulong tradeOfferID, uint otherSteamID3, ETradeOfferState state) { - ArgumentOutOfRangeException.ThrowIfZero(tradeOfferID); - ArgumentOutOfRangeException.ThrowIfZero(otherSteamID3); + [JsonDisallowNull] + [JsonInclude] + [JsonPropertyName("items_to_give")] + internal HashSet ItemsToGive { get; private init; } = []; - if (!Enum.IsDefined(state)) { - throw new InvalidEnumArgumentException(nameof(state), (int) state, typeof(ETradeOfferState)); - } + [JsonDisallowNull] + [JsonInclude] + [JsonPropertyName("items_to_receive")] + internal HashSet ItemsToReceive { get; private init; } = []; - TradeOfferID = tradeOfferID; - OtherSteamID64 = new SteamID(otherSteamID3, EUniverse.Public, EAccountType.Individual); - State = state; - } + [JsonInclude] + [JsonPropertyName("accountid_other")] + [JsonRequired] + private uint OtherSteamID3 { init => OtherSteamID64 = new SteamID(value, EUniverse.Public, EAccountType.Individual); } + + [JsonConstructor] + private TradeOffer() { } [PublicAPI] public bool IsValidSteamItemsRequest(IReadOnlyCollection acceptedTypes) { diff --git a/ArchiSteamFarm/Steam/Data/TradeOffersResponse.cs b/ArchiSteamFarm/Steam/Data/TradeOffersResponse.cs new file mode 100644 index 000000000..d6d721bf0 --- /dev/null +++ b/ArchiSteamFarm/Steam/Data/TradeOffersResponse.cs @@ -0,0 +1,43 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2024 Ł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.Text.Json.Serialization; +using ArchiSteamFarm.Helpers.Json; + +namespace ArchiSteamFarm.Steam.Data; + +public class TradeOffersResponse { + [JsonDisallowNull] + [JsonInclude] + [JsonPropertyName("descriptions")] + public ImmutableHashSet Descriptions { get; private init; } = []; + + [JsonDisallowNull] + [JsonInclude] + [JsonPropertyName("trade_offers_received")] + public ImmutableHashSet TradeOffersReceived { get; private init; } = []; + + [JsonDisallowNull] + [JsonInclude] + [JsonPropertyName("trade_offers_sent")] + public ImmutableHashSet TradeOffersSent { get; private init; } = []; +} diff --git a/ArchiSteamFarm/Steam/Exchange/Trading.cs b/ArchiSteamFarm/Steam/Exchange/Trading.cs index bbe5e974b..8b1e5398d 100644 --- a/ArchiSteamFarm/Steam/Exchange/Trading.cs +++ b/ArchiSteamFarm/Steam/Exchange/Trading.cs @@ -639,7 +639,7 @@ public sealed class Trading : IDisposable { HashSet inventory; try { - inventory = await Bot.ArchiWebHandler.GetInventoryAsync().Where(item => !item.IsSteamPointsShopItem && wantedSets.Contains((item.RealAppID, item.Type, item.Rarity))).ToHashSetAsync().ConfigureAwait(false); + inventory = await Bot.ArchiHandler.GetMyInventoryAsync().Where(item => !item.IsSteamPointsShopItem && wantedSets.Contains((item.RealAppID, item.Type, item.Rarity))).ToHashSetAsync().ConfigureAwait(false); } catch (HttpRequestException e) { // If we can't check our inventory when not using MatchEverything, this is a temporary failure, try again later Bot.ArchiLogger.LogGenericWarningException(e); diff --git a/ArchiSteamFarm/Steam/Integration/ArchiHandler.cs b/ArchiSteamFarm/Steam/Integration/ArchiHandler.cs index b1efda956..f3e0790bc 100644 --- a/ArchiSteamFarm/Steam/Integration/ArchiHandler.cs +++ b/ArchiSteamFarm/Steam/Integration/ArchiHandler.cs @@ -22,10 +22,13 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using ArchiSteamFarm.Core; +using ArchiSteamFarm.Localization; using ArchiSteamFarm.NLog; +using ArchiSteamFarm.Steam.Data; using ArchiSteamFarm.Steam.Integration.Callbacks; using ArchiSteamFarm.Steam.Integration.CMsgs; using JetBrains.Annotations; @@ -151,6 +154,99 @@ public sealed class ArchiHandler : ClientMsgHandler { return response.Result == EResult.OK ? response.GetDeserializedResponse() : null; } + [PublicAPI] + public async IAsyncEnumerable GetMyInventoryAsync(uint appID = Asset.SteamAppID, ulong contextID = Asset.SteamCommunityContextID, bool tradableOnly = false, bool marketableOnly = false, ushort itemsCountPerRequest = 40000) { + ArgumentOutOfRangeException.ThrowIfZero(appID); + ArgumentOutOfRangeException.ThrowIfZero(contextID); + + if (Client.SteamID == null) { + throw new InvalidOperationException(nameof(Client.SteamID)); + } + + SteamID steamID = Client.SteamID; + + // We need to store asset IDs to make sure we won't get duplicate items + HashSet? assetIDs = null; + ulong startAssetID = 0; + + while (true) { + ulong currentStartAssetID = startAssetID; + + CEcon_GetInventoryItemsWithDescriptions_Request request = new() { + appid = appID, + contextid = contextID, + filters = new CEcon_GetInventoryItemsWithDescriptions_Request.FilterOptions { + tradable_only = tradableOnly, + marketable_only = marketableOnly + }, + get_descriptions = true, + steamid = steamID.ConvertToUInt64(), + start_assetid = currentStartAssetID, + count = itemsCountPerRequest + }; + + SteamUnifiedMessages.ServiceMethodResponse genericResponse = await UnifiedEconService + .SendMessage(x => x.GetInventoryItemsWithDescriptions(request)) + .ToLongRunningTask() + .ConfigureAwait(false); + + CEcon_GetInventoryItemsWithDescriptions_Response response = genericResponse.GetDeserializedResponse(); + + if ((response.total_inventory_count == 0) || (response.assets.Count == 0)) { + // Empty inventory + yield break; + } + + if (response.descriptions.Count == 0) { + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(response.descriptions))); + } + + if (response.total_inventory_count > Array.MaxLength) { + throw new InvalidOperationException(nameof(response.total_inventory_count)); + } + + assetIDs ??= new HashSet((int) response.total_inventory_count); + + if ((response.assets.Count == 0) || (response.descriptions.Count == 0)) { + throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, $"{nameof(response.assets)} || {nameof(response.descriptions)}")); + } + + List convertedDescriptions = response.descriptions.Select(static description => new InventoryDescription(description)).ToList(); + + Dictionary<(ulong ClassID, ulong InstanceID), InventoryDescription> descriptions = new(); + + foreach (InventoryDescription description in convertedDescriptions) { + if (description.ClassID == 0) { + throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(description.ClassID))); + } + + (ulong ClassID, ulong InstanceID) key = (description.ClassID, description.InstanceID); + + descriptions.TryAdd(key, description); + } + + foreach (CEcon_Asset? asset in response.assets) { + if (!descriptions.TryGetValue((asset.classid, asset.instanceid), out InventoryDescription? description) || !assetIDs.Add(asset.assetid)) { + continue; + } + + Asset convertedAsset = new(asset.appid, asset.contextid, asset.classid, (uint) asset.amount, description, asset.assetid, asset.instanceid); + + yield return convertedAsset; + } + + if (!response.more_items) { + yield break; + } + + if (response.last_assetid == 0) { + throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(response.last_assetid))); + } + + startAssetID = response.last_assetid; + } + } + [PublicAPI] public async Task?> GetOwnedGames(ulong steamID) { if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { diff --git a/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs b/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs index 54e966fa7..66203edd4 100644 --- a/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs +++ b/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs @@ -33,6 +33,7 @@ using System.Text; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; +using System.Web; using AngleSharp.Dom; using ArchiSteamFarm.Core; using ArchiSteamFarm.Helpers; @@ -230,30 +231,30 @@ public sealed class ArchiWebHandler : IDisposable { } [PublicAPI] - public async IAsyncEnumerable GetInventoryAsync(ulong steamID = 0, uint appID = Asset.SteamAppID, ulong contextID = Asset.SteamCommunityContextID) { + [Obsolete($"Use ArchiHandler.{nameof(ArchiHandler.GetMyInventoryAsync)} for getting bot's own inventory or ArchiWebHandler.${nameof(GetForeignInventoryAsync)} in other cases instead.")] + public IAsyncEnumerable GetInventoryAsync(ulong steamID = 0, uint appID = Asset.SteamAppID, ulong contextID = Asset.SteamCommunityContextID) { + if ((steamID == 0) || (steamID == Bot.SteamID)) { + return Bot.ArchiHandler.GetMyInventoryAsync(appID, contextID); + } + + return GetForeignInventoryAsync(steamID, appID, contextID); + } + + [PublicAPI] + public async IAsyncEnumerable GetForeignInventoryAsync(ulong steamID, uint appID = Asset.SteamAppID, ulong contextID = Asset.SteamCommunityContextID) { ArgumentOutOfRangeException.ThrowIfZero(appID); ArgumentOutOfRangeException.ThrowIfZero(contextID); - if (ASF.InventorySemaphore == null) { - throw new InvalidOperationException(nameof(ASF.InventorySemaphore)); + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - if (steamID == 0) { - if (!Initialized) { - byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; + if (steamID == Bot.SteamID) { + throw new NotSupportedException(); + } - for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { - await Task.Delay(1000).ConfigureAwait(false); - } - - if (!Initialized) { - throw new HttpRequestException(Strings.WarningFailed); - } - } - - steamID = Bot.SteamID; - } else if (!new SteamID(steamID).IsIndividualAccount) { - throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(steamID))); + if (ASF.InventorySemaphore == null) { + throw new InvalidOperationException(nameof(ASF.InventorySemaphore)); } ulong startAssetID = 0; @@ -343,9 +344,9 @@ public sealed class ArchiWebHandler : IDisposable { throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, $"{nameof(response.Content.Assets)} || {nameof(response.Content.Descriptions)}")); } - Dictionary<(ulong ClassID, ulong InstanceID), InventoryResponse.Description> descriptions = new(); + Dictionary<(ulong ClassID, ulong InstanceID), InventoryDescription> descriptions = new(); - foreach (InventoryResponse.Description description in response.Content.Descriptions) { + foreach (InventoryDescription description in response.Content.Descriptions) { if (description.ClassID == 0) { throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(description.ClassID))); } @@ -356,20 +357,11 @@ public sealed class ArchiWebHandler : IDisposable { } foreach (Asset asset in response.Content.Assets) { - if (!descriptions.TryGetValue((asset.ClassID, asset.InstanceID), out InventoryResponse.Description? description) || !assetIDs.Add(asset.AssetID)) { + if (!descriptions.TryGetValue((asset.ClassID, asset.InstanceID), out InventoryDescription? description) || !assetIDs.Add(asset.AssetID)) { continue; } - asset.Marketable = description.Marketable; - asset.Tradable = description.Tradable; - asset.Tags = description.Tags; - asset.RealAppID = description.RealAppID; - asset.Type = description.Type; - asset.Rarity = description.Rarity; - - if (description.AdditionalProperties != null) { - asset.AdditionalProperties = description.AdditionalProperties; - } + asset.Description = description; yield return asset; } @@ -457,7 +449,7 @@ public sealed class ArchiWebHandler : IDisposable { return null; } - Dictionary arguments = new(StringComparer.Ordinal) { + Dictionary arguments = new(StringComparer.Ordinal) { { "access_token", accessToken } }; @@ -484,30 +476,11 @@ public sealed class ArchiWebHandler : IDisposable { arguments["get_descriptions"] = withDescriptions.Value ? "true" : "false"; } - KeyValue? response = null; + string queryString = string.Join('&', arguments.Select(static argument => $"{argument.Key}={HttpUtility.UrlEncode(argument.Value.ToString())}")); - for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { - if ((i > 0) && (WebLimiterDelay > 0)) { - await Task.Delay(WebLimiterDelay).ConfigureAwait(false); - } + string request = $"/{EconService}/GetTradeOffers/v1/?" + queryString; - using WebAPI.AsyncInterface econService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(EconService); - - econService.Timeout = WebBrowser.Timeout; - - try { - response = await WebLimitRequest( - WebAPI.DefaultBaseAddress, - - // ReSharper disable once AccessToDisposedClosure - async () => await econService.CallAsync(HttpMethod.Get, "GetTradeOffers", args: arguments).ConfigureAwait(false) - ).ConfigureAwait(false); - } catch (TaskCanceledException e) { - Bot.ArchiLogger.LogGenericDebuggingException(e); - } catch (Exception e) { - Bot.ArchiLogger.LogGenericWarningException(e); - } - } + TradeOffersResponse? response = (await WebLimitRequest(WebAPI.DefaultBaseAddress, async () => await WebBrowser.UrlGetToJsonObject>(new Uri(WebAPI.DefaultBaseAddress, request)).ConfigureAwait(false)).ConfigureAwait(false))?.Content?.Response; if (response == null) { Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); @@ -515,130 +488,27 @@ public sealed class ArchiWebHandler : IDisposable { return null; } - Dictionary<(uint AppID, ulong ClassID, ulong InstanceID), InventoryResponse.Description> descriptions = new(); - - foreach (KeyValue description in response["descriptions"].Children) { - uint appID = description["appid"].AsUnsignedInteger(); - - if (appID == 0) { - Bot.ArchiLogger.LogNullError(appID); - - return null; - } - - ulong classID = description["classid"].AsUnsignedLong(); - - if (classID == 0) { - Bot.ArchiLogger.LogNullError(classID); - - return null; - } - - ulong instanceID = description["instanceid"].AsUnsignedLong(); - - (uint AppID, ulong ClassID, ulong InstanceID) key = (appID, classID, instanceID); - - if (descriptions.ContainsKey(key)) { - continue; - } - - bool marketable = description["marketable"].AsBoolean(); - - List tags = description["tags"].Children; - - HashSet? parsedTags = null; - - if (tags.Count > 0) { - parsedTags = new HashSet(tags.Count); - - foreach (KeyValue tag in tags) { - string? identifier = tag["category"].AsString(); - - if (string.IsNullOrEmpty(identifier)) { - Bot.ArchiLogger.LogNullError(identifier); - - return null; - } - - string? value = tag["internal_name"].AsString(); - - // Apparently, name can be empty, but not null - if (value == null) { - Bot.ArchiLogger.LogNullError(value); - - return null; - } - - parsedTags.Add(new Tag(identifier, value)); - } - } - - InventoryResponse.Description parsedDescription = new(appID, classID, instanceID, marketable, parsedTags); - - descriptions[key] = parsedDescription; - } - - IEnumerable trades = Enumerable.Empty(); + IEnumerable trades = Enumerable.Empty(); if (receivedOffers.GetValueOrDefault(true)) { - trades = trades.Concat(response["trade_offers_received"].Children); + trades = trades.Concat(response.TradeOffersReceived); } if (sentOffers.GetValueOrDefault(true)) { - trades = trades.Concat(response["trade_offers_sent"].Children); + trades = trades.Concat(response.TradeOffersSent); } + Dictionary<(uint AppID, ulong ClassID, ulong InstanceID), InventoryDescription> descriptions = response.Descriptions.ToDictionary(static description => (description.AppID, description.ClassID, description.InstanceID), static description => description); + HashSet result = []; - foreach (KeyValue trade in trades) { - ETradeOfferState state = trade["trade_offer_state"].AsEnum(); - - if (!Enum.IsDefined(state)) { - Bot.ArchiLogger.LogNullError(state); - - return null; + foreach (TradeOffer tradeOffer in trades.Where(tradeOffer => !activeOnly.HasValue || ((!activeOnly.Value || (tradeOffer.State == ETradeOfferState.Active)) && (activeOnly.Value || (tradeOffer.State != ETradeOfferState.Active))))) { + if (tradeOffer.ItemsToGive.Count > 0) { + SetDescriptionsToAssets(tradeOffer.ItemsToGive, descriptions); } - if (activeOnly.HasValue && ((activeOnly.Value && (state != ETradeOfferState.Active)) || (!activeOnly.Value && (state == ETradeOfferState.Active)))) { - continue; - } - - ulong tradeOfferID = trade["tradeofferid"].AsUnsignedLong(); - - if (tradeOfferID == 0) { - Bot.ArchiLogger.LogNullError(tradeOfferID); - - return null; - } - - uint otherSteamID3 = trade["accountid_other"].AsUnsignedInteger(); - - if (otherSteamID3 == 0) { - Bot.ArchiLogger.LogNullError(otherSteamID3); - - return null; - } - - TradeOffer tradeOffer = new(tradeOfferID, otherSteamID3, state); - - List itemsToGive = trade["items_to_give"].Children; - - if (itemsToGive.Count > 0) { - if (!ParseItems(descriptions, itemsToGive, tradeOffer.ItemsToGive)) { - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(itemsToGive))); - - return null; - } - } - - List itemsToReceive = trade["items_to_receive"].Children; - - if (itemsToReceive.Count > 0) { - if (!ParseItems(descriptions, itemsToReceive, tradeOffer.ItemsToReceive)) { - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(itemsToReceive))); - - return null; - } + if (tradeOffer.ItemsToReceive.Count > 0) { + SetDescriptionsToAssets(tradeOffer.ItemsToReceive, descriptions); } result.Add(tradeOffer); @@ -2389,77 +2259,6 @@ public sealed class ArchiWebHandler : IDisposable { return uri.AbsolutePath.StartsWith("/login", StringComparison.OrdinalIgnoreCase) || uri.Host.Equals("lostauth", StringComparison.OrdinalIgnoreCase); } - private static bool ParseItems([SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")] Dictionary<(uint AppID, ulong ClassID, ulong InstanceID), InventoryResponse.Description> descriptions, IReadOnlyCollection input, ICollection output) { - ArgumentNullException.ThrowIfNull(descriptions); - - if ((input == null) || (input.Count == 0)) { - throw new ArgumentNullException(nameof(input)); - } - - ArgumentNullException.ThrowIfNull(output); - - foreach (KeyValue item in input) { - uint appID = item["appid"].AsUnsignedInteger(); - - if (appID == 0) { - ASF.ArchiLogger.LogNullError(appID); - - return false; - } - - ulong contextID = item["contextid"].AsUnsignedLong(); - - if (contextID == 0) { - ASF.ArchiLogger.LogNullError(contextID); - - return false; - } - - ulong classID = item["classid"].AsUnsignedLong(); - - if (classID == 0) { - ASF.ArchiLogger.LogNullError(classID); - - return false; - } - - ulong instanceID = item["instanceid"].AsUnsignedLong(); - - (uint AppID, ulong ClassID, ulong InstanceID) key = (appID, classID, instanceID); - - uint amount = item["amount"].AsUnsignedInteger(); - - if (amount == 0) { - ASF.ArchiLogger.LogNullError(amount); - - return false; - } - - ulong assetID = item["assetid"].AsUnsignedLong(); - - bool marketable = true; - bool tradable = true; - ImmutableHashSet? tags = null; - uint realAppID = 0; - Asset.EType type = Asset.EType.Unknown; - Asset.ERarity rarity = Asset.ERarity.Unknown; - - if (descriptions.TryGetValue(key, out InventoryResponse.Description? description)) { - marketable = description.Marketable; - tradable = description.Tradable; - tags = description.Tags; - realAppID = description.RealAppID; - type = description.Type; - rarity = description.Rarity; - } - - Asset steamAsset = new(appID, contextID, classID, amount, instanceID, assetID, marketable, tradable, tags, realAppID, type, rarity); - output.Add(steamAsset); - } - - return true; - } - private async Task RefreshSession() { if (!Bot.IsConnectedAndLoggedOn) { return false; @@ -2569,6 +2368,16 @@ public sealed class ArchiWebHandler : IDisposable { return (true, result.ToFrozenSet()); } + private static void SetDescriptionsToAssets(IEnumerable assets, [SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")] Dictionary<(uint AppID, ulong ClassID, ulong InstanceID), InventoryDescription> descriptions) { + foreach (Asset asset in assets) { + if (!descriptions.TryGetValue((asset.AppID, asset.ClassID, asset.InstanceID), out InventoryDescription? description)) { + description = new InventoryDescription(asset.AppID, asset.ClassID, asset.InstanceID, true, true); + } + + asset.Description = description; + } + } + private async Task UnlockParentalAccount(string parentalCode) { ArgumentException.ThrowIfNullOrEmpty(parentalCode); diff --git a/ArchiSteamFarm/Steam/Interaction/Actions.cs b/ArchiSteamFarm/Steam/Interaction/Actions.cs index ee3eb832f..9cc2e388e 100644 --- a/ArchiSteamFarm/Steam/Interaction/Actions.cs +++ b/ArchiSteamFarm/Steam/Interaction/Actions.cs @@ -429,7 +429,7 @@ public sealed class Actions : IAsyncDisposable, IDisposable { TradingScheduled = false; } - inventory = await Bot.ArchiWebHandler.GetInventoryAsync(appID: appID, contextID: contextID).Where(item => item.Tradable && filterFunction(item)).ToHashSetAsync().ConfigureAwait(false); + inventory = await Bot.ArchiHandler.GetMyInventoryAsync(appID, contextID, true).Where(item => filterFunction(item)).ToHashSetAsync().ConfigureAwait(false); } catch (HttpRequestException e) { Bot.ArchiLogger.LogGenericWarningException(e); diff --git a/ArchiSteamFarm/Steam/Interaction/Commands.cs b/ArchiSteamFarm/Steam/Interaction/Commands.cs index 0d9c51bcb..44ebbd31a 100644 --- a/ArchiSteamFarm/Steam/Interaction/Commands.cs +++ b/ArchiSteamFarm/Steam/Interaction/Commands.cs @@ -3107,7 +3107,7 @@ public sealed class Commands { // It'd also make sense to run all of this in parallel, but it seems that Steam has a lot of problems with inventory-related parallel requests | https://steamcommunity.com/groups/archiasf/discussions/1/3559414588264550284/ try { - await foreach (Asset item in Bot.ArchiWebHandler.GetInventoryAsync().Where(static item => item.Type == Asset.EType.BoosterPack).ConfigureAwait(false)) { + await foreach (Asset item in Bot.ArchiHandler.GetMyInventoryAsync().Where(static item => item.Type == Asset.EType.BoosterPack).ConfigureAwait(false)) { if (!await Bot.ArchiWebHandler.UnpackBooster(item.RealAppID, item.AssetID).ConfigureAwait(false)) { completeSuccess = false; }