* 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
This commit is contained in:
Łukasz Domeradzki 2023-11-29 00:08:16 +01:00 committed by GitHub
parent 8cf2d1bc94
commit 36ae066c65
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 771 additions and 156 deletions

View file

@ -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<BasicResponse?> AnnounceForListing(ulong steamID, WebBrowser webBrowser, IReadOnlyList<AssetForListing> inventory, IReadOnlyCollection<Asset.EType> acceptedMatchableTypes, uint totalInventoryCount, bool matchEverything, string tradeToken, string? nickname = null, string? avatarHash = null) {
internal static async Task<BasicResponse?> AnnounceDiffForListing(WebBrowser webBrowser, ulong steamID, ICollection<AssetForListing> inventory, string inventoryChecksum, IReadOnlyCollection<Asset.EType> acceptedMatchableTypes, uint totalInventoryCount, bool matchEverything, string tradeToken, ICollection<AssetForListing> 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<BasicResponse?> AnnounceForListing(WebBrowser webBrowser, ulong steamID, ICollection<AssetForListing> inventory, string inventoryChecksum, IReadOnlyCollection<Asset.EType> 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<AssetForListing> 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<ListedUser> Users)?> GetListedUsersForMatching(Guid licenseID, Bot bot, WebBrowser webBrowser, IReadOnlyCollection<Asset> inventory, IReadOnlyCollection<Asset.EType> acceptedMatchableTypes) {
ArgumentOutOfRangeException.ThrowIfEqual(licenseID, Guid.Empty);
ArgumentNullException.ThrowIfNull(bot);
@ -97,6 +144,28 @@ internal static class Backend {
return (response.StatusCode, response.Content?.Result ?? ImmutableHashSet<ListedUser>.Empty);
}
internal static async Task<ObjectResponse<GenericResponse<ImmutableHashSet<SetPart>>>?> GetSetParts(WebBrowser webBrowser, ulong steamID, ICollection<Asset.EType> matchableTypes, ICollection<uint> 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<GenericResponse<ImmutableHashSet<SetPart>>, 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<BasicResponse?> HeartBeatForListing(Bot bot, WebBrowser webBrowser) {
ArgumentNullException.ThrowIfNull(bot);
ArgumentNullException.ThrowIfNull(webBrowser);

View file

@ -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<AssetForListing> 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<BotCache> 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<BotCache>(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);
}
}

View file

@ -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<AssetForListing> InventoryRemoved;
[JsonProperty(Required = Required.Always)]
private readonly string PreviousInventoryChecksum;
internal AnnouncementDiffRequest(Guid guid, ulong steamID, ICollection<AssetForListing> inventory, string inventoryChecksum, IReadOnlyCollection<Asset.EType> matchableTypes, uint totalInventoryCount, bool matchEverything, byte maxTradeHoldDuration, string tradeToken, ICollection<AssetForListing> 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;
}
}

View file

@ -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<AssetForListing> Inventory;
private readonly ImmutableHashSet<AssetForListing> Inventory;
[JsonProperty(Required = Required.Always)]
private readonly string InventoryChecksum;
[JsonProperty(Required = Required.Always)]
private readonly ImmutableHashSet<Asset.EType> 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<AssetForListing> inventory, IReadOnlyCollection<Asset.EType> matchableTypes, uint totalInventoryCount, bool matchEverything, byte maxTradeHoldDuration, string? nickname = null, string? avatarHash = null) {
internal AnnouncementRequest(Guid guid, ulong steamID, ICollection<AssetForListing> inventory, string inventoryChecksum, IReadOnlyCollection<Asset.EType> 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;

View file

@ -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() { }
}

View file

@ -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() { }

View file

@ -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);
}

View file

@ -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

View file

@ -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<Asset.EType> MatchableTypes;
[JsonProperty(Required = Required.Always)]
internal readonly ImmutableHashSet<uint> RealAppIDs;
[JsonProperty(Required = Required.Always)]
internal readonly ulong SteamID;
internal SetPartsRequest(Guid guid, ulong steamID, ICollection<Asset.EType> matchableTypes, ICollection<uint> 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();
}
}

View file

@ -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<Asset.EType> 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<ulong, uint> 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<AssetForListing> 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<uint> realAppIDs = new();
Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary<ulong, uint>> 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<ulong, uint>? set)) {
set[asset.ClassID] = set.TryGetValue(asset.ClassID, out uint amount) ? amount + asset.Amount : asset.Amount;
} else {
state[key] = new Dictionary<ulong, uint> { { 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<GenericResponse<ImmutableHashSet<SetPart>>>? 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<ulong>> 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<ulong, uint> set) in state) {
if (!databaseSets.TryGetValue(key, out HashSet<ulong>? 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<AssetForListing> assetsForListingFiltered = new();
foreach (AssetForListing asset in assetsForListing.Where(asset => state.TryGetValue((asset.RealAppID, asset.Type, asset.Rarity), out Dictionary<ulong, uint>? 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<ulong, uint>? 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<ulong, AssetForListing> previousInventoryState = BotCache.LastAnnouncedAssetsForListing.ToDictionary(static asset => asset.AssetID);
return;
HashSet<AssetForListing> inventoryAdded = new();
HashSet<AssetForListing> 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<AssetForListing>? 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<bool?> 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();
}
}
}

View file

@ -29,6 +29,7 @@ using JetBrains.Annotations;
namespace ArchiSteamFarm.Collections;
public sealed class ConcurrentHashSet<T> : IReadOnlyCollection<T>, ISet<T> where T : notnull {
[PublicAPI]
public event EventHandler? OnModified;
public int Count => BackingCollection.Count;

View file

@ -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<T> : IList<T>, IReadOnlyList<T> {
[PublicAPI]
public event EventHandler? OnModified;
public bool IsReadOnly => false;
internal int Count {
@ -53,6 +58,8 @@ internal sealed class ConcurrentList<T> : IList<T>, IReadOnlyList<T> {
using (Lock.WriterLock()) {
BackingCollection[index] = value;
}
OnModified?.Invoke(this, EventArgs.Empty);
}
}
@ -60,12 +67,16 @@ internal sealed class ConcurrentList<T> : IList<T>, IReadOnlyList<T> {
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<T> : IList<T>, IReadOnlyList<T> {
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<T> : IList<T>, IReadOnlyList<T> {
BackingCollection.Clear();
BackingCollection.AddRange(collection);
}
OnModified?.Invoke(this, EventArgs.Empty);
}
}

View file

@ -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);

View file

@ -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) {

View file

@ -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")]

View file

@ -86,7 +86,7 @@ internal static class Program {
IEnumerable<string> 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);
}

View file

@ -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;