mirror of
https://github.com/JustArchiNET/ArchiSteamFarm
synced 2024-11-10 07:04:27 +00:00
* 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:
parent
8cf2d1bc94
commit
36ae066c65
17 changed files with 771 additions and 156 deletions
|
@ -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);
|
||||
|
|
124
ArchiSteamFarm.OfficialPlugins.ItemsMatcher/BotCache.cs
Normal file
124
ArchiSteamFarm.OfficialPlugins.ItemsMatcher/BotCache.cs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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() { }
|
||||
}
|
||||
|
|
|
@ -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() { }
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
54
ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/SetPart.cs
Normal file
54
ArchiSteamFarm.OfficialPlugins.ItemsMatcher/Data/SetPart.cs
Normal 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
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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")]
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue