From 1e6ab11d9fd3437ef41804d585b6df2a302bda72 Mon Sep 17 00:00:00 2001 From: Archi Date: Wed, 10 Nov 2021 21:23:24 +0100 Subject: [PATCH] Use file-scoped namespaces --- .../CatAPI.cs | 62 +- .../CatController.cs | 38 +- .../ExamplePlugin.cs | 268 +- .../PeriodicGCPlugin.cs | 50 +- .../GlobalCache.cs | 430 +- .../GlobalConfigExtension.cs | 18 +- .../RequestData.cs | 76 +- .../ResponseData.cs | 64 +- .../SharedInfo.cs | 24 +- .../SteamTokenDumperConfig.cs | 38 +- .../SteamTokenDumperController.cs | 16 +- .../SteamTokenDumperPlugin.cs | 918 +-- ArchiSteamFarm.Tests/Bot.cs | 848 +-- ArchiSteamFarm.Tests/SteamChatMessage.cs | 386 +- ArchiSteamFarm.Tests/Trading.cs | 724 +- ArchiSteamFarm.Tests/Utilities.cs | 38 +- .../Collections/ConcurrentEnumerator.cs | 38 +- .../Collections/ConcurrentHashSet.cs | 310 +- ArchiSteamFarm/Collections/ConcurrentList.cs | 170 +- .../Collections/FixedSizeConcurrentQueue.cs | 80 +- ArchiSteamFarm/Core/ASF.cs | 1786 ++--- ArchiSteamFarm/Core/AprilFools.cs | 68 +- ArchiSteamFarm/Core/Debugging.cs | 24 +- ArchiSteamFarm/Core/Events.cs | 32 +- ArchiSteamFarm/Core/OS.cs | 490 +- ArchiSteamFarm/Core/Statistics.cs | 1338 ++-- ArchiSteamFarm/Core/Utilities.cs | 646 +- ArchiSteamFarm/Helpers/ArchiCacheable.cs | 124 +- ArchiSteamFarm/Helpers/ArchiCryptoHelper.cs | 510 +- .../Helpers/CrossProcessFileBasedSemaphore.cs | 304 +- .../Helpers/ICrossProcessSemaphore.cs | 14 +- ArchiSteamFarm/Helpers/SemaphoreLock.cs | 12 +- ArchiSteamFarm/Helpers/SerializableFile.cs | 204 +- ArchiSteamFarm/IPC/ArchiKestrel.cs | 230 +- .../IPC/Controllers/Api/ASFController.cs | 260 +- .../IPC/Controllers/Api/ArchiController.cs | 22 +- .../IPC/Controllers/Api/BotController.cs | 748 +- .../IPC/Controllers/Api/CommandController.cs | 102 +- .../IPC/Controllers/Api/GitHubController.cs | 164 +- .../IPC/Controllers/Api/NLogController.cs | 258 +- .../IPC/Controllers/Api/PluginsController.cs | 18 +- .../IPC/Controllers/Api/StorageController.cs | 126 +- .../Controllers/Api/StructureController.cs | 62 +- .../Api/TwoFactorAuthenticationController.cs | 246 +- .../IPC/Controllers/Api/TypeController.cs | 170 +- .../ApiAuthenticationMiddleware.cs | 214 +- .../CustomAttributesSchemaFilter.cs | 42 +- .../IPC/Integration/CustomSwaggerAttribute.cs | 12 +- .../IPC/Integration/EnumSchemaFilter.cs | 144 +- .../IPC/Integration/LocalizationMiddleware.cs | 94 +- .../SwaggerItemsMinMaxAttribute.cs | 54 +- .../SwaggerSteamIdentifierAttribute.cs | 26 +- .../SwaggerValidValuesAttribute.cs | 42 +- .../IPC/Requests/ASFEncryptRequest.cs | 36 +- ArchiSteamFarm/IPC/Requests/ASFHashRequest.cs | 36 +- ArchiSteamFarm/IPC/Requests/ASFRequest.cs | 24 +- .../BotGamesToRedeemInBackgroundRequest.cs | 32 +- .../IPC/Requests/BotInputRequest.cs | 32 +- .../IPC/Requests/BotPauseRequest.cs | 32 +- .../IPC/Requests/BotRedeemRequest.cs | 24 +- .../IPC/Requests/BotRenameRequest.cs | 24 +- ArchiSteamFarm/IPC/Requests/BotRequest.cs | 24 +- ArchiSteamFarm/IPC/Requests/CommandRequest.cs | 32 +- ...actorAuthenticationConfirmationsRequest.cs | 100 +- ArchiSteamFarm/IPC/Responses/ASFResponse.cs | 92 +- .../GamesToRedeemInBackgroundResponse.cs | 32 +- .../IPC/Responses/GenericResponse.cs | 70 +- .../IPC/Responses/GitHubReleaseResponse.cs | 68 +- .../IPC/Responses/StatusCodeResponse.cs | 36 +- .../IPC/Responses/TypeProperties.cs | 62 +- ArchiSteamFarm/IPC/Responses/TypeResponse.cs | 46 +- ArchiSteamFarm/IPC/Startup.cs | 444 +- ArchiSteamFarm/IPC/WebUtilities.cs | 78 +- ArchiSteamFarm/NLog/ArchiLogger.cs | 298 +- ArchiSteamFarm/NLog/Logging.cs | 666 +- ArchiSteamFarm/NLog/Targets/HistoryTarget.cs | 88 +- ArchiSteamFarm/NLog/Targets/SteamTarget.cs | 164 +- ArchiSteamFarm/Plugins/Interfaces/IASF.cs | 18 +- ArchiSteamFarm/Plugins/Interfaces/IBot.cs | 34 +- .../Plugins/Interfaces/IBotCardsFarmerInfo.cs | 40 +- .../Plugins/Interfaces/IBotCommand.cs | 26 +- .../Plugins/Interfaces/IBotConnection.cs | 30 +- .../Plugins/Interfaces/IBotFriendRequest.cs | 22 +- .../Plugins/Interfaces/IBotMessage.cs | 24 +- .../Plugins/Interfaces/IBotModules.cs | 20 +- .../Plugins/Interfaces/IBotSteamClient.cs | 32 +- .../Plugins/Interfaces/IBotTradeOffer.cs | 22 +- .../Interfaces/IBotTradeOfferResults.cs | 20 +- .../Interfaces/IBotUserNotifications.cs | 20 +- .../Plugins/Interfaces/IBotsComparer.cs | 20 +- .../ICrossProcessSemaphoreProvider.cs | 20 +- ArchiSteamFarm/Plugins/Interfaces/IPlugin.cs | 42 +- .../Plugins/Interfaces/ISteamPICSChanges.cs | 42 +- ArchiSteamFarm/Plugins/OfficialPlugin.cs | 14 +- ArchiSteamFarm/Plugins/PluginsCore.cs | 1126 +-- ArchiSteamFarm/Program.cs | 1046 +-- ArchiSteamFarm/SharedInfo.cs | 130 +- ArchiSteamFarm/Steam/Bot.cs | 6364 ++++++++--------- ArchiSteamFarm/Steam/Cards/CardsFarmer.cs | 1898 ++--- ArchiSteamFarm/Steam/Cards/Game.cs | 46 +- .../Steam/Data/AccessTokenResponse.cs | 28 +- ArchiSteamFarm/Steam/Data/Asset.cs | 408 +- ArchiSteamFarm/Steam/Data/BooleanResponse.cs | 18 +- .../Steam/Data/InventoryResponse.cs | 414 +- .../Steam/Data/NewDiscoveryQueueResponse.cs | 16 +- .../Steam/Data/RedeemWalletResponse.cs | 16 +- ArchiSteamFarm/Steam/Data/ResultResponse.cs | 18 +- ArchiSteamFarm/Steam/Data/Tag.cs | 28 +- ArchiSteamFarm/Steam/Data/TradeOffer.cs | 76 +- .../Steam/Data/TradeOfferAcceptResponse.cs | 20 +- .../Steam/Data/TradeOfferSendRequest.cs | 20 +- .../Steam/Data/TradeOfferSendResponse.cs | 50 +- ArchiSteamFarm/Steam/Data/UserPrivacy.cs | 146 +- .../Steam/Exchange/ParseTradeResult.cs | 56 +- ArchiSteamFarm/Steam/Exchange/Trading.cs | 1162 +-- .../Steam/Integration/ArchiHandler.cs | 1582 ++-- .../Steam/Integration/ArchiWebHandler.cs | 5154 ++++++------- .../CMsgs/CMsgClientAcknowledgeClanInvite.cs | 40 +- .../Callbacks/PurchaseResponseCallback.cs | 128 +- .../Callbacks/UserNotificationsCallback.cs | 156 +- .../Steam/Integration/SteamChatMessage.cs | 326 +- .../Steam/Integration/SteamPICSChanges.cs | 140 +- .../Steam/Integration/SteamSaleEvent.cs | 140 +- ArchiSteamFarm/Steam/Interaction/Actions.cs | 802 +-- ArchiSteamFarm/Steam/Interaction/Commands.cs | 6218 ++++++++-------- ArchiSteamFarm/Steam/Security/Confirmation.cs | 54 +- .../Steam/Security/MobileAuthenticator.cs | 680 +- .../SteamKit2/InMemoryServerListProvider.cs | 36 +- .../Steam/SteamKit2/ServerRecordEndPoint.cs | 56 +- ArchiSteamFarm/Steam/Storage/BotConfig.cs | 1152 +-- ArchiSteamFarm/Steam/Storage/BotDatabase.cs | 372 +- ArchiSteamFarm/Storage/GlobalConfig.cs | 946 +-- ArchiSteamFarm/Storage/GlobalDatabase.cs | 516 +- ArchiSteamFarm/Web/GitHub.cs | 474 +- ArchiSteamFarm/Web/Responses/BasicResponse.cs | 34 +- .../Web/Responses/BinaryResponse.cs | 20 +- .../Web/Responses/HtmlDocumentResponse.cs | 46 +- .../Web/Responses/ObjectResponse.cs | 18 +- .../Web/Responses/StreamResponse.cs | 32 +- .../Web/Responses/StringResponse.cs | 20 +- .../Web/Responses/XmlDocumentResponse.cs | 20 +- ArchiSteamFarm/Web/WebBrowser.cs | 1596 ++--- 142 files changed, 25006 insertions(+), 25006 deletions(-) diff --git a/ArchiSteamFarm.CustomPlugins.ExamplePlugin/CatAPI.cs b/ArchiSteamFarm.CustomPlugins.ExamplePlugin/CatAPI.cs index ebedfb75d..a0ac94261 100644 --- a/ArchiSteamFarm.CustomPlugins.ExamplePlugin/CatAPI.cs +++ b/ArchiSteamFarm.CustomPlugins.ExamplePlugin/CatAPI.cs @@ -26,42 +26,42 @@ using ArchiSteamFarm.Web; using ArchiSteamFarm.Web.Responses; using Newtonsoft.Json; -namespace ArchiSteamFarm.CustomPlugins.ExamplePlugin { - // This is example class that shows how you can call third-party services within your plugin - // You've always wanted from your ASF to post cats, right? Now is your chance! - // P.S. The code is almost 1:1 copy from the one I use in ArchiBot, you can thank me later - internal static class CatAPI { - private const string URL = "https://aws.random.cat"; +namespace ArchiSteamFarm.CustomPlugins.ExamplePlugin; - internal static async Task GetRandomCatURL(WebBrowser webBrowser) { - if (webBrowser == null) { - throw new ArgumentNullException(nameof(webBrowser)); - } +// This is example class that shows how you can call third-party services within your plugin +// You've always wanted from your ASF to post cats, right? Now is your chance! +// P.S. The code is almost 1:1 copy from the one I use in ArchiBot, you can thank me later +internal static class CatAPI { + private const string URL = "https://aws.random.cat"; - Uri request = new($"{URL}/meow"); - - ObjectResponse? response = await webBrowser.UrlGetToJsonObject(request).ConfigureAwait(false); - - if (response == null) { - return null; - } - - if (string.IsNullOrEmpty(response.Content.Link)) { - throw new InvalidOperationException(nameof(response.Content.Link)); - } - - return Uri.EscapeDataString(response.Content.Link); + internal static async Task GetRandomCatURL(WebBrowser webBrowser) { + if (webBrowser == null) { + throw new ArgumentNullException(nameof(webBrowser)); } + Uri request = new($"{URL}/meow"); + + ObjectResponse? response = await webBrowser.UrlGetToJsonObject(request).ConfigureAwait(false); + + if (response == null) { + return null; + } + + if (string.IsNullOrEmpty(response.Content.Link)) { + throw new InvalidOperationException(nameof(response.Content.Link)); + } + + return Uri.EscapeDataString(response.Content.Link); + } + #pragma warning disable CA1812 // False positive, the class is used during json deserialization - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - private sealed class MeowResponse { - [JsonProperty(PropertyName = "file", Required = Required.Always)] - internal readonly string Link = ""; + [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] + private sealed class MeowResponse { + [JsonProperty(PropertyName = "file", Required = Required.Always)] + internal readonly string Link = ""; - [JsonConstructor] - private MeowResponse() { } - } -#pragma warning restore CA1812 // False positive, the class is used during json deserialization + [JsonConstructor] + private MeowResponse() { } } +#pragma warning restore CA1812 // False positive, the class is used during json deserialization } diff --git a/ArchiSteamFarm.CustomPlugins.ExamplePlugin/CatController.cs b/ArchiSteamFarm.CustomPlugins.ExamplePlugin/CatController.cs index f4ac0d4a6..b486d6521 100644 --- a/ArchiSteamFarm.CustomPlugins.ExamplePlugin/CatController.cs +++ b/ArchiSteamFarm.CustomPlugins.ExamplePlugin/CatController.cs @@ -27,26 +27,26 @@ using ArchiSteamFarm.IPC.Controllers.Api; using ArchiSteamFarm.IPC.Responses; using Microsoft.AspNetCore.Mvc; -namespace ArchiSteamFarm.CustomPlugins.ExamplePlugin { - // This is an example class which shows you how you can extend ASF's API with your own custom API routes and controllers - // You're free to decide whether you want to integrate with existing ASF concepts (such as ArchiController/GenericResponse), or roll out your own - // All API controllers will be discovered during our Kestrel initialization using attributes mapping, you're also getting usual ASF goodies such as swagger documentation out of the box - [Route("/Api/Cat")] - public sealed class CatController : ArchiController { - /// - /// Fetches URL of a random cat picture. - /// - [HttpGet] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)] - public async Task> CatGet() { - if (ASF.WebBrowser == null) { - throw new InvalidOperationException(nameof(ASF.WebBrowser)); - } +namespace ArchiSteamFarm.CustomPlugins.ExamplePlugin; - string? link = await CatAPI.GetRandomCatURL(ASF.WebBrowser).ConfigureAwait(false); - - return !string.IsNullOrEmpty(link) ? Ok(new GenericResponse(link)) : StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false)); +// This is an example class which shows you how you can extend ASF's API with your own custom API routes and controllers +// You're free to decide whether you want to integrate with existing ASF concepts (such as ArchiController/GenericResponse), or roll out your own +// All API controllers will be discovered during our Kestrel initialization using attributes mapping, you're also getting usual ASF goodies such as swagger documentation out of the box +[Route("/Api/Cat")] +public sealed class CatController : ArchiController { + /// + /// Fetches URL of a random cat picture. + /// + [HttpGet] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)] + public async Task> CatGet() { + if (ASF.WebBrowser == null) { + throw new InvalidOperationException(nameof(ASF.WebBrowser)); } + + string? link = await CatAPI.GetRandomCatURL(ASF.WebBrowser).ConfigureAwait(false); + + return !string.IsNullOrEmpty(link) ? Ok(new GenericResponse(link)) : StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false)); } } diff --git a/ArchiSteamFarm.CustomPlugins.ExamplePlugin/ExamplePlugin.cs b/ArchiSteamFarm.CustomPlugins.ExamplePlugin/ExamplePlugin.cs index b6ece79e6..2a94a8959 100644 --- a/ArchiSteamFarm.CustomPlugins.ExamplePlugin/ExamplePlugin.cs +++ b/ArchiSteamFarm.CustomPlugins.ExamplePlugin/ExamplePlugin.cs @@ -33,149 +33,149 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using SteamKit2; -namespace ArchiSteamFarm.CustomPlugins.ExamplePlugin { - // In order for your plugin to work, it must export generic ASF's IPlugin interface - [Export(typeof(IPlugin))] +namespace ArchiSteamFarm.CustomPlugins.ExamplePlugin; - // Your plugin class should inherit the plugin interfaces it wants to handle - // If you do not want to handle a particular action (e.g. OnBotMessage that is offered in IBotMessage), it's the best idea to not inherit it at all - // This will keep your code compact, efficient and less dependent. You can always add additional interfaces when you'll need them, this example project will inherit quite a bit of them to show you potential usage - internal sealed class ExamplePlugin : IASF, IBot, IBotCommand, IBotConnection, IBotFriendRequest, IBotMessage, IBotModules, IBotTradeOffer { - // This is used for identification purposes, typically you want to use a friendly name of your plugin here, such as the name of your main class - // Please note that this property can have direct dependencies only on structures that were initialized by the constructor, as it's possible to be called before OnLoaded() takes place - public string Name => nameof(ExamplePlugin); +// In order for your plugin to work, it must export generic ASF's IPlugin interface +[Export(typeof(IPlugin))] - // This will be displayed to the user and written in the log file, typically you should point it to the version of your library, but alternatively you can do some more advanced logic if you'd like to - // Please note that this property can have direct dependencies only on structures that were initialized by the constructor, as it's possible to be called before OnLoaded() takes place - public Version Version => typeof(ExamplePlugin).Assembly.GetName().Version ?? throw new InvalidOperationException(nameof(Version)); +// Your plugin class should inherit the plugin interfaces it wants to handle +// If you do not want to handle a particular action (e.g. OnBotMessage that is offered in IBotMessage), it's the best idea to not inherit it at all +// This will keep your code compact, efficient and less dependent. You can always add additional interfaces when you'll need them, this example project will inherit quite a bit of them to show you potential usage +internal sealed class ExamplePlugin : IASF, IBot, IBotCommand, IBotConnection, IBotFriendRequest, IBotMessage, IBotModules, IBotTradeOffer { + // This is used for identification purposes, typically you want to use a friendly name of your plugin here, such as the name of your main class + // Please note that this property can have direct dependencies only on structures that were initialized by the constructor, as it's possible to be called before OnLoaded() takes place + public string Name => nameof(ExamplePlugin); - // Plugins can expose custom properties for our GET /Api/Plugins API call, simply annotate them with [JsonProperty] (or keep public) - [JsonProperty] - public bool CustomIsEnabledField { get; private set; } = true; + // This will be displayed to the user and written in the log file, typically you should point it to the version of your library, but alternatively you can do some more advanced logic if you'd like to + // Please note that this property can have direct dependencies only on structures that were initialized by the constructor, as it's possible to be called before OnLoaded() takes place + public Version Version => typeof(ExamplePlugin).Assembly.GetName().Version ?? throw new InvalidOperationException(nameof(Version)); - // This method, apart from being called before any bot initialization takes place, allows you to read custom global config properties that are not recognized by ASF - // Thanks to that, you can extend default ASF config with your own stuff, then parse it here in order to customize your plugin during runtime - // Keep in mind that, as noted in the interface, additionalConfigProperties can be null if no custom, unrecognized properties are found by ASF, you should handle that case appropriately - // In addition to that, this method also guarantees that all plugins were already OnLoaded(), which allows cross-plugins-communication to be possible - public void OnASFInit(IReadOnlyDictionary? additionalConfigProperties = null) { - if (additionalConfigProperties == null) { - return; - } + // Plugins can expose custom properties for our GET /Api/Plugins API call, simply annotate them with [JsonProperty] (or keep public) + [JsonProperty] + public bool CustomIsEnabledField { get; private set; } = true; - foreach ((string configProperty, JToken configValue) in additionalConfigProperties) { - // It's a good idea to prefix your custom properties with the name of your plugin, so there will be no possible conflict of ASF or other plugins using the same name, neither now or in the future - switch (configProperty) { - case nameof(ExamplePlugin) + "TestProperty" when configValue.Type == JTokenType.Boolean: - bool exampleBooleanValue = configValue.Value(); - ASF.ArchiLogger.LogGenericInfo($"{nameof(ExamplePlugin)}TestProperty boolean property has been found with a value of: {exampleBooleanValue}"); - - break; - } - } + // This method, apart from being called before any bot initialization takes place, allows you to read custom global config properties that are not recognized by ASF + // Thanks to that, you can extend default ASF config with your own stuff, then parse it here in order to customize your plugin during runtime + // Keep in mind that, as noted in the interface, additionalConfigProperties can be null if no custom, unrecognized properties are found by ASF, you should handle that case appropriately + // In addition to that, this method also guarantees that all plugins were already OnLoaded(), which allows cross-plugins-communication to be possible + public void OnASFInit(IReadOnlyDictionary? additionalConfigProperties = null) { + if (additionalConfigProperties == null) { + return; } - // This method is called when unknown command is received (starting with CommandPrefix) - // This allows you to recognize the command yourself and implement custom commands - // Keep in mind that there is no guarantee what is the actual access of steamID, so you should do the appropriate access checking yourself - // You can use either ASF's default functions for that, or implement your own logic as you please - // Since ASF already had to do initial parsing in order to determine that the command is unknown, args[] are splitted using standard ASF delimiters - // If by any chance you want to handle message in its raw format, you also have it available, although for usual ASF pattern you can most likely stick with args[] exclusively. The message has CommandPrefix already stripped for your convenience - // If you do not recognize the command, just return null/empty and allow ASF to gracefully return "unknown command" to user on usual basis - public async Task OnBotCommand(Bot bot, ulong steamID, string message, string[] args) { - // In comparison with OnBotMessage(), we're using asynchronous CatAPI call here, so we declare our method as async and return the message as usual - // Notice how we handle access here as well, it'll work only for FamilySharing+ - switch (args[0].ToUpperInvariant()) { - case "CAT" when bot.HasAccess(steamID, BotConfig.EAccess.FamilySharing): - // Notice how we can decide whether to use bot's AWH WebBrowser or ASF's one. For Steam-related requests, AWH's one should always be used, for third-party requests like those it doesn't really matter - // Still, it makes sense to pass AWH's one, so in case you get some errors or alike, you know from which bot instance they come from. It's similar to using Bot's ArchiLogger compared to ASF's one - string? randomCatURL = await CatAPI.GetRandomCatURL(bot.ArchiWebHandler.WebBrowser).ConfigureAwait(false); + foreach ((string configProperty, JToken configValue) in additionalConfigProperties) { + // It's a good idea to prefix your custom properties with the name of your plugin, so there will be no possible conflict of ASF or other plugins using the same name, neither now or in the future + switch (configProperty) { + case nameof(ExamplePlugin) + "TestProperty" when configValue.Type == JTokenType.Boolean: + bool exampleBooleanValue = configValue.Value(); + ASF.ArchiLogger.LogGenericInfo($"{nameof(ExamplePlugin)}TestProperty boolean property has been found with a value of: {exampleBooleanValue}"); - return !string.IsNullOrEmpty(randomCatURL) ? randomCatURL : "God damn it, we're out of cats, care to notify my master? Thanks!"; - default: - return null; + break; } } - - // This method is called when bot is destroyed, e.g. on config removal - // You should ensure that all of your references to this bot instance are cleared - most of the time this is anything you created in OnBotInit(), including deep roots in your custom modules - // This doesn't have to be done immediately (e.g. no need to cancel existing work), but it should be done in timely manner when everything is finished - // Doing so will allow the garbage collector to dispose the bot afterwards, refraining from doing so will create a "memory leak" by keeping the reference alive - public void OnBotDestroy(Bot bot) { } - - // This method is called when bot is disconnected from Steam network, you may want to use this info in some kind of way, or not - // ASF tries its best to provide logical reason why the disconnection has happened, and will use EResult.OK if the disconnection was initiated by us (e.g. as part of a command) - // Still, you should take anything other than EResult.OK with a grain of salt, unless you want to assume that Steam knows why it disconnected us (hehe, you bet) - public void OnBotDisconnected(Bot bot, EResult reason) { } - - // This method is called when bot receives a friend request or group invite that ASF isn't willing to accept - // It allows you to generate a response whether ASF should accept it (true) or proceed like usual (false) - // If you wanted to do extra filtering (e.g. friend requests only), you can interpret the steamID as SteamID (SteamKit2 type) and then operate on AccountType - // As an example, we'll run a trade bot that is open to all friend/group invites, therefore we'll accept all of them here - public Task OnBotFriendRequest(Bot bot, ulong steamID) => Task.FromResult(true); - - // This method is called at the end of Bot's constructor - // You can initialize all your per-bot structures here - // In general you should do that only when you have a particular need of custom modules or alike, since ASF's plugin system will always provide bot to you as a function argument - public void OnBotInit(Bot bot) { - // Apart of those two that are already provided by ASF, you can also initialize your own logger with your plugin's name, if needed - bot.ArchiLogger.LogGenericInfo($"Our bot named {bot.BotName} has been initialized, and we're letting you know about it from our {nameof(ExamplePlugin)}!"); - ASF.ArchiLogger.LogGenericWarning("In case we won't have a bot reference or have something process-wide to log, we can also use ASF's logger!"); - } - - // This method, apart from being called during bot modules initialization, allows you to read custom bot config properties that are not recognized by ASF - // Thanks to that, you can extend default bot config with your own stuff, then parse it here in order to customize your plugin during runtime - // Keep in mind that, as noted in the interface, additionalConfigProperties can be null if no custom, unrecognized properties are found by ASF, you should handle that case appropriately - // Also keep in mind that this function can be called multiple times, e.g. when user edits his bot configs during runtime - // Take a look at OnASFInit() for example parsing code - public async void OnBotInitModules(Bot bot, IReadOnlyDictionary? additionalConfigProperties = null) { - // ASF marked this message as synchronous, in case we have async code to execute, we can just use async void return - // For example, we'll ensure that every bot starts paused regardless of Paused property, in order to do this, we'll just call Pause here in InitModules() - // Thanks to the fact that this method is called with each bot config reload, we'll ensure that our bot stays paused even if it'd get unpaused otherwise - bot.ArchiLogger.LogGenericInfo("Pausing this bot as asked from the plugin"); - await bot.Actions.Pause(true).ConfigureAwait(false); - } - - // This method is called when the bot is successfully connected to Steam network and it's a good place to schedule any on-connected tasks, as AWH is also expected to be available shortly - public void OnBotLoggedOn(Bot bot) { } - - // This method is called when bot receives a message that is NOT a command (in other words, a message that doesn't start with CommandPrefix) - // Normally ASF entirely ignores such messages as the program should not respond to something that isn't recognized - // Therefore this function allows you to catch all such messages and handle them yourself - // Keep in mind that there is no guarantee what is the actual access of steamID, so you should do the appropriate access checking yourself - // You can use either ASF's default functions for that, or implement your own logic as you please - // If you do not intend to return any response to user, just return null/empty and ASF will proceed with the silence as usual - public Task OnBotMessage(Bot bot, ulong steamID, string message) { - // Normally ASF will expect from you async-capable responses, such as Task. This allows you to make your code fully asynchronous which is a core foundation on which ASF is built upon - // Since in this method we're not doing any async stuff, instead of defining this method as async (pointless), we just need to wrap our responses in Task.FromResult<>() - if (Bot.BotsReadOnly == null) { - throw new InvalidOperationException(nameof(Bot.BotsReadOnly)); - } - - // As a starter, we can for example ignore messages sent from our own bots, since otherwise they can run into a possible infinite loop of answering themselves - if (Bot.BotsReadOnly.Values.Any(existingBot => existingBot.SteamID == steamID)) { - return Task.FromResult(null); - } - - // If this message doesn't come from one of our bots, we can reply to the user in some pre-defined way - bot.ArchiLogger.LogGenericTrace("Hey boss, we got some unknown message here!"); - - return Task.FromResult((string?) "I didn't get that, did you mean to use a command?"); - } - - // This method is called when bot receives a trade offer that ASF isn't willing to accept (ignored and rejected trades) - // It allows you not only to analyze such trades, but generate a response whether ASF should accept it (true), or proceed like usual (false) - // Thanks to that, you can implement custom rules for all trades that aren't handled by ASF, for example cross-set trading on your own custom rules - // You'd implement your own logic here, as an example we'll allow all trades to be accepted if the bot's name starts from "TrashBot" - public Task OnBotTradeOffer(Bot bot, TradeOffer tradeOffer) => Task.FromResult(bot.BotName.StartsWith("TrashBot", StringComparison.OrdinalIgnoreCase)); - - // This is the earliest method that will be called, right after loading the plugin, long before any bot initialization takes place - // It's a good place to initialize all potential (non-bot-specific) structures that you will need across lifetime of your plugin, such as global timers, concurrent dictionaries and alike - // If you do not have any global structures to initialize, you can leave this function empty - // At this point you can access core ASF's functionality, such as logging, but more advanced structures (like ASF's WebBrowser) will be available in OnASFInit(), which itself takes place after every plugin gets OnLoaded() - // Typically you should use this function only for preparing core structures of your plugin, and optionally also sending a message to the user (e.g. support link, welcome message or similar), ASF-specific things should usually happen in OnASFInit() - public void OnLoaded() { - ASF.ArchiLogger.LogGenericInfo($"Hey! Thanks for checking if our example plugin works fine, this is a confirmation that indeed {nameof(OnLoaded)}() method was called!"); - ASF.ArchiLogger.LogGenericInfo("Good luck in whatever you're doing!"); - } + } + + // This method is called when unknown command is received (starting with CommandPrefix) + // This allows you to recognize the command yourself and implement custom commands + // Keep in mind that there is no guarantee what is the actual access of steamID, so you should do the appropriate access checking yourself + // You can use either ASF's default functions for that, or implement your own logic as you please + // Since ASF already had to do initial parsing in order to determine that the command is unknown, args[] are splitted using standard ASF delimiters + // If by any chance you want to handle message in its raw format, you also have it available, although for usual ASF pattern you can most likely stick with args[] exclusively. The message has CommandPrefix already stripped for your convenience + // If you do not recognize the command, just return null/empty and allow ASF to gracefully return "unknown command" to user on usual basis + public async Task OnBotCommand(Bot bot, ulong steamID, string message, string[] args) { + // In comparison with OnBotMessage(), we're using asynchronous CatAPI call here, so we declare our method as async and return the message as usual + // Notice how we handle access here as well, it'll work only for FamilySharing+ + switch (args[0].ToUpperInvariant()) { + case "CAT" when bot.HasAccess(steamID, BotConfig.EAccess.FamilySharing): + // Notice how we can decide whether to use bot's AWH WebBrowser or ASF's one. For Steam-related requests, AWH's one should always be used, for third-party requests like those it doesn't really matter + // Still, it makes sense to pass AWH's one, so in case you get some errors or alike, you know from which bot instance they come from. It's similar to using Bot's ArchiLogger compared to ASF's one + string? randomCatURL = await CatAPI.GetRandomCatURL(bot.ArchiWebHandler.WebBrowser).ConfigureAwait(false); + + return !string.IsNullOrEmpty(randomCatURL) ? randomCatURL : "God damn it, we're out of cats, care to notify my master? Thanks!"; + default: + return null; + } + } + + // This method is called when bot is destroyed, e.g. on config removal + // You should ensure that all of your references to this bot instance are cleared - most of the time this is anything you created in OnBotInit(), including deep roots in your custom modules + // This doesn't have to be done immediately (e.g. no need to cancel existing work), but it should be done in timely manner when everything is finished + // Doing so will allow the garbage collector to dispose the bot afterwards, refraining from doing so will create a "memory leak" by keeping the reference alive + public void OnBotDestroy(Bot bot) { } + + // This method is called when bot is disconnected from Steam network, you may want to use this info in some kind of way, or not + // ASF tries its best to provide logical reason why the disconnection has happened, and will use EResult.OK if the disconnection was initiated by us (e.g. as part of a command) + // Still, you should take anything other than EResult.OK with a grain of salt, unless you want to assume that Steam knows why it disconnected us (hehe, you bet) + public void OnBotDisconnected(Bot bot, EResult reason) { } + + // This method is called when bot receives a friend request or group invite that ASF isn't willing to accept + // It allows you to generate a response whether ASF should accept it (true) or proceed like usual (false) + // If you wanted to do extra filtering (e.g. friend requests only), you can interpret the steamID as SteamID (SteamKit2 type) and then operate on AccountType + // As an example, we'll run a trade bot that is open to all friend/group invites, therefore we'll accept all of them here + public Task OnBotFriendRequest(Bot bot, ulong steamID) => Task.FromResult(true); + + // This method is called at the end of Bot's constructor + // You can initialize all your per-bot structures here + // In general you should do that only when you have a particular need of custom modules or alike, since ASF's plugin system will always provide bot to you as a function argument + public void OnBotInit(Bot bot) { + // Apart of those two that are already provided by ASF, you can also initialize your own logger with your plugin's name, if needed + bot.ArchiLogger.LogGenericInfo($"Our bot named {bot.BotName} has been initialized, and we're letting you know about it from our {nameof(ExamplePlugin)}!"); + ASF.ArchiLogger.LogGenericWarning("In case we won't have a bot reference or have something process-wide to log, we can also use ASF's logger!"); + } + + // This method, apart from being called during bot modules initialization, allows you to read custom bot config properties that are not recognized by ASF + // Thanks to that, you can extend default bot config with your own stuff, then parse it here in order to customize your plugin during runtime + // Keep in mind that, as noted in the interface, additionalConfigProperties can be null if no custom, unrecognized properties are found by ASF, you should handle that case appropriately + // Also keep in mind that this function can be called multiple times, e.g. when user edits his bot configs during runtime + // Take a look at OnASFInit() for example parsing code + public async void OnBotInitModules(Bot bot, IReadOnlyDictionary? additionalConfigProperties = null) { + // ASF marked this message as synchronous, in case we have async code to execute, we can just use async void return + // For example, we'll ensure that every bot starts paused regardless of Paused property, in order to do this, we'll just call Pause here in InitModules() + // Thanks to the fact that this method is called with each bot config reload, we'll ensure that our bot stays paused even if it'd get unpaused otherwise + bot.ArchiLogger.LogGenericInfo("Pausing this bot as asked from the plugin"); + await bot.Actions.Pause(true).ConfigureAwait(false); + } + + // This method is called when the bot is successfully connected to Steam network and it's a good place to schedule any on-connected tasks, as AWH is also expected to be available shortly + public void OnBotLoggedOn(Bot bot) { } + + // This method is called when bot receives a message that is NOT a command (in other words, a message that doesn't start with CommandPrefix) + // Normally ASF entirely ignores such messages as the program should not respond to something that isn't recognized + // Therefore this function allows you to catch all such messages and handle them yourself + // Keep in mind that there is no guarantee what is the actual access of steamID, so you should do the appropriate access checking yourself + // You can use either ASF's default functions for that, or implement your own logic as you please + // If you do not intend to return any response to user, just return null/empty and ASF will proceed with the silence as usual + public Task OnBotMessage(Bot bot, ulong steamID, string message) { + // Normally ASF will expect from you async-capable responses, such as Task. This allows you to make your code fully asynchronous which is a core foundation on which ASF is built upon + // Since in this method we're not doing any async stuff, instead of defining this method as async (pointless), we just need to wrap our responses in Task.FromResult<>() + if (Bot.BotsReadOnly == null) { + throw new InvalidOperationException(nameof(Bot.BotsReadOnly)); + } + + // As a starter, we can for example ignore messages sent from our own bots, since otherwise they can run into a possible infinite loop of answering themselves + if (Bot.BotsReadOnly.Values.Any(existingBot => existingBot.SteamID == steamID)) { + return Task.FromResult(null); + } + + // If this message doesn't come from one of our bots, we can reply to the user in some pre-defined way + bot.ArchiLogger.LogGenericTrace("Hey boss, we got some unknown message here!"); + + return Task.FromResult((string?) "I didn't get that, did you mean to use a command?"); + } + + // This method is called when bot receives a trade offer that ASF isn't willing to accept (ignored and rejected trades) + // It allows you not only to analyze such trades, but generate a response whether ASF should accept it (true), or proceed like usual (false) + // Thanks to that, you can implement custom rules for all trades that aren't handled by ASF, for example cross-set trading on your own custom rules + // You'd implement your own logic here, as an example we'll allow all trades to be accepted if the bot's name starts from "TrashBot" + public Task OnBotTradeOffer(Bot bot, TradeOffer tradeOffer) => Task.FromResult(bot.BotName.StartsWith("TrashBot", StringComparison.OrdinalIgnoreCase)); + + // This is the earliest method that will be called, right after loading the plugin, long before any bot initialization takes place + // It's a good place to initialize all potential (non-bot-specific) structures that you will need across lifetime of your plugin, such as global timers, concurrent dictionaries and alike + // If you do not have any global structures to initialize, you can leave this function empty + // At this point you can access core ASF's functionality, such as logging, but more advanced structures (like ASF's WebBrowser) will be available in OnASFInit(), which itself takes place after every plugin gets OnLoaded() + // Typically you should use this function only for preparing core structures of your plugin, and optionally also sending a message to the user (e.g. support link, welcome message or similar), ASF-specific things should usually happen in OnASFInit() + public void OnLoaded() { + ASF.ArchiLogger.LogGenericInfo($"Hey! Thanks for checking if our example plugin works fine, this is a confirmation that indeed {nameof(OnLoaded)}() method was called!"); + ASF.ArchiLogger.LogGenericInfo("Good luck in whatever you're doing!"); } } diff --git a/ArchiSteamFarm.CustomPlugins.PeriodicGC/PeriodicGCPlugin.cs b/ArchiSteamFarm.CustomPlugins.PeriodicGC/PeriodicGCPlugin.cs index 97ad05aaa..b587e4bc9 100644 --- a/ArchiSteamFarm.CustomPlugins.PeriodicGC/PeriodicGCPlugin.cs +++ b/ArchiSteamFarm.CustomPlugins.PeriodicGC/PeriodicGCPlugin.cs @@ -27,38 +27,38 @@ using System.Threading; using ArchiSteamFarm.Core; using ArchiSteamFarm.Plugins.Interfaces; -namespace ArchiSteamFarm.CustomPlugins.PeriodicGC { - [Export(typeof(IPlugin))] - [SuppressMessage("ReSharper", "UnusedType.Global")] - internal sealed class PeriodicGCPlugin : IPlugin { - private const byte GCPeriod = 60; // In seconds +namespace ArchiSteamFarm.CustomPlugins.PeriodicGC; - private static readonly object LockObject = new(); - private static readonly Timer PeriodicGCTimer = new(PerformGC); +[Export(typeof(IPlugin))] +[SuppressMessage("ReSharper", "UnusedType.Global")] +internal sealed class PeriodicGCPlugin : IPlugin { + private const byte GCPeriod = 60; // In seconds - public string Name => nameof(PeriodicGCPlugin); + private static readonly object LockObject = new(); + private static readonly Timer PeriodicGCTimer = new(PerformGC); - public Version Version => typeof(PeriodicGCPlugin).Assembly.GetName().Version ?? throw new InvalidOperationException(nameof(Version)); + public string Name => nameof(PeriodicGCPlugin); - public void OnLoaded() { - TimeSpan timeSpan = TimeSpan.FromSeconds(GCPeriod); + public Version Version => typeof(PeriodicGCPlugin).Assembly.GetName().Version ?? throw new InvalidOperationException(nameof(Version)); - ASF.ArchiLogger.LogGenericWarning($"Periodic GC will occur every {timeSpan.ToHumanReadable()}. Please keep in mind that this plugin should be used for debugging tests only."); + public void OnLoaded() { + TimeSpan timeSpan = TimeSpan.FromSeconds(GCPeriod); - lock (LockObject) { - PeriodicGCTimer.Change(timeSpan, timeSpan); - } - } + ASF.ArchiLogger.LogGenericWarning($"Periodic GC will occur every {timeSpan.ToHumanReadable()}. Please keep in mind that this plugin should be used for debugging tests only."); - private static void PerformGC(object? state = null) { - ASF.ArchiLogger.LogGenericWarning($"Performing GC, current memory: {GC.GetTotalMemory(false) / 1024} KB."); - - lock (LockObject) { - GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; - GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true, true); - } - - ASF.ArchiLogger.LogGenericWarning($"GC finished, current memory: {GC.GetTotalMemory(false) / 1024} KB."); + lock (LockObject) { + PeriodicGCTimer.Change(timeSpan, timeSpan); } } + + private static void PerformGC(object? state = null) { + ASF.ArchiLogger.LogGenericWarning($"Performing GC, current memory: {GC.GetTotalMemory(false) / 1024} KB."); + + lock (LockObject) { + GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; + GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true, true); + } + + ASF.ArchiLogger.LogGenericWarning($"GC finished, current memory: {GC.GetTotalMemory(false) / 1024} KB."); + } } diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/GlobalCache.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/GlobalCache.cs index a0e5a1f8d..2aa517b8f 100644 --- a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/GlobalCache.cs +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/GlobalCache.cs @@ -33,275 +33,275 @@ using JetBrains.Annotations; using Newtonsoft.Json; using SteamKit2; -namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper { - internal sealed class GlobalCache : SerializableFile { - private static string SharedFilePath => Path.Combine(ArchiSteamFarm.SharedInfo.ConfigDirectory, $"{nameof(SteamTokenDumper)}.cache"); +namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper; - [JsonProperty(Required = Required.DisallowNull)] - private readonly ConcurrentDictionary AppChangeNumbers = new(); +internal sealed class GlobalCache : SerializableFile { + private static string SharedFilePath => Path.Combine(ArchiSteamFarm.SharedInfo.ConfigDirectory, $"{nameof(SteamTokenDumper)}.cache"); - [JsonProperty(Required = Required.DisallowNull)] - private readonly ConcurrentDictionary AppTokens = new(); + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentDictionary AppChangeNumbers = new(); - [JsonProperty(Required = Required.DisallowNull)] - private readonly ConcurrentDictionary DepotKeys = new(); + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentDictionary AppTokens = new(); - [JsonProperty(Required = Required.DisallowNull)] - private readonly ConcurrentDictionary PackageTokens = new(); + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentDictionary DepotKeys = new(); - [JsonProperty(Required = Required.DisallowNull)] - private readonly ConcurrentDictionary SubmittedApps = new(); + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentDictionary PackageTokens = new(); - [JsonProperty(Required = Required.DisallowNull)] - private readonly ConcurrentDictionary SubmittedDepots = new(); + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentDictionary SubmittedApps = new(); - [JsonProperty(Required = Required.DisallowNull)] - private readonly ConcurrentDictionary SubmittedPackages = new(); + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentDictionary SubmittedDepots = new(); - [JsonProperty(Required = Required.DisallowNull)] - internal uint LastChangeNumber { get; private set; } + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentDictionary SubmittedPackages = new(); - internal GlobalCache() => FilePath = SharedFilePath; + [JsonProperty(Required = Required.DisallowNull)] + internal uint LastChangeNumber { get; private set; } - [UsedImplicitly] - public bool ShouldSerializeAppChangeNumbers() => !AppChangeNumbers.IsEmpty; + internal GlobalCache() => FilePath = SharedFilePath; - [UsedImplicitly] - public bool ShouldSerializeAppTokens() => !AppTokens.IsEmpty; + [UsedImplicitly] + public bool ShouldSerializeAppChangeNumbers() => !AppChangeNumbers.IsEmpty; - [UsedImplicitly] - public bool ShouldSerializeDepotKeys() => !DepotKeys.IsEmpty; + [UsedImplicitly] + public bool ShouldSerializeAppTokens() => !AppTokens.IsEmpty; - [UsedImplicitly] - public bool ShouldSerializeLastChangeNumber() => LastChangeNumber > 0; + [UsedImplicitly] + public bool ShouldSerializeDepotKeys() => !DepotKeys.IsEmpty; - [UsedImplicitly] - public bool ShouldSerializePackageTokens() => !PackageTokens.IsEmpty; + [UsedImplicitly] + public bool ShouldSerializeLastChangeNumber() => LastChangeNumber > 0; - [UsedImplicitly] - public bool ShouldSerializeSubmittedApps() => !SubmittedApps.IsEmpty; + [UsedImplicitly] + public bool ShouldSerializePackageTokens() => !PackageTokens.IsEmpty; - [UsedImplicitly] - public bool ShouldSerializeSubmittedDepots() => !SubmittedDepots.IsEmpty; + [UsedImplicitly] + public bool ShouldSerializeSubmittedApps() => !SubmittedApps.IsEmpty; - [UsedImplicitly] - public bool ShouldSerializeSubmittedPackages() => !SubmittedPackages.IsEmpty; + [UsedImplicitly] + public bool ShouldSerializeSubmittedDepots() => !SubmittedDepots.IsEmpty; - internal ulong GetAppToken(uint appID) => AppTokens[appID]; + [UsedImplicitly] + public bool ShouldSerializeSubmittedPackages() => !SubmittedPackages.IsEmpty; - internal Dictionary GetAppTokensForSubmission() => AppTokens.Where(appToken => (SteamTokenDumperPlugin.Config?.SecretAppIDs.Contains(appToken.Key) == false) && (appToken.Value > 0) && (!SubmittedApps.TryGetValue(appToken.Key, out ulong token) || (appToken.Value != token))).ToDictionary(static appToken => appToken.Key, static appToken => appToken.Value); - internal Dictionary GetDepotKeysForSubmission() => DepotKeys.Where(depotKey => (SteamTokenDumperPlugin.Config?.SecretDepotIDs.Contains(depotKey.Key) == false) && !string.IsNullOrEmpty(depotKey.Value) && (!SubmittedDepots.TryGetValue(depotKey.Key, out string? key) || (depotKey.Value != key))).ToDictionary(static depotKey => depotKey.Key, static depotKey => depotKey.Value); - internal Dictionary GetPackageTokensForSubmission() => PackageTokens.Where(packageToken => (SteamTokenDumperPlugin.Config?.SecretPackageIDs.Contains(packageToken.Key) == false) && (packageToken.Value > 0) && (!SubmittedPackages.TryGetValue(packageToken.Key, out ulong token) || (packageToken.Value != token))).ToDictionary(static packageToken => packageToken.Key, static packageToken => packageToken.Value); + internal ulong GetAppToken(uint appID) => AppTokens[appID]; - internal static async Task Load() { - if (!File.Exists(SharedFilePath)) { - GlobalCache result = new(); + internal Dictionary GetAppTokensForSubmission() => AppTokens.Where(appToken => (SteamTokenDumperPlugin.Config?.SecretAppIDs.Contains(appToken.Key) == false) && (appToken.Value > 0) && (!SubmittedApps.TryGetValue(appToken.Key, out ulong token) || (appToken.Value != token))).ToDictionary(static appToken => appToken.Key, static appToken => appToken.Value); + internal Dictionary GetDepotKeysForSubmission() => DepotKeys.Where(depotKey => (SteamTokenDumperPlugin.Config?.SecretDepotIDs.Contains(depotKey.Key) == false) && !string.IsNullOrEmpty(depotKey.Value) && (!SubmittedDepots.TryGetValue(depotKey.Key, out string? key) || (depotKey.Value != key))).ToDictionary(static depotKey => depotKey.Key, static depotKey => depotKey.Value); + internal Dictionary GetPackageTokensForSubmission() => PackageTokens.Where(packageToken => (SteamTokenDumperPlugin.Config?.SecretPackageIDs.Contains(packageToken.Key) == false) && (packageToken.Value > 0) && (!SubmittedPackages.TryGetValue(packageToken.Key, out ulong token) || (packageToken.Value != token))).ToDictionary(static packageToken => packageToken.Key, static packageToken => packageToken.Value); - Utilities.InBackground(result.Save); + internal static async Task Load() { + if (!File.Exists(SharedFilePath)) { + GlobalCache result = new(); - return result; - } + Utilities.InBackground(result.Save); - GlobalCache? globalCache; + return result; + } - try { - string json = await File.ReadAllTextAsync(SharedFilePath).ConfigureAwait(false); + GlobalCache? globalCache; - if (string.IsNullOrEmpty(json)) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(json))); + try { + string json = await File.ReadAllTextAsync(SharedFilePath).ConfigureAwait(false); - return null; - } - - globalCache = JsonConvert.DeserializeObject(json); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); + if (string.IsNullOrEmpty(json)) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(json))); return null; } - if (globalCache == null) { - ASF.ArchiLogger.LogNullError(nameof(globalCache)); + globalCache = JsonConvert.DeserializeObject(json); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); - return null; - } - - return globalCache; + return null; } - internal void OnPICSChanges(uint currentChangeNumber, IReadOnlyCollection> appChanges) { - if (currentChangeNumber == 0) { - throw new ArgumentOutOfRangeException(nameof(currentChangeNumber)); - } + if (globalCache == null) { + ASF.ArchiLogger.LogNullError(nameof(globalCache)); - if (appChanges == null) { - throw new ArgumentNullException(nameof(appChanges)); - } - - if (currentChangeNumber <= LastChangeNumber) { - return; - } - - LastChangeNumber = currentChangeNumber; - - foreach ((uint appID, SteamApps.PICSChangesCallback.PICSChangeData appData) in appChanges) { - if (!AppChangeNumbers.TryGetValue(appID, out uint previousChangeNumber) || (appData.ChangeNumber <= previousChangeNumber)) { - continue; - } - - AppChangeNumbers.TryRemove(appID, out _); - } - - Utilities.InBackground(Save); + return null; } - internal void OnPICSChangesRestart(uint currentChangeNumber) { - if (currentChangeNumber == 0) { - throw new ArgumentOutOfRangeException(nameof(currentChangeNumber)); - } + return globalCache; + } - if (currentChangeNumber <= LastChangeNumber) { - return; - } - - LastChangeNumber = currentChangeNumber; - AppChangeNumbers.Clear(); - - Utilities.InBackground(Save); + internal void OnPICSChanges(uint currentChangeNumber, IReadOnlyCollection> appChanges) { + if (currentChangeNumber == 0) { + throw new ArgumentOutOfRangeException(nameof(currentChangeNumber)); } - internal bool ShouldRefreshAppInfo(uint appID) => !AppChangeNumbers.ContainsKey(appID); - internal bool ShouldRefreshDepotKey(uint depotID) => !DepotKeys.ContainsKey(depotID); - - internal void UpdateAppChangeNumbers(IReadOnlyCollection> appChangeNumbers) { - if (appChangeNumbers == null) { - throw new ArgumentNullException(nameof(appChangeNumbers)); - } - - bool save = false; - - foreach ((uint appID, uint changeNumber) in appChangeNumbers) { - if (AppChangeNumbers.TryGetValue(appID, out uint previousChangeNumber) && (previousChangeNumber == changeNumber)) { - continue; - } - - AppChangeNumbers[appID] = changeNumber; - save = true; - } - - if (save) { - Utilities.InBackground(Save); - } + if (appChanges == null) { + throw new ArgumentNullException(nameof(appChanges)); } - internal void UpdateAppTokens(IReadOnlyCollection> appTokens, IReadOnlyCollection publicAppIDs) { - if (appTokens == null) { - throw new ArgumentNullException(nameof(appTokens)); - } - - if (publicAppIDs == null) { - throw new ArgumentNullException(nameof(publicAppIDs)); - } - - bool save = false; - - foreach ((uint appID, ulong appToken) in appTokens) { - if (AppTokens.TryGetValue(appID, out ulong previousAppToken) && (previousAppToken == appToken)) { - continue; - } - - AppTokens[appID] = appToken; - save = true; - } - - foreach (uint appID in publicAppIDs) { - if (AppTokens.TryGetValue(appID, out ulong previousAppToken) && (previousAppToken == 0)) { - continue; - } - - AppTokens[appID] = 0; - save = true; - } - - if (save) { - Utilities.InBackground(Save); - } + if (currentChangeNumber <= LastChangeNumber) { + return; } - internal void UpdateDepotKeys(ICollection depotKeyResults) { - if (depotKeyResults == null) { - throw new ArgumentNullException(nameof(depotKeyResults)); + LastChangeNumber = currentChangeNumber; + + foreach ((uint appID, SteamApps.PICSChangesCallback.PICSChangeData appData) in appChanges) { + if (!AppChangeNumbers.TryGetValue(appID, out uint previousChangeNumber) || (appData.ChangeNumber <= previousChangeNumber)) { + continue; } - bool save = false; - - foreach (SteamApps.DepotKeyCallback depotKeyResult in depotKeyResults) { - if (depotKeyResult.Result != EResult.OK) { - continue; - } - - string depotKey = BitConverter.ToString(depotKeyResult.DepotKey).Replace("-", "", StringComparison.Ordinal); - - if (DepotKeys.TryGetValue(depotKeyResult.DepotID, out string? previousDepotKey) && (previousDepotKey == depotKey)) { - continue; - } - - DepotKeys[depotKeyResult.DepotID] = depotKey; - save = true; - } - - if (save) { - Utilities.InBackground(Save); - } + AppChangeNumbers.TryRemove(appID, out _); } - internal void UpdatePackageTokens(IReadOnlyCollection> packageTokens) { - if (packageTokens == null) { - throw new ArgumentNullException(nameof(packageTokens)); - } + Utilities.InBackground(Save); + } - bool save = false; - - foreach ((uint packageID, ulong packageToken) in packageTokens) { - if (PackageTokens.TryGetValue(packageID, out ulong previousPackageToken) && (previousPackageToken == packageToken)) { - continue; - } - - PackageTokens[packageID] = packageToken; - save = true; - } - - if (save) { - Utilities.InBackground(Save); - } + internal void OnPICSChangesRestart(uint currentChangeNumber) { + if (currentChangeNumber == 0) { + throw new ArgumentOutOfRangeException(nameof(currentChangeNumber)); } - internal void UpdateSubmittedData(IReadOnlyDictionary apps, IReadOnlyDictionary packages, IReadOnlyDictionary depots) { - if (apps == null) { - throw new ArgumentNullException(nameof(apps)); + if (currentChangeNumber <= LastChangeNumber) { + return; + } + + LastChangeNumber = currentChangeNumber; + AppChangeNumbers.Clear(); + + Utilities.InBackground(Save); + } + + internal bool ShouldRefreshAppInfo(uint appID) => !AppChangeNumbers.ContainsKey(appID); + internal bool ShouldRefreshDepotKey(uint depotID) => !DepotKeys.ContainsKey(depotID); + + internal void UpdateAppChangeNumbers(IReadOnlyCollection> appChangeNumbers) { + if (appChangeNumbers == null) { + throw new ArgumentNullException(nameof(appChangeNumbers)); + } + + bool save = false; + + foreach ((uint appID, uint changeNumber) in appChangeNumbers) { + if (AppChangeNumbers.TryGetValue(appID, out uint previousChangeNumber) && (previousChangeNumber == changeNumber)) { + continue; } - if (packages == null) { - throw new ArgumentNullException(nameof(packages)); - } - - if (depots == null) { - throw new ArgumentNullException(nameof(depots)); - } - - foreach ((uint appID, ulong token) in apps) { - SubmittedApps[appID] = token; - } - - foreach ((uint packageID, ulong token) in packages) { - SubmittedPackages[packageID] = token; - } - - foreach ((uint depotID, string key) in depots) { - SubmittedDepots[depotID] = key; - } + AppChangeNumbers[appID] = changeNumber; + save = true; + } + if (save) { Utilities.InBackground(Save); } } + + internal void UpdateAppTokens(IReadOnlyCollection> appTokens, IReadOnlyCollection publicAppIDs) { + if (appTokens == null) { + throw new ArgumentNullException(nameof(appTokens)); + } + + if (publicAppIDs == null) { + throw new ArgumentNullException(nameof(publicAppIDs)); + } + + bool save = false; + + foreach ((uint appID, ulong appToken) in appTokens) { + if (AppTokens.TryGetValue(appID, out ulong previousAppToken) && (previousAppToken == appToken)) { + continue; + } + + AppTokens[appID] = appToken; + save = true; + } + + foreach (uint appID in publicAppIDs) { + if (AppTokens.TryGetValue(appID, out ulong previousAppToken) && (previousAppToken == 0)) { + continue; + } + + AppTokens[appID] = 0; + save = true; + } + + if (save) { + Utilities.InBackground(Save); + } + } + + internal void UpdateDepotKeys(ICollection depotKeyResults) { + if (depotKeyResults == null) { + throw new ArgumentNullException(nameof(depotKeyResults)); + } + + bool save = false; + + foreach (SteamApps.DepotKeyCallback depotKeyResult in depotKeyResults) { + if (depotKeyResult.Result != EResult.OK) { + continue; + } + + string depotKey = BitConverter.ToString(depotKeyResult.DepotKey).Replace("-", "", StringComparison.Ordinal); + + if (DepotKeys.TryGetValue(depotKeyResult.DepotID, out string? previousDepotKey) && (previousDepotKey == depotKey)) { + continue; + } + + DepotKeys[depotKeyResult.DepotID] = depotKey; + save = true; + } + + if (save) { + Utilities.InBackground(Save); + } + } + + internal void UpdatePackageTokens(IReadOnlyCollection> packageTokens) { + if (packageTokens == null) { + throw new ArgumentNullException(nameof(packageTokens)); + } + + bool save = false; + + foreach ((uint packageID, ulong packageToken) in packageTokens) { + if (PackageTokens.TryGetValue(packageID, out ulong previousPackageToken) && (previousPackageToken == packageToken)) { + continue; + } + + PackageTokens[packageID] = packageToken; + save = true; + } + + if (save) { + Utilities.InBackground(Save); + } + } + + internal void UpdateSubmittedData(IReadOnlyDictionary apps, IReadOnlyDictionary packages, IReadOnlyDictionary depots) { + if (apps == null) { + throw new ArgumentNullException(nameof(apps)); + } + + if (packages == null) { + throw new ArgumentNullException(nameof(packages)); + } + + if (depots == null) { + throw new ArgumentNullException(nameof(depots)); + } + + foreach ((uint appID, ulong token) in apps) { + SubmittedApps[appID] = token; + } + + foreach ((uint packageID, ulong token) in packages) { + SubmittedPackages[packageID] = token; + } + + foreach ((uint depotID, string key) in depots) { + SubmittedDepots[depotID] = key; + } + + Utilities.InBackground(Save); + } } diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/GlobalConfigExtension.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/GlobalConfigExtension.cs index 8cef1e23b..fe0adcef8 100644 --- a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/GlobalConfigExtension.cs +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/GlobalConfigExtension.cs @@ -21,15 +21,15 @@ using Newtonsoft.Json; -namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper { - public sealed class GlobalConfigExtension { - [JsonProperty(Required = Required.DisallowNull)] - public SteamTokenDumperConfig? SteamTokenDumperPlugin { get; private set; } +namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper; - [JsonProperty(Required = Required.DisallowNull)] - public bool SteamTokenDumperPluginEnabled { get; private set; } +public sealed class GlobalConfigExtension { + [JsonProperty(Required = Required.DisallowNull)] + public SteamTokenDumperConfig? SteamTokenDumperPlugin { get; private set; } - [JsonConstructor] - internal GlobalConfigExtension() { } - } + [JsonProperty(Required = Required.DisallowNull)] + public bool SteamTokenDumperPluginEnabled { get; private set; } + + [JsonConstructor] + internal GlobalConfigExtension() { } } diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/RequestData.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/RequestData.cs index cb27ad052..2b43328f2 100644 --- a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/RequestData.cs +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/RequestData.cs @@ -27,53 +27,53 @@ using ArchiSteamFarm.Core; using Newtonsoft.Json; using SteamKit2; -namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper { - internal sealed class RequestData { - [JsonProperty(PropertyName = "guid", Required = Required.Always)] - private static string Guid => ASF.GlobalDatabase?.Identifier.ToString("N") ?? throw new InvalidOperationException(nameof(ASF.GlobalDatabase.Identifier)); +namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper; - [JsonProperty(PropertyName = "token", Required = Required.Always)] - private static string Token => SharedInfo.Token; +internal sealed class RequestData { + [JsonProperty(PropertyName = "guid", Required = Required.Always)] + private static string Guid => ASF.GlobalDatabase?.Identifier.ToString("N") ?? throw new InvalidOperationException(nameof(ASF.GlobalDatabase.Identifier)); - [JsonProperty(PropertyName = "v", Required = Required.Always)] - private static byte Version => SharedInfo.ApiVersion; + [JsonProperty(PropertyName = "token", Required = Required.Always)] + private static string Token => SharedInfo.Token; - [JsonProperty(PropertyName = "apps", Required = Required.Always)] - private readonly ImmutableDictionary Apps; + [JsonProperty(PropertyName = "v", Required = Required.Always)] + private static byte Version => SharedInfo.ApiVersion; - [JsonProperty(PropertyName = "depots", Required = Required.Always)] - private readonly ImmutableDictionary Depots; + [JsonProperty(PropertyName = "apps", Required = Required.Always)] + private readonly ImmutableDictionary Apps; - private readonly ulong SteamID; + [JsonProperty(PropertyName = "depots", Required = Required.Always)] + private readonly ImmutableDictionary Depots; - [JsonProperty(PropertyName = "subs", Required = Required.Always)] - private readonly ImmutableDictionary Subs; + private readonly ulong SteamID; - [JsonProperty(PropertyName = "steamid", Required = Required.Always)] - private string SteamIDText => new SteamID(SteamID).Render(); + [JsonProperty(PropertyName = "subs", Required = Required.Always)] + private readonly ImmutableDictionary Subs; - internal RequestData(ulong steamID, IReadOnlyCollection> apps, IReadOnlyCollection> accessTokens, IReadOnlyCollection> depots) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + [JsonProperty(PropertyName = "steamid", Required = Required.Always)] + private string SteamIDText => new SteamID(SteamID).Render(); - if (apps == null) { - throw new ArgumentNullException(nameof(apps)); - } - - if (accessTokens == null) { - throw new ArgumentNullException(nameof(accessTokens)); - } - - if (depots == null) { - throw new ArgumentNullException(nameof(depots)); - } - - SteamID = steamID; - - Apps = apps.ToImmutableDictionary(static app => app.Key.ToString(CultureInfo.InvariantCulture), static app => app.Value.ToString(CultureInfo.InvariantCulture)); - Subs = accessTokens.ToImmutableDictionary(static package => package.Key.ToString(CultureInfo.InvariantCulture), static package => package.Value.ToString(CultureInfo.InvariantCulture)); - Depots = depots.ToImmutableDictionary(static depot => depot.Key.ToString(CultureInfo.InvariantCulture), static depot => depot.Value); + internal RequestData(ulong steamID, IReadOnlyCollection> apps, IReadOnlyCollection> accessTokens, IReadOnlyCollection> depots) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } + + if (apps == null) { + throw new ArgumentNullException(nameof(apps)); + } + + if (accessTokens == null) { + throw new ArgumentNullException(nameof(accessTokens)); + } + + if (depots == null) { + throw new ArgumentNullException(nameof(depots)); + } + + SteamID = steamID; + + Apps = apps.ToImmutableDictionary(static app => app.Key.ToString(CultureInfo.InvariantCulture), static app => app.Value.ToString(CultureInfo.InvariantCulture)); + Subs = accessTokens.ToImmutableDictionary(static package => package.Key.ToString(CultureInfo.InvariantCulture), static package => package.Value.ToString(CultureInfo.InvariantCulture)); + Depots = depots.ToImmutableDictionary(static depot => depot.Key.ToString(CultureInfo.InvariantCulture), static depot => depot.Value); } } diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/ResponseData.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/ResponseData.cs index 2bfdc19c8..2a7be9dd9 100644 --- a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/ResponseData.cs +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/ResponseData.cs @@ -23,45 +23,45 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; -namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper { +namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper; + #pragma warning disable CA1812 // False positive, the class is used during json deserialization - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - internal sealed class ResponseData { +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +internal sealed class ResponseData { #pragma warning disable CS0649 // False positive, the field is used during json deserialization - [JsonProperty(PropertyName = "data", Required = Required.DisallowNull)] - internal readonly InternalData? Data; + [JsonProperty(PropertyName = "data", Required = Required.DisallowNull)] + internal readonly InternalData? Data; #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(PropertyName = "success", Required = Required.Always)] - internal readonly bool Success; + [JsonProperty(PropertyName = "success", Required = Required.Always)] + internal readonly bool Success; #pragma warning restore CS0649 // False positive, the field is used during json deserialization + [JsonConstructor] + private ResponseData() { } + + internal sealed class InternalData { + [JsonProperty(PropertyName = "new_apps", Required = Required.Always)] + internal readonly ImmutableHashSet NewApps = ImmutableHashSet.Empty; + + [JsonProperty(PropertyName = "new_depots", Required = Required.Always)] + internal readonly ImmutableHashSet NewDepots = ImmutableHashSet.Empty; + + [JsonProperty(PropertyName = "new_subs", Required = Required.Always)] + internal readonly ImmutableHashSet NewPackages = ImmutableHashSet.Empty; + + [JsonProperty(PropertyName = "verified_apps", Required = Required.Always)] + internal readonly ImmutableHashSet VerifiedApps = ImmutableHashSet.Empty; + + [JsonProperty(PropertyName = "verified_depots", Required = Required.Always)] + internal readonly ImmutableHashSet VerifiedDepots = ImmutableHashSet.Empty; + + [JsonProperty(PropertyName = "verified_subs", Required = Required.Always)] + internal readonly ImmutableHashSet VerifiedPackages = ImmutableHashSet.Empty; + [JsonConstructor] - private ResponseData() { } - - internal sealed class InternalData { - [JsonProperty(PropertyName = "new_apps", Required = Required.Always)] - internal readonly ImmutableHashSet NewApps = ImmutableHashSet.Empty; - - [JsonProperty(PropertyName = "new_depots", Required = Required.Always)] - internal readonly ImmutableHashSet NewDepots = ImmutableHashSet.Empty; - - [JsonProperty(PropertyName = "new_subs", Required = Required.Always)] - internal readonly ImmutableHashSet NewPackages = ImmutableHashSet.Empty; - - [JsonProperty(PropertyName = "verified_apps", Required = Required.Always)] - internal readonly ImmutableHashSet VerifiedApps = ImmutableHashSet.Empty; - - [JsonProperty(PropertyName = "verified_depots", Required = Required.Always)] - internal readonly ImmutableHashSet VerifiedDepots = ImmutableHashSet.Empty; - - [JsonProperty(PropertyName = "verified_subs", Required = Required.Always)] - internal readonly ImmutableHashSet VerifiedPackages = ImmutableHashSet.Empty; - - [JsonConstructor] - private InternalData() { } - } + private InternalData() { } } -#pragma warning restore CA1812 // False positive, the class is used during json deserialization } +#pragma warning restore CA1812 // False positive, the class is used during json deserialization diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SharedInfo.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SharedInfo.cs index 32e521628..14c530005 100644 --- a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SharedInfo.cs +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SharedInfo.cs @@ -19,17 +19,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper { - internal static class SharedInfo { - internal const byte ApiVersion = 2; - internal const byte AppInfosPerSingleRequest = byte.MaxValue; - internal const byte MaximumHoursBetweenRefresh = 8; // Per single bot account, makes sense to be 2 or 3 times less than MinimumHoursBetweenUploads - internal const byte MaximumMinutesBeforeFirstUpload = 60; // Must be greater or equal to MinimumMinutesBeforeFirstUpload - internal const byte MinimumHoursBetweenUploads = 24; - internal const byte MinimumMinutesBeforeFirstUpload = 10; // Must be less or equal to MaximumMinutesBeforeFirstUpload - internal const string ServerURL = "https://asf-token-dumper.xpaw.me"; - internal const string Token = "STEAM_TOKEN_DUMPER_TOKEN"; // This is filled automatically during our CI build with API key provided by xPaw for ASF project +namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper; - internal static bool HasValidToken => Token.Length == 128; - } +internal static class SharedInfo { + internal const byte ApiVersion = 2; + internal const byte AppInfosPerSingleRequest = byte.MaxValue; + internal const byte MaximumHoursBetweenRefresh = 8; // Per single bot account, makes sense to be 2 or 3 times less than MinimumHoursBetweenUploads + internal const byte MaximumMinutesBeforeFirstUpload = 60; // Must be greater or equal to MinimumMinutesBeforeFirstUpload + internal const byte MinimumHoursBetweenUploads = 24; + internal const byte MinimumMinutesBeforeFirstUpload = 10; // Must be less or equal to MaximumMinutesBeforeFirstUpload + internal const string ServerURL = "https://asf-token-dumper.xpaw.me"; + internal const string Token = "STEAM_TOKEN_DUMPER_TOKEN"; // This is filled automatically during our CI build with API key provided by xPaw for ASF project + + internal static bool HasValidToken => Token.Length == 128; } diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperConfig.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperConfig.cs index 202c07648..f040d55b9 100644 --- a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperConfig.cs +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperConfig.cs @@ -24,28 +24,28 @@ using System.Diagnostics.CodeAnalysis; using ArchiSteamFarm.IPC.Integration; using Newtonsoft.Json; -namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public sealed class SteamTokenDumperConfig { - [JsonProperty(Required = Required.DisallowNull)] - public bool Enabled { get; internal set; } +namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper; - [JsonProperty(Required = Required.DisallowNull)] - [SwaggerItemsMinMax(MinimumUint = 1, MaximumUint = uint.MaxValue)] - public ImmutableHashSet SecretAppIDs { get; private set; } = ImmutableHashSet.Empty; +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class SteamTokenDumperConfig { + [JsonProperty(Required = Required.DisallowNull)] + public bool Enabled { get; internal set; } - [JsonProperty(Required = Required.DisallowNull)] - [SwaggerItemsMinMax(MinimumUint = 1, MaximumUint = uint.MaxValue)] - public ImmutableHashSet SecretDepotIDs { get; private set; } = ImmutableHashSet.Empty; + [JsonProperty(Required = Required.DisallowNull)] + [SwaggerItemsMinMax(MinimumUint = 1, MaximumUint = uint.MaxValue)] + public ImmutableHashSet SecretAppIDs { get; private set; } = ImmutableHashSet.Empty; - [JsonProperty(Required = Required.DisallowNull)] - [SwaggerItemsMinMax(MinimumUint = 1, MaximumUint = uint.MaxValue)] - public ImmutableHashSet SecretPackageIDs { get; private set; } = ImmutableHashSet.Empty; + [JsonProperty(Required = Required.DisallowNull)] + [SwaggerItemsMinMax(MinimumUint = 1, MaximumUint = uint.MaxValue)] + public ImmutableHashSet SecretDepotIDs { get; private set; } = ImmutableHashSet.Empty; - [JsonProperty(Required = Required.DisallowNull)] - public bool SkipAutoGrantPackages { get; private set; } + [JsonProperty(Required = Required.DisallowNull)] + [SwaggerItemsMinMax(MinimumUint = 1, MaximumUint = uint.MaxValue)] + public ImmutableHashSet SecretPackageIDs { get; private set; } = ImmutableHashSet.Empty; - [JsonConstructor] - internal SteamTokenDumperConfig() { } - } + [JsonProperty(Required = Required.DisallowNull)] + public bool SkipAutoGrantPackages { get; private set; } + + [JsonConstructor] + internal SteamTokenDumperConfig() { } } diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperController.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperController.cs index e2e6d20b3..ca6865e6a 100644 --- a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperController.cs +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperController.cs @@ -24,12 +24,12 @@ using ArchiSteamFarm.IPC.Controllers.Api; using Microsoft.AspNetCore.Mvc; using Swashbuckle.AspNetCore.Annotations; -namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper { - [Route("Api/SteamTokenDumperPlugin")] - public sealed class SteamTokenDumperController : ArchiController { - [HttpGet(nameof(GlobalConfigExtension))] - [ProducesResponseType(typeof(GlobalConfigExtension), (int) HttpStatusCode.OK)] - [SwaggerOperation(Tags = new[] { nameof(GlobalConfigExtension) })] - public ActionResult Get() => Ok(new GlobalConfigExtension()); - } +namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper; + +[Route("Api/SteamTokenDumperPlugin")] +public sealed class SteamTokenDumperController : ArchiController { + [HttpGet(nameof(GlobalConfigExtension))] + [ProducesResponseType(typeof(GlobalConfigExtension), (int) HttpStatusCode.OK)] + [SwaggerOperation(Tags = new[] { nameof(GlobalConfigExtension) })] + public ActionResult Get() => Ok(new GlobalConfigExtension()); } diff --git a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperPlugin.cs b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperPlugin.cs index 144396147..bb9165576 100644 --- a/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperPlugin.cs +++ b/ArchiSteamFarm.OfficialPlugins.SteamTokenDumper/SteamTokenDumperPlugin.cs @@ -40,537 +40,537 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using SteamKit2; -namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper { - [Export(typeof(IPlugin))] - internal sealed class SteamTokenDumperPlugin : OfficialPlugin, IASF, IBot, IBotSteamClient, ISteamPICSChanges { - [JsonProperty] - internal static SteamTokenDumperConfig? Config { get; private set; } +namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper; - private static readonly ConcurrentDictionary BotSubscriptions = new(); - private static readonly ConcurrentDictionary BotSynchronizations = new(); - private static readonly SemaphoreSlim SubmissionSemaphore = new(1, 1); - private static readonly Timer SubmissionTimer = new(SubmitData); +[Export(typeof(IPlugin))] +internal sealed class SteamTokenDumperPlugin : OfficialPlugin, IASF, IBot, IBotSteamClient, ISteamPICSChanges { + [JsonProperty] + internal static SteamTokenDumperConfig? Config { get; private set; } - private static GlobalCache? GlobalCache; + private static readonly ConcurrentDictionary BotSubscriptions = new(); + private static readonly ConcurrentDictionary BotSynchronizations = new(); + private static readonly SemaphoreSlim SubmissionSemaphore = new(1, 1); + private static readonly Timer SubmissionTimer = new(SubmitData); - [JsonProperty] - public override string Name => nameof(SteamTokenDumperPlugin); + private static GlobalCache? GlobalCache; - [JsonProperty] - public override Version Version => typeof(SteamTokenDumperPlugin).Assembly.GetName().Version ?? throw new InvalidOperationException(nameof(Version)); + [JsonProperty] + public override string Name => nameof(SteamTokenDumperPlugin); - public Task GetPreferredChangeNumberToStartFrom() => Task.FromResult(Config?.Enabled == true ? GlobalCache?.LastChangeNumber ?? 0 : 0); + [JsonProperty] + public override Version Version => typeof(SteamTokenDumperPlugin).Assembly.GetName().Version ?? throw new InvalidOperationException(nameof(Version)); - public async void OnASFInit(IReadOnlyDictionary? additionalConfigProperties = null) { - if (!SharedInfo.HasValidToken) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.PluginDisabledMissingBuildToken, nameof(SteamTokenDumperPlugin))); + public Task GetPreferredChangeNumberToStartFrom() => Task.FromResult(Config?.Enabled == true ? GlobalCache?.LastChangeNumber ?? 0 : 0); + + public async void OnASFInit(IReadOnlyDictionary? additionalConfigProperties = null) { + if (!SharedInfo.HasValidToken) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.PluginDisabledMissingBuildToken, nameof(SteamTokenDumperPlugin))); + + return; + } + + bool isEnabled = false; + SteamTokenDumperConfig? config = null; + + if (additionalConfigProperties != null) { + foreach ((string configProperty, JToken configValue) in additionalConfigProperties) { + try { + switch (configProperty) { + case nameof(GlobalConfigExtension.SteamTokenDumperPlugin): + config = configValue.ToObject(); + + break; + case nameof(GlobalConfigExtension.SteamTokenDumperPluginEnabled): + isEnabled = configValue.Value(); + + break; + } + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.PluginDisabledInConfig, nameof(SteamTokenDumperPlugin))); + + return; + } + } + } + + config ??= new SteamTokenDumperConfig(); + + if (isEnabled) { + config.Enabled = true; + } + + Config = config; + + if (!config.Enabled) { + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginDisabledInConfig, nameof(SteamTokenDumperPlugin))); + + return; + } + + if (!config.SecretAppIDs.IsEmpty) { + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginSecretListInitialized, nameof(config.SecretAppIDs), string.Join(", ", config.SecretAppIDs))); + } + + if (!config.SecretPackageIDs.IsEmpty) { + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginSecretListInitialized, nameof(config.SecretPackageIDs), string.Join(", ", config.SecretPackageIDs))); + } + + if (!config.SecretDepotIDs.IsEmpty) { + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginSecretListInitialized, nameof(config.SecretDepotIDs), string.Join(", ", config.SecretDepotIDs))); + } + + if (GlobalCache == null) { + GlobalCache? globalCache = await GlobalCache.Load().ConfigureAwait(false); + + if (globalCache == null) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.FileCouldNotBeLoadedFreshInit, nameof(GlobalCache))); + + GlobalCache = new GlobalCache(); + } else { + GlobalCache = globalCache; + } + } + + TimeSpan startIn = TimeSpan.FromMinutes(Utilities.RandomNext(SharedInfo.MinimumMinutesBeforeFirstUpload, SharedInfo.MaximumMinutesBeforeFirstUpload)); + + // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that + lock (SubmissionSemaphore) { + SubmissionTimer.Change(startIn, TimeSpan.FromHours(SharedInfo.MinimumHoursBetweenUploads)); + } + + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginInitializedAndEnabled, nameof(SteamTokenDumperPlugin), startIn.ToHumanReadable())); + } + + public async void OnBotDestroy(Bot bot) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if (BotSubscriptions.TryRemove(bot, out IDisposable? subscription)) { + subscription.Dispose(); + } + + if (BotSynchronizations.TryRemove(bot, out (SemaphoreSlim RefreshSemaphore, Timer RefreshTimer) synchronization)) { + synchronization.RefreshSemaphore.Dispose(); + + await synchronization.RefreshTimer.DisposeAsync().ConfigureAwait(false); + } + } + + public async void OnBotInit(Bot bot) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if (Config is not { Enabled: true }) { + return; + } + + SemaphoreSlim refreshSemaphore = new(1, 1); + Timer refreshTimer = new(OnBotRefreshTimer, bot, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + + if (!BotSynchronizations.TryAdd(bot, (refreshSemaphore, refreshTimer))) { + refreshSemaphore.Dispose(); + + await refreshTimer.DisposeAsync().ConfigureAwait(false); + } + } + + public void OnBotSteamCallbacksInit(Bot bot, CallbackManager callbackManager) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if (callbackManager == null) { + throw new ArgumentNullException(nameof(callbackManager)); + } + + if (BotSubscriptions.TryRemove(bot, out IDisposable? subscription)) { + subscription.Dispose(); + } + + if (Config is not { Enabled: true }) { + return; + } + + subscription = callbackManager.Subscribe(callback => OnLicenseList(bot, callback)); + + if (!BotSubscriptions.TryAdd(bot, subscription)) { + subscription.Dispose(); + } + } + + public IReadOnlyCollection? OnBotSteamHandlersInit(Bot bot) => null; + + public override void OnLoaded() => Utilities.WarnAboutIncompleteTranslation(Strings.ResourceManager); + + public void OnPICSChanges(uint currentChangeNumber, IReadOnlyDictionary appChanges, IReadOnlyDictionary packageChanges) { + if (currentChangeNumber == 0) { + throw new ArgumentOutOfRangeException(nameof(currentChangeNumber)); + } + + if (appChanges == null) { + throw new ArgumentNullException(nameof(appChanges)); + } + + if (packageChanges == null) { + throw new ArgumentNullException(nameof(packageChanges)); + } + + if (Config is not { Enabled: true }) { + return; + } + + if (GlobalCache == null) { + throw new InvalidOperationException(nameof(GlobalCache)); + } + + GlobalCache.OnPICSChanges(currentChangeNumber, appChanges); + } + + public void OnPICSChangesRestart(uint currentChangeNumber) { + if (currentChangeNumber == 0) { + throw new ArgumentOutOfRangeException(nameof(currentChangeNumber)); + } + + if (Config is not { Enabled: true }) { + return; + } + + if (GlobalCache == null) { + throw new InvalidOperationException(nameof(GlobalCache)); + } + + GlobalCache.OnPICSChangesRestart(currentChangeNumber); + } + + private static async void OnBotRefreshTimer(object? state) { + if (state is not Bot bot) { + throw new InvalidOperationException(nameof(state)); + } + + await Refresh(bot).ConfigureAwait(false); + } + + private static async void OnLicenseList(Bot bot, SteamApps.LicenseListCallback callback) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); + } + + if (Config is not { Enabled: true }) { + return; + } + + if (GlobalCache == null) { + throw new InvalidOperationException(nameof(GlobalCache)); + } + + Dictionary packageTokens = callback.LicenseList.Where(static license => !Config.SecretPackageIDs.Contains(license.PackageID) && ((license.PaymentMethod != EPaymentMethod.AutoGrant) || !Config.SkipAutoGrantPackages)).GroupBy(static license => license.PackageID).ToDictionary(static group => group.Key, static group => group.OrderByDescending(static license => license.TimeCreated).First().AccessToken); + + GlobalCache.UpdatePackageTokens(packageTokens); + + await Refresh(bot, packageTokens.Keys).ConfigureAwait(false); + } + + private static async Task Refresh(Bot bot, IReadOnlyCollection? packageIDs = null) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if (Config is not { Enabled: true }) { + return; + } + + if (GlobalCache == null) { + throw new InvalidOperationException(nameof(GlobalCache)); + } + + if (ASF.GlobalDatabase == null) { + throw new InvalidOperationException(nameof(GlobalCache)); + } + + if (!BotSynchronizations.TryGetValue(bot, out (SemaphoreSlim RefreshSemaphore, Timer RefreshTimer) synchronization)) { + throw new InvalidOperationException(nameof(synchronization)); + } + + if (!await synchronization.RefreshSemaphore.WaitAsync(0).ConfigureAwait(false)) { + return; + } + + try { + if (!bot.IsConnectedAndLoggedOn) { + return; + } + + packageIDs ??= bot.OwnedPackageIDs.Where(static package => !Config.SecretPackageIDs.Contains(package.Key) && ((package.Value.PaymentMethod != EPaymentMethod.AutoGrant) || !Config.SkipAutoGrantPackages)).Select(static package => package.Key).ToHashSet(); + + HashSet appIDsToRefresh = new(); + + foreach (uint packageID in packageIDs.Where(static packageID => !Config.SecretPackageIDs.Contains(packageID))) { + if (!ASF.GlobalDatabase.PackagesDataReadOnly.TryGetValue(packageID, out (uint ChangeNumber, ImmutableHashSet? AppIDs) packageData) || (packageData.AppIDs == null)) { + // ASF might not have the package info for us at the moment, we'll retry later + continue; + } + + appIDsToRefresh.UnionWith(packageData.AppIDs.Where(static appID => !Config.SecretAppIDs.Contains(appID) && GlobalCache.ShouldRefreshAppInfo(appID))); + } + + if (appIDsToRefresh.Count == 0) { + bot.ArchiLogger.LogGenericDebug(Strings.BotNoAppsToRefresh); return; } - bool isEnabled = false; - SteamTokenDumperConfig? config = null; + bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRetrievingTotalAppAccessTokens, appIDsToRefresh.Count)); + + HashSet appIDsThisRound = new(Math.Min(appIDsToRefresh.Count, SharedInfo.AppInfosPerSingleRequest)); + + using (HashSet.Enumerator enumerator = appIDsToRefresh.GetEnumerator()) { + while (true) { + while ((appIDsThisRound.Count < SharedInfo.AppInfosPerSingleRequest) && enumerator.MoveNext()) { + appIDsThisRound.Add(enumerator.Current); + } + + if (appIDsThisRound.Count == 0) { + break; + } + + if (!bot.IsConnectedAndLoggedOn) { + return; + } + + bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRetrievingAppAccessTokens, appIDsThisRound.Count)); + + SteamApps.PICSTokensCallback response; - if (additionalConfigProperties != null) { - foreach ((string configProperty, JToken configValue) in additionalConfigProperties) { try { - switch (configProperty) { - case nameof(GlobalConfigExtension.SteamTokenDumperPlugin): - config = configValue.ToObject(); - - break; - case nameof(GlobalConfigExtension.SteamTokenDumperPluginEnabled): - isEnabled = configValue.Value(); - - break; - } + response = await bot.SteamApps.PICSGetAccessTokens(appIDsThisRound, Enumerable.Empty()).ToLongRunningTask().ConfigureAwait(false); } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.PluginDisabledInConfig, nameof(SteamTokenDumperPlugin))); + bot.ArchiLogger.LogGenericWarningException(e); return; } + + bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotFinishedRetrievingAppAccessTokens, appIDsThisRound.Count)); + + appIDsThisRound.Clear(); + + GlobalCache.UpdateAppTokens(response.AppTokens, response.AppTokensDenied); } } - config ??= new SteamTokenDumperConfig(); + bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotFinishedRetrievingTotalAppAccessTokens, appIDsToRefresh.Count)); + bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRetrievingTotalDepots, appIDsToRefresh.Count)); - if (isEnabled) { - config.Enabled = true; - } - - Config = config; - - if (!config.Enabled) { - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginDisabledInConfig, nameof(SteamTokenDumperPlugin))); - - return; - } - - if (!config.SecretAppIDs.IsEmpty) { - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginSecretListInitialized, nameof(config.SecretAppIDs), string.Join(", ", config.SecretAppIDs))); - } - - if (!config.SecretPackageIDs.IsEmpty) { - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginSecretListInitialized, nameof(config.SecretPackageIDs), string.Join(", ", config.SecretPackageIDs))); - } - - if (!config.SecretDepotIDs.IsEmpty) { - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginSecretListInitialized, nameof(config.SecretDepotIDs), string.Join(", ", config.SecretDepotIDs))); - } - - if (GlobalCache == null) { - GlobalCache? globalCache = await GlobalCache.Load().ConfigureAwait(false); - - if (globalCache == null) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.FileCouldNotBeLoadedFreshInit, nameof(GlobalCache))); - - GlobalCache = new GlobalCache(); - } else { - GlobalCache = globalCache; - } - } - - TimeSpan startIn = TimeSpan.FromMinutes(Utilities.RandomNext(SharedInfo.MinimumMinutesBeforeFirstUpload, SharedInfo.MaximumMinutesBeforeFirstUpload)); - - // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that - lock (SubmissionSemaphore) { - SubmissionTimer.Change(startIn, TimeSpan.FromHours(SharedInfo.MinimumHoursBetweenUploads)); - } - - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginInitializedAndEnabled, nameof(SteamTokenDumperPlugin), startIn.ToHumanReadable())); - } - - public async void OnBotDestroy(Bot bot) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if (BotSubscriptions.TryRemove(bot, out IDisposable? subscription)) { - subscription.Dispose(); - } - - if (BotSynchronizations.TryRemove(bot, out (SemaphoreSlim RefreshSemaphore, Timer RefreshTimer) synchronization)) { - synchronization.RefreshSemaphore.Dispose(); - - await synchronization.RefreshTimer.DisposeAsync().ConfigureAwait(false); - } - } - - public async void OnBotInit(Bot bot) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if (Config is not { Enabled: true }) { - return; - } - - SemaphoreSlim refreshSemaphore = new(1, 1); - Timer refreshTimer = new(OnBotRefreshTimer, bot, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - - if (!BotSynchronizations.TryAdd(bot, (refreshSemaphore, refreshTimer))) { - refreshSemaphore.Dispose(); - - await refreshTimer.DisposeAsync().ConfigureAwait(false); - } - } - - public void OnBotSteamCallbacksInit(Bot bot, CallbackManager callbackManager) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if (callbackManager == null) { - throw new ArgumentNullException(nameof(callbackManager)); - } - - if (BotSubscriptions.TryRemove(bot, out IDisposable? subscription)) { - subscription.Dispose(); - } - - if (Config is not { Enabled: true }) { - return; - } - - subscription = callbackManager.Subscribe(callback => OnLicenseList(bot, callback)); - - if (!BotSubscriptions.TryAdd(bot, subscription)) { - subscription.Dispose(); - } - } - - public IReadOnlyCollection? OnBotSteamHandlersInit(Bot bot) => null; - - public override void OnLoaded() => Utilities.WarnAboutIncompleteTranslation(Strings.ResourceManager); - - public void OnPICSChanges(uint currentChangeNumber, IReadOnlyDictionary appChanges, IReadOnlyDictionary packageChanges) { - if (currentChangeNumber == 0) { - throw new ArgumentOutOfRangeException(nameof(currentChangeNumber)); - } - - if (appChanges == null) { - throw new ArgumentNullException(nameof(appChanges)); - } - - if (packageChanges == null) { - throw new ArgumentNullException(nameof(packageChanges)); - } - - if (Config is not { Enabled: true }) { - return; - } - - if (GlobalCache == null) { - throw new InvalidOperationException(nameof(GlobalCache)); - } - - GlobalCache.OnPICSChanges(currentChangeNumber, appChanges); - } - - public void OnPICSChangesRestart(uint currentChangeNumber) { - if (currentChangeNumber == 0) { - throw new ArgumentOutOfRangeException(nameof(currentChangeNumber)); - } - - if (Config is not { Enabled: true }) { - return; - } - - if (GlobalCache == null) { - throw new InvalidOperationException(nameof(GlobalCache)); - } - - GlobalCache.OnPICSChangesRestart(currentChangeNumber); - } - - private static async void OnBotRefreshTimer(object? state) { - if (state is not Bot bot) { - throw new InvalidOperationException(nameof(state)); - } - - await Refresh(bot).ConfigureAwait(false); - } - - private static async void OnLicenseList(Bot bot, SteamApps.LicenseListCallback callback) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } - - if (Config is not { Enabled: true }) { - return; - } - - if (GlobalCache == null) { - throw new InvalidOperationException(nameof(GlobalCache)); - } - - Dictionary packageTokens = callback.LicenseList.Where(static license => !Config.SecretPackageIDs.Contains(license.PackageID) && ((license.PaymentMethod != EPaymentMethod.AutoGrant) || !Config.SkipAutoGrantPackages)).GroupBy(static license => license.PackageID).ToDictionary(static group => group.Key, static group => group.OrderByDescending(static license => license.TimeCreated).First().AccessToken); - - GlobalCache.UpdatePackageTokens(packageTokens); - - await Refresh(bot, packageTokens.Keys).ConfigureAwait(false); - } - - private static async Task Refresh(Bot bot, IReadOnlyCollection? packageIDs = null) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if (Config is not { Enabled: true }) { - return; - } - - if (GlobalCache == null) { - throw new InvalidOperationException(nameof(GlobalCache)); - } - - if (ASF.GlobalDatabase == null) { - throw new InvalidOperationException(nameof(GlobalCache)); - } - - if (!BotSynchronizations.TryGetValue(bot, out (SemaphoreSlim RefreshSemaphore, Timer RefreshTimer) synchronization)) { - throw new InvalidOperationException(nameof(synchronization)); - } - - if (!await synchronization.RefreshSemaphore.WaitAsync(0).ConfigureAwait(false)) { - return; - } - - try { - if (!bot.IsConnectedAndLoggedOn) { - return; - } - - packageIDs ??= bot.OwnedPackageIDs.Where(static package => !Config.SecretPackageIDs.Contains(package.Key) && ((package.Value.PaymentMethod != EPaymentMethod.AutoGrant) || !Config.SkipAutoGrantPackages)).Select(static package => package.Key).ToHashSet(); - - HashSet appIDsToRefresh = new(); - - foreach (uint packageID in packageIDs.Where(static packageID => !Config.SecretPackageIDs.Contains(packageID))) { - if (!ASF.GlobalDatabase.PackagesDataReadOnly.TryGetValue(packageID, out (uint ChangeNumber, ImmutableHashSet? AppIDs) packageData) || (packageData.AppIDs == null)) { - // ASF might not have the package info for us at the moment, we'll retry later - continue; + using (HashSet.Enumerator enumerator = appIDsToRefresh.GetEnumerator()) { + while (true) { + while ((appIDsThisRound.Count < SharedInfo.AppInfosPerSingleRequest) && enumerator.MoveNext()) { + appIDsThisRound.Add(enumerator.Current); } - appIDsToRefresh.UnionWith(packageData.AppIDs.Where(static appID => !Config.SecretAppIDs.Contains(appID) && GlobalCache.ShouldRefreshAppInfo(appID))); - } + if (appIDsThisRound.Count == 0) { + break; + } - if (appIDsToRefresh.Count == 0) { - bot.ArchiLogger.LogGenericDebug(Strings.BotNoAppsToRefresh); + if (!bot.IsConnectedAndLoggedOn) { + return; + } - return; - } + bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRetrievingAppInfos, appIDsThisRound.Count)); - bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRetrievingTotalAppAccessTokens, appIDsToRefresh.Count)); + AsyncJobMultiple.ResultSet response; - HashSet appIDsThisRound = new(Math.Min(appIDsToRefresh.Count, SharedInfo.AppInfosPerSingleRequest)); + try { + response = await bot.SteamApps.PICSGetProductInfo(appIDsThisRound.Select(static appID => new SteamApps.PICSRequest(appID, GlobalCache.GetAppToken(appID))), Enumerable.Empty()).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + bot.ArchiLogger.LogGenericWarningException(e); - using (HashSet.Enumerator enumerator = appIDsToRefresh.GetEnumerator()) { - while (true) { - while ((appIDsThisRound.Count < SharedInfo.AppInfosPerSingleRequest) && enumerator.MoveNext()) { - appIDsThisRound.Add(enumerator.Current); + return; + } + + if (response.Results == null) { + bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, ArchiSteamFarm.Localization.Strings.WarningFailedWithError, nameof(response.Results))); + + return; + } + + bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotFinishedRetrievingAppInfos, appIDsThisRound.Count)); + + appIDsThisRound.Clear(); + + Dictionary appChangeNumbers = new(); + + HashSet> depotTasks = new(); + + foreach (SteamApps.PICSProductInfoCallback.PICSProductInfo app in response.Results.SelectMany(static result => result.Apps.Values)) { + appChangeNumbers[app.ID] = app.ChangeNumber; + + if (GlobalCache.ShouldRefreshDepotKey(app.ID)) { + depotTasks.Add(bot.SteamApps.GetDepotDecryptionKey(app.ID, app.ID).ToLongRunningTask()); } - if (appIDsThisRound.Count == 0) { - break; + foreach (KeyValue depot in app.KeyValues["depots"].Children) { + if (uint.TryParse(depot.Name, out uint depotID) && !Config.SecretDepotIDs.Contains(depotID) && GlobalCache.ShouldRefreshDepotKey(depotID)) { + depotTasks.Add(bot.SteamApps.GetDepotDecryptionKey(depotID, app.ID).ToLongRunningTask()); + } } + } - if (!bot.IsConnectedAndLoggedOn) { - return; - } + GlobalCache.UpdateAppChangeNumbers(appChangeNumbers); - bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRetrievingAppAccessTokens, appIDsThisRound.Count)); + if (depotTasks.Count > 0) { + bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRetrievingDepotKeys, depotTasks.Count)); - SteamApps.PICSTokensCallback response; + IList results; try { - response = await bot.SteamApps.PICSGetAccessTokens(appIDsThisRound, Enumerable.Empty()).ToLongRunningTask().ConfigureAwait(false); + results = await Utilities.InParallel(depotTasks).ConfigureAwait(false); } catch (Exception e) { bot.ArchiLogger.LogGenericWarningException(e); return; } - bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotFinishedRetrievingAppAccessTokens, appIDsThisRound.Count)); + bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotFinishedRetrievingDepotKeys, depotTasks.Count)); - appIDsThisRound.Clear(); - - GlobalCache.UpdateAppTokens(response.AppTokens, response.AppTokensDenied); + GlobalCache.UpdateDepotKeys(results); } } - - bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotFinishedRetrievingTotalAppAccessTokens, appIDsToRefresh.Count)); - bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRetrievingTotalDepots, appIDsToRefresh.Count)); - - using (HashSet.Enumerator enumerator = appIDsToRefresh.GetEnumerator()) { - while (true) { - while ((appIDsThisRound.Count < SharedInfo.AppInfosPerSingleRequest) && enumerator.MoveNext()) { - appIDsThisRound.Add(enumerator.Current); - } - - if (appIDsThisRound.Count == 0) { - break; - } - - if (!bot.IsConnectedAndLoggedOn) { - return; - } - - bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRetrievingAppInfos, appIDsThisRound.Count)); - - AsyncJobMultiple.ResultSet response; - - try { - response = await bot.SteamApps.PICSGetProductInfo(appIDsThisRound.Select(static appID => new SteamApps.PICSRequest(appID, GlobalCache.GetAppToken(appID))), Enumerable.Empty()).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - bot.ArchiLogger.LogGenericWarningException(e); - - return; - } - - if (response.Results == null) { - bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, ArchiSteamFarm.Localization.Strings.WarningFailedWithError, nameof(response.Results))); - - return; - } - - bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotFinishedRetrievingAppInfos, appIDsThisRound.Count)); - - appIDsThisRound.Clear(); - - Dictionary appChangeNumbers = new(); - - HashSet> depotTasks = new(); - - foreach (SteamApps.PICSProductInfoCallback.PICSProductInfo app in response.Results.SelectMany(static result => result.Apps.Values)) { - appChangeNumbers[app.ID] = app.ChangeNumber; - - if (GlobalCache.ShouldRefreshDepotKey(app.ID)) { - depotTasks.Add(bot.SteamApps.GetDepotDecryptionKey(app.ID, app.ID).ToLongRunningTask()); - } - - foreach (KeyValue depot in app.KeyValues["depots"].Children) { - if (uint.TryParse(depot.Name, out uint depotID) && !Config.SecretDepotIDs.Contains(depotID) && GlobalCache.ShouldRefreshDepotKey(depotID)) { - depotTasks.Add(bot.SteamApps.GetDepotDecryptionKey(depotID, app.ID).ToLongRunningTask()); - } - } - } - - GlobalCache.UpdateAppChangeNumbers(appChangeNumbers); - - if (depotTasks.Count > 0) { - bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRetrievingDepotKeys, depotTasks.Count)); - - IList results; - - try { - results = await Utilities.InParallel(depotTasks).ConfigureAwait(false); - } catch (Exception e) { - bot.ArchiLogger.LogGenericWarningException(e); - - return; - } - - bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotFinishedRetrievingDepotKeys, depotTasks.Count)); - - GlobalCache.UpdateDepotKeys(results); - } - } - } - - bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotFinishedRetrievingTotalDepots, appIDsToRefresh.Count)); - } finally { - TimeSpan timeSpan = TimeSpan.FromHours(SharedInfo.MaximumHoursBetweenRefresh); - - synchronization.RefreshTimer.Change(timeSpan, timeSpan); - synchronization.RefreshSemaphore.Release(); } + + bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotFinishedRetrievingTotalDepots, appIDsToRefresh.Count)); + } finally { + TimeSpan timeSpan = TimeSpan.FromHours(SharedInfo.MaximumHoursBetweenRefresh); + + synchronization.RefreshTimer.Change(timeSpan, timeSpan); + synchronization.RefreshSemaphore.Release(); + } + } + + private static async void SubmitData(object? state = null) { + if (Bot.Bots == null) { + throw new InvalidOperationException(nameof(Bot.Bots)); } - private static async void SubmitData(object? state = null) { - if (Bot.Bots == null) { - throw new InvalidOperationException(nameof(Bot.Bots)); - } + if (Config is not { Enabled: true }) { + return; + } + + if (GlobalCache == null) { + throw new InvalidOperationException(nameof(GlobalCache)); + } + + if (ASF.GlobalConfig == null) { + throw new InvalidOperationException(nameof(ASF.GlobalConfig)); + } + + if (ASF.WebBrowser == null) { + throw new InvalidOperationException(nameof(ASF.WebBrowser)); + } + + if (!await SubmissionSemaphore.WaitAsync(0).ConfigureAwait(false)) { + return; + } + + try { + Dictionary appTokens = GlobalCache.GetAppTokensForSubmission(); + Dictionary packageTokens = GlobalCache.GetPackageTokensForSubmission(); + Dictionary depotKeys = GlobalCache.GetDepotKeysForSubmission(); + + if ((appTokens.Count == 0) && (packageTokens.Count == 0) && (depotKeys.Count == 0)) { + ASF.ArchiLogger.LogGenericInfo(Strings.SubmissionNoNewData); - if (Config is not { Enabled: true }) { return; } - if (GlobalCache == null) { - throw new InvalidOperationException(nameof(GlobalCache)); - } + ulong contributorSteamID = (ASF.GlobalConfig.SteamOwnerID > 0) && new SteamID(ASF.GlobalConfig.SteamOwnerID).IsIndividualAccount ? ASF.GlobalConfig.SteamOwnerID : Bot.Bots.Values.Where(static bot => bot.SteamID > 0).OrderByDescending(static bot => bot.OwnedPackageIDs.Count).FirstOrDefault()?.SteamID ?? 0; - if (ASF.GlobalConfig == null) { - throw new InvalidOperationException(nameof(ASF.GlobalConfig)); - } + if (contributorSteamID == 0) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionNoContributorSet, nameof(ASF.GlobalConfig.SteamOwnerID))); - if (ASF.WebBrowser == null) { - throw new InvalidOperationException(nameof(ASF.WebBrowser)); - } - - if (!await SubmissionSemaphore.WaitAsync(0).ConfigureAwait(false)) { return; } - try { - Dictionary appTokens = GlobalCache.GetAppTokensForSubmission(); - Dictionary packageTokens = GlobalCache.GetPackageTokensForSubmission(); - Dictionary depotKeys = GlobalCache.GetDepotKeysForSubmission(); + Uri request = new($"{SharedInfo.ServerURL}/submit"); + RequestData requestData = new(contributorSteamID, appTokens, packageTokens, depotKeys); - if ((appTokens.Count == 0) && (packageTokens.Count == 0) && (depotKeys.Count == 0)) { - ASF.ArchiLogger.LogGenericInfo(Strings.SubmissionNoNewData); + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionInProgress, appTokens.Count, packageTokens.Count, depotKeys.Count)); - return; - } + ObjectResponse? response = await ASF.WebBrowser.UrlPostToJsonObject(request, data: requestData, requestOptions: WebBrowser.ERequestOptions.ReturnClientErrors).ConfigureAwait(false); - ulong contributorSteamID = (ASF.GlobalConfig.SteamOwnerID > 0) && new SteamID(ASF.GlobalConfig.SteamOwnerID).IsIndividualAccount ? ASF.GlobalConfig.SteamOwnerID : Bot.Bots.Values.Where(static bot => bot.SteamID > 0).OrderByDescending(static bot => bot.OwnedPackageIDs.Count).FirstOrDefault()?.SteamID ?? 0; + if (response == null) { + ASF.ArchiLogger.LogGenericWarning(ArchiSteamFarm.Localization.Strings.WarningFailed); - if (contributorSteamID == 0) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionNoContributorSet, nameof(ASF.GlobalConfig.SteamOwnerID))); + return; + } - return; - } - - Uri request = new($"{SharedInfo.ServerURL}/submit"); - RequestData requestData = new(contributorSteamID, appTokens, packageTokens, depotKeys); - - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionInProgress, appTokens.Count, packageTokens.Count, depotKeys.Count)); - - ObjectResponse? response = await ASF.WebBrowser.UrlPostToJsonObject(request, data: requestData, requestOptions: WebBrowser.ERequestOptions.ReturnClientErrors).ConfigureAwait(false); - - if (response == null) { - ASF.ArchiLogger.LogGenericWarning(ArchiSteamFarm.Localization.Strings.WarningFailed); - - return; - } - - if (response.StatusCode.IsClientErrorCode()) { - ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, ArchiSteamFarm.Localization.Strings.WarningFailedWithError, response.StatusCode)); + if (response.StatusCode.IsClientErrorCode()) { + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, ArchiSteamFarm.Localization.Strings.WarningFailedWithError, response.StatusCode)); #if NETFRAMEWORK if (response.StatusCode == (HttpStatusCode) 429) { #else - if (response.StatusCode == HttpStatusCode.TooManyRequests) { + if (response.StatusCode == HttpStatusCode.TooManyRequests) { #endif - TimeSpan startIn = TimeSpan.FromMinutes(Utilities.RandomNext(SharedInfo.MinimumMinutesBeforeFirstUpload, SharedInfo.MaximumMinutesBeforeFirstUpload)); + TimeSpan startIn = TimeSpan.FromMinutes(Utilities.RandomNext(SharedInfo.MinimumMinutesBeforeFirstUpload, SharedInfo.MaximumMinutesBeforeFirstUpload)); - // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that - lock (SubmissionSemaphore) { - SubmissionTimer.Change(startIn, TimeSpan.FromHours(SharedInfo.MinimumHoursBetweenUploads)); - } - - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionFailedTooManyRequests, startIn.ToHumanReadable())); + // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that + lock (SubmissionSemaphore) { + SubmissionTimer.Change(startIn, TimeSpan.FromHours(SharedInfo.MinimumHoursBetweenUploads)); } - return; + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionFailedTooManyRequests, startIn.ToHumanReadable())); } - if (!response.Content.Success) { - ASF.ArchiLogger.LogGenericError(ArchiSteamFarm.Localization.Strings.WarningFailed); - - return; - } - - if (response.Content.Data == null) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, ArchiSteamFarm.Localization.Strings.ErrorIsInvalid), nameof(response.Content.Data)); - - return; - } - - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionSuccessful, response.Content.Data.NewApps.Count, response.Content.Data.VerifiedApps.Count, response.Content.Data.NewPackages.Count, response.Content.Data.VerifiedPackages.Count, response.Content.Data.NewDepots.Count, response.Content.Data.VerifiedDepots.Count)); - - GlobalCache.UpdateSubmittedData(appTokens, packageTokens, depotKeys); - - if (!response.Content.Data.NewApps.IsEmpty) { - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionSuccessfulNewApps, string.Join(", ", response.Content.Data.NewApps))); - } - - if (!response.Content.Data.VerifiedApps.IsEmpty) { - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionSuccessfulVerifiedApps, string.Join(", ", response.Content.Data.VerifiedApps))); - } - - if (!response.Content.Data.NewPackages.IsEmpty) { - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionSuccessfulNewPackages, string.Join(", ", response.Content.Data.NewPackages))); - } - - if (!response.Content.Data.VerifiedPackages.IsEmpty) { - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionSuccessfulVerifiedPackages, string.Join(", ", response.Content.Data.VerifiedPackages))); - } - - if (!response.Content.Data.NewDepots.IsEmpty) { - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionSuccessfulNewDepots, string.Join(", ", response.Content.Data.NewDepots))); - } - - if (!response.Content.Data.VerifiedDepots.IsEmpty) { - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionSuccessfulVerifiedDepots, string.Join(", ", response.Content.Data.VerifiedDepots))); - } - } finally { - SubmissionSemaphore.Release(); + return; } + + if (!response.Content.Success) { + ASF.ArchiLogger.LogGenericError(ArchiSteamFarm.Localization.Strings.WarningFailed); + + return; + } + + if (response.Content.Data == null) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, ArchiSteamFarm.Localization.Strings.ErrorIsInvalid), nameof(response.Content.Data)); + + return; + } + + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionSuccessful, response.Content.Data.NewApps.Count, response.Content.Data.VerifiedApps.Count, response.Content.Data.NewPackages.Count, response.Content.Data.VerifiedPackages.Count, response.Content.Data.NewDepots.Count, response.Content.Data.VerifiedDepots.Count)); + + GlobalCache.UpdateSubmittedData(appTokens, packageTokens, depotKeys); + + if (!response.Content.Data.NewApps.IsEmpty) { + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionSuccessfulNewApps, string.Join(", ", response.Content.Data.NewApps))); + } + + if (!response.Content.Data.VerifiedApps.IsEmpty) { + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionSuccessfulVerifiedApps, string.Join(", ", response.Content.Data.VerifiedApps))); + } + + if (!response.Content.Data.NewPackages.IsEmpty) { + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionSuccessfulNewPackages, string.Join(", ", response.Content.Data.NewPackages))); + } + + if (!response.Content.Data.VerifiedPackages.IsEmpty) { + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionSuccessfulVerifiedPackages, string.Join(", ", response.Content.Data.VerifiedPackages))); + } + + if (!response.Content.Data.NewDepots.IsEmpty) { + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionSuccessfulNewDepots, string.Join(", ", response.Content.Data.NewDepots))); + } + + if (!response.Content.Data.VerifiedDepots.IsEmpty) { + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.SubmissionSuccessfulVerifiedDepots, string.Join(", ", response.Content.Data.VerifiedDepots))); + } + } finally { + SubmissionSemaphore.Release(); } } } diff --git a/ArchiSteamFarm.Tests/Bot.cs b/ArchiSteamFarm.Tests/Bot.cs index 6a9d44a8e..319c5f2a9 100644 --- a/ArchiSteamFarm.Tests/Bot.cs +++ b/ArchiSteamFarm.Tests/Bot.cs @@ -26,485 +26,485 @@ using ArchiSteamFarm.Steam.Data; using Microsoft.VisualStudio.TestTools.UnitTesting; using static ArchiSteamFarm.Steam.Bot; -namespace ArchiSteamFarm.Tests { - [TestClass] - public sealed class Bot { - [TestMethod] - public void MaxItemsBarelyEnoughForOneSet() { - const uint relevantAppID = 42; +namespace ArchiSteamFarm.Tests; - Dictionary itemsPerSet = new() { - { relevantAppID, MinCardsPerBadge }, - { 43, MinCardsPerBadge + 1 } - }; +[TestClass] +public sealed class Bot { + [TestMethod] + public void MaxItemsBarelyEnoughForOneSet() { + const uint relevantAppID = 42; - HashSet items = new(); + Dictionary itemsPerSet = new() { + { relevantAppID, MinCardsPerBadge }, + { 43, MinCardsPerBadge + 1 } + }; - foreach ((uint appID, byte cards) in itemsPerSet) { - for (byte i = 1; i <= cards; i++) { - items.Add(CreateCard(i, appID)); - } + HashSet items = new(); + + foreach ((uint appID, byte cards) in itemsPerSet) { + for (byte i = 1; i <= cards; i++) { + items.Add(CreateCard(i, appID)); } - - HashSet itemsToSend = GetItemsForFullBadge(items, itemsPerSet, MinCardsPerBadge); - - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = items.Where(static item => item.RealAppID == relevantAppID).GroupBy(static item => (item.RealAppID, item.ContextID, item.ClassID)).ToDictionary(static group => group.Key, static group => (uint) group.Sum(static item => item.Amount)); - - AssertResultMatchesExpectation(expectedResult, itemsToSend); } - [TestMethod] - [ExpectedException(typeof(ArgumentOutOfRangeException))] - public void MaxItemsTooSmall() { - const uint appID = 42; - - HashSet items = new() { - CreateCard(1, appID), - CreateCard(2, appID) - }; + HashSet itemsToSend = GetItemsForFullBadge(items, itemsPerSet, MinCardsPerBadge); - GetItemsForFullBadge(items, 2, appID, MinCardsPerBadge - 1); - - Assert.Fail(); - } - - [TestMethod] - public void MoreCardsThanNeeded() { - const uint appID = 42; + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = items.Where(static item => item.RealAppID == relevantAppID).GroupBy(static item => (item.RealAppID, item.ContextID, item.ClassID)).ToDictionary(static group => group.Key, static group => (uint) group.Sum(static item => item.Amount)); - HashSet items = new() { - CreateCard(1, appID), - CreateCard(1, appID), - CreateCard(2, appID), - CreateCard(3, appID) - }; - - HashSet itemsToSend = GetItemsForFullBadge(items, 3, appID); - - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { - { (appID, Asset.SteamCommunityContextID, 1), 1 }, - { (appID, Asset.SteamCommunityContextID, 2), 1 }, - { (appID, Asset.SteamCommunityContextID, 3), 1 } - }; + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } - - [TestMethod] - public void MultipleSets() { - const uint appID = 42; - - HashSet items = new() { - CreateCard(1, appID), - CreateCard(1, appID), - CreateCard(2, appID), - CreateCard(2, appID) - }; - - HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void MaxItemsTooSmall() { + const uint appID = 42; - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { - { (appID, Asset.SteamCommunityContextID, 1), 2 }, - { (appID, Asset.SteamCommunityContextID, 2), 2 } - }; + HashSet items = new() { + CreateCard(1, appID), + CreateCard(2, appID) + }; - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } + GetItemsForFullBadge(items, 2, appID, MinCardsPerBadge - 1); - [TestMethod] - public void MultipleSetsDifferentAmount() { - const uint appID = 42; - - HashSet items = new() { - CreateCard(1, appID, 2), - CreateCard(2, appID), - CreateCard(2, appID) - }; - - HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); - - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { - { (appID, Asset.SteamCommunityContextID, 1), 2 }, - { (appID, Asset.SteamCommunityContextID, 2), 2 } - }; - - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } - - [TestMethod] - public void MutliRarityAndType() { - const uint appID = 42; - - HashSet items = new() { - CreateCard(1, appID, type: Asset.EType.TradingCard, rarity: Asset.ERarity.Common), - CreateCard(2, appID, type: Asset.EType.TradingCard, rarity: Asset.ERarity.Common), - - CreateCard(1, appID, type: Asset.EType.FoilTradingCard, rarity: Asset.ERarity.Uncommon), - CreateCard(2, appID, type: Asset.EType.FoilTradingCard, rarity: Asset.ERarity.Uncommon), - - CreateCard(1, appID, type: Asset.EType.FoilTradingCard, rarity: Asset.ERarity.Rare), - CreateCard(2, appID, type: Asset.EType.FoilTradingCard, rarity: Asset.ERarity.Rare), - - // for better readability and easier verification when thinking about this test the items that shall be selected for sending are the ones below this comment - CreateCard(1, appID, type: Asset.EType.TradingCard, rarity: Asset.ERarity.Uncommon), - CreateCard(2, appID, type: Asset.EType.TradingCard, rarity: Asset.ERarity.Uncommon), - CreateCard(3, appID, type: Asset.EType.TradingCard, rarity: Asset.ERarity.Uncommon), - - CreateCard(1, appID, type: Asset.EType.FoilTradingCard, rarity: Asset.ERarity.Common), - CreateCard(3, appID, type: Asset.EType.FoilTradingCard, rarity: Asset.ERarity.Common), - CreateCard(7, appID, type: Asset.EType.FoilTradingCard, rarity: Asset.ERarity.Common), - - CreateCard(2, appID, type: Asset.EType.Unknown, rarity: Asset.ERarity.Rare), - CreateCard(3, appID, type: Asset.EType.Unknown, rarity: Asset.ERarity.Rare), - CreateCard(4, appID, type: Asset.EType.Unknown, rarity: Asset.ERarity.Rare) - }; - - HashSet itemsToSend = GetItemsForFullBadge(items, 3, appID); - - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { - { (appID, Asset.SteamCommunityContextID, 1), 2 }, - { (appID, Asset.SteamCommunityContextID, 2), 2 }, - { (appID, Asset.SteamCommunityContextID, 3), 3 }, - { (appID, Asset.SteamCommunityContextID, 4), 1 }, - { (appID, Asset.SteamCommunityContextID, 7), 1 } - }; - - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } - - [TestMethod] - public void NotAllCardsPresent() { - const uint appID = 42; - - HashSet items = new() { - CreateCard(1, appID), - CreateCard(2, appID) - }; - - HashSet itemsToSend = GetItemsForFullBadge(items, 3, appID); - - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new(0); - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } - - [TestMethod] - public void OneSet() { - const uint appID = 42; - - HashSet items = new() { - CreateCard(1, appID), - CreateCard(2, appID) - }; - - HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); - - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { - { (appID, Asset.SteamCommunityContextID, 1), 1 }, - { (appID, Asset.SteamCommunityContextID, 2), 1 } - }; - - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } - - [TestMethod] - public void OtherAppIDFullSets() { - const uint appID0 = 42; - const uint appID1 = 43; - - HashSet items = new() { - CreateCard(1, appID0), - CreateCard(1, appID1) - }; - - HashSet itemsToSend = GetItemsForFullBadge( - items, new Dictionary { - { appID0, 1 }, - { appID1, 1 } - } - ); - - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { - { (appID0, Asset.SteamCommunityContextID, 1), 1 }, - { (appID1, Asset.SteamCommunityContextID, 1), 1 } - }; - - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } - - [TestMethod] - public void OtherAppIDNoSets() { - const uint appID0 = 42; - const uint appID1 = 43; - - HashSet items = new() { - CreateCard(1, appID0), - CreateCard(1, appID1) - }; - - HashSet itemsToSend = GetItemsForFullBadge( - items, new Dictionary { - { appID0, 2 }, - { appID1, 2 } - } - ); - - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new(0); - - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } - - [TestMethod] - public void OtherAppIDOneSet() { - const uint appID0 = 42; - const uint appID1 = 43; - const uint appID2 = 44; - - HashSet items = new() { - CreateCard(1, appID0), - CreateCard(2, appID0), - - CreateCard(1, appID1), - CreateCard(2, appID1), - CreateCard(3, appID1) - }; - - HashSet itemsToSend = GetItemsForFullBadge( - items, new Dictionary { - { appID0, 3 }, - { appID1, 3 }, - { appID2, 3 } - } - ); + Assert.Fail(); + } - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { - { (appID1, Asset.SteamCommunityContextID, 1), 1 }, - { (appID1, Asset.SteamCommunityContextID, 2), 1 }, - { (appID1, Asset.SteamCommunityContextID, 3), 1 } - }; - - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } - - [TestMethod] - public void OtherRarityFullSets() { - const uint appID = 42; - - HashSet items = new() { - CreateCard(1, appID, rarity: Asset.ERarity.Common), - CreateCard(1, appID, rarity: Asset.ERarity.Rare) - }; - - HashSet itemsToSend = GetItemsForFullBadge(items, 1, appID); - - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { - { (appID, Asset.SteamCommunityContextID, 1), 2 } - }; - - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } - - [TestMethod] - public void OtherRarityNoSets() { - const uint appID = 42; - - HashSet items = new() { - CreateCard(1, appID, rarity: Asset.ERarity.Common), - CreateCard(1, appID, rarity: Asset.ERarity.Rare) - }; - - HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); - - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new(0); - - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } - - [TestMethod] - public void OtherRarityOneSet() { - const uint appID = 42; + [TestMethod] + public void MoreCardsThanNeeded() { + const uint appID = 42; - HashSet items = new() { - CreateCard(1, appID, rarity: Asset.ERarity.Common), - CreateCard(2, appID, rarity: Asset.ERarity.Common), - CreateCard(1, appID, rarity: Asset.ERarity.Uncommon), - CreateCard(2, appID, rarity: Asset.ERarity.Uncommon), - CreateCard(3, appID, rarity: Asset.ERarity.Uncommon) - }; + HashSet items = new() { + CreateCard(1, appID), + CreateCard(1, appID), + CreateCard(2, appID), + CreateCard(3, appID) + }; - HashSet itemsToSend = GetItemsForFullBadge(items, 3, appID); + HashSet itemsToSend = GetItemsForFullBadge(items, 3, appID); - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { - { (appID, Asset.SteamCommunityContextID, 1), 1 }, - { (appID, Asset.SteamCommunityContextID, 2), 1 }, - { (appID, Asset.SteamCommunityContextID, 3), 1 } - }; + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { + { (appID, Asset.SteamCommunityContextID, 1), 1 }, + { (appID, Asset.SteamCommunityContextID, 2), 1 }, + { (appID, Asset.SteamCommunityContextID, 3), 1 } + }; - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } - [TestMethod] - public void OtherTypeFullSets() { - const uint appID = 42; + [TestMethod] + public void MultipleSets() { + const uint appID = 42; - HashSet items = new() { - CreateCard(1, appID, type: Asset.EType.TradingCard), - CreateCard(1, appID, type: Asset.EType.FoilTradingCard) - }; + HashSet items = new() { + CreateCard(1, appID), + CreateCard(1, appID), + CreateCard(2, appID), + CreateCard(2, appID) + }; - HashSet itemsToSend = GetItemsForFullBadge(items, 1, appID); + HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { - { (appID, Asset.SteamCommunityContextID, 1), 2 } - }; + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { + { (appID, Asset.SteamCommunityContextID, 1), 2 }, + { (appID, Asset.SteamCommunityContextID, 2), 2 } + }; - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } - [TestMethod] - public void OtherTypeNoSets() { - const uint appID = 42; + [TestMethod] + public void MultipleSetsDifferentAmount() { + const uint appID = 42; - HashSet items = new() { - CreateCard(1, appID, type: Asset.EType.TradingCard), - CreateCard(1, appID, type: Asset.EType.FoilTradingCard) - }; + HashSet items = new() { + CreateCard(1, appID, 2), + CreateCard(2, appID), + CreateCard(2, appID) + }; - HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); + HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new(0); + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { + { (appID, Asset.SteamCommunityContextID, 1), 2 }, + { (appID, Asset.SteamCommunityContextID, 2), 2 } + }; - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } - [TestMethod] - public void OtherTypeOneSet() { - const uint appID = 42; + [TestMethod] + public void MutliRarityAndType() { + const uint appID = 42; - HashSet items = new() { - CreateCard(1, appID, type: Asset.EType.TradingCard), - CreateCard(2, appID, type: Asset.EType.TradingCard), - CreateCard(1, appID, type: Asset.EType.FoilTradingCard), - CreateCard(2, appID, type: Asset.EType.FoilTradingCard), - CreateCard(3, appID, type: Asset.EType.FoilTradingCard) - }; + HashSet items = new() { + CreateCard(1, appID, type: Asset.EType.TradingCard, rarity: Asset.ERarity.Common), + CreateCard(2, appID, type: Asset.EType.TradingCard, rarity: Asset.ERarity.Common), - HashSet itemsToSend = GetItemsForFullBadge(items, 3, appID); + CreateCard(1, appID, type: Asset.EType.FoilTradingCard, rarity: Asset.ERarity.Uncommon), + CreateCard(2, appID, type: Asset.EType.FoilTradingCard, rarity: Asset.ERarity.Uncommon), - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { - { (appID, Asset.SteamCommunityContextID, 1), 1 }, - { (appID, Asset.SteamCommunityContextID, 2), 1 }, - { (appID, Asset.SteamCommunityContextID, 3), 1 } - }; + CreateCard(1, appID, type: Asset.EType.FoilTradingCard, rarity: Asset.ERarity.Rare), + CreateCard(2, appID, type: Asset.EType.FoilTradingCard, rarity: Asset.ERarity.Rare), - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } + // for better readability and easier verification when thinking about this test the items that shall be selected for sending are the ones below this comment + CreateCard(1, appID, type: Asset.EType.TradingCard, rarity: Asset.ERarity.Uncommon), + CreateCard(2, appID, type: Asset.EType.TradingCard, rarity: Asset.ERarity.Uncommon), + CreateCard(3, appID, type: Asset.EType.TradingCard, rarity: Asset.ERarity.Uncommon), - [TestMethod] - public void TooHighAmount() { - const uint appID0 = 42; + CreateCard(1, appID, type: Asset.EType.FoilTradingCard, rarity: Asset.ERarity.Common), + CreateCard(3, appID, type: Asset.EType.FoilTradingCard, rarity: Asset.ERarity.Common), + CreateCard(7, appID, type: Asset.EType.FoilTradingCard, rarity: Asset.ERarity.Common), + + CreateCard(2, appID, type: Asset.EType.Unknown, rarity: Asset.ERarity.Rare), + CreateCard(3, appID, type: Asset.EType.Unknown, rarity: Asset.ERarity.Rare), + CreateCard(4, appID, type: Asset.EType.Unknown, rarity: Asset.ERarity.Rare) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 3, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { + { (appID, Asset.SteamCommunityContextID, 1), 2 }, + { (appID, Asset.SteamCommunityContextID, 2), 2 }, + { (appID, Asset.SteamCommunityContextID, 3), 3 }, + { (appID, Asset.SteamCommunityContextID, 4), 1 }, + { (appID, Asset.SteamCommunityContextID, 7), 1 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void NotAllCardsPresent() { + const uint appID = 42; + + HashSet items = new() { + CreateCard(1, appID), + CreateCard(2, appID) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 3, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new(0); + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } - HashSet items = new() { - CreateCard(1, appID0, 2), - CreateCard(2, appID0) - }; + [TestMethod] + public void OneSet() { + const uint appID = 42; + + HashSet items = new() { + CreateCard(1, appID), + CreateCard(2, appID) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); - HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID0); + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { + { (appID, Asset.SteamCommunityContextID, 1), 1 }, + { (appID, Asset.SteamCommunityContextID, 2), 1 } + }; - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { - { (appID0, Asset.SteamCommunityContextID, 1), 1 }, - { (appID0, Asset.SteamCommunityContextID, 2), 1 } - }; + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } - AssertResultMatchesExpectation(expectedResult, itemsToSend); - } + [TestMethod] + public void OtherAppIDFullSets() { + const uint appID0 = 42; + const uint appID1 = 43; - [TestMethod] - public void TooManyCardsForSingleTrade() { - const uint appID = 42; - - HashSet items = new(); - - for (byte i = 0; i < Steam.Exchange.Trading.MaxItemsPerTrade; i++) { - items.Add(CreateCard(1, appID)); - items.Add(CreateCard(2, appID)); + HashSet items = new() { + CreateCard(1, appID0), + CreateCard(1, appID1) + }; + + HashSet itemsToSend = GetItemsForFullBadge( + items, new Dictionary { + { appID0, 1 }, + { appID1, 1 } } + ); - HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { + { (appID0, Asset.SteamCommunityContextID, 1), 1 }, + { (appID1, Asset.SteamCommunityContextID, 1), 1 } + }; - Assert.IsTrue(itemsToSend.Count <= Steam.Exchange.Trading.MaxItemsPerTrade); - } + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } - [TestMethod] - public void TooManyCardsForSingleTradeMultipleAppIDs() { - const uint appID0 = 42; - const uint appID1 = 43; + [TestMethod] + public void OtherAppIDNoSets() { + const uint appID0 = 42; + const uint appID1 = 43; - HashSet items = new(); + HashSet items = new() { + CreateCard(1, appID0), + CreateCard(1, appID1) + }; - for (byte i = 0; i < 100; i++) { - items.Add(CreateCard(1, appID0)); - items.Add(CreateCard(2, appID0)); - items.Add(CreateCard(1, appID1)); - items.Add(CreateCard(2, appID1)); - } - - Dictionary itemsPerSet = new() { + HashSet itemsToSend = GetItemsForFullBadge( + items, new Dictionary { { appID0, 2 }, { appID1, 2 } - }; - - HashSet itemsToSend = GetItemsForFullBadge(items, itemsPerSet); - - Assert.IsTrue(itemsToSend.Count <= Steam.Exchange.Trading.MaxItemsPerTrade); - } - - [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] - public void TooManyCardsPerSet() { - const uint appID0 = 42; - const uint appID1 = 43; - const uint appID2 = 44; - - HashSet items = new() { - CreateCard(1, appID0), - CreateCard(2, appID0), - CreateCard(3, appID0), - CreateCard(4, appID0) - }; - - GetItemsForFullBadge( - items, new Dictionary { - { appID0, 3 }, - { appID1, 3 }, - { appID2, 3 } - } - ); - - Assert.Fail(); - } - - private static void AssertResultMatchesExpectation(IReadOnlyDictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult, IReadOnlyCollection itemsToSend) { - if (expectedResult == null) { - throw new ArgumentNullException(nameof(expectedResult)); } + ); - if (itemsToSend == null) { - throw new ArgumentNullException(nameof(itemsToSend)); + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new(0); + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OtherAppIDOneSet() { + const uint appID0 = 42; + const uint appID1 = 43; + const uint appID2 = 44; + + HashSet items = new() { + CreateCard(1, appID0), + CreateCard(2, appID0), + + CreateCard(1, appID1), + CreateCard(2, appID1), + CreateCard(3, appID1) + }; + + HashSet itemsToSend = GetItemsForFullBadge( + items, new Dictionary { + { appID0, 3 }, + { appID1, 3 }, + { appID2, 3 } } + ); - Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), long> realResult = itemsToSend.GroupBy(static asset => (asset.RealAppID, asset.ContextID, asset.ClassID)).ToDictionary(static group => group.Key, static group => group.Sum(static asset => asset.Amount)); - Assert.AreEqual(expectedResult.Count, realResult.Count); - Assert.IsTrue(expectedResult.All(expectation => realResult.TryGetValue(expectation.Key, out long reality) && (expectation.Value == reality))); + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { + { (appID1, Asset.SteamCommunityContextID, 1), 1 }, + { (appID1, Asset.SteamCommunityContextID, 2), 1 }, + { (appID1, Asset.SteamCommunityContextID, 3), 1 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OtherRarityFullSets() { + const uint appID = 42; + + HashSet items = new() { + CreateCard(1, appID, rarity: Asset.ERarity.Common), + CreateCard(1, appID, rarity: Asset.ERarity.Rare) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 1, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { + { (appID, Asset.SteamCommunityContextID, 1), 2 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OtherRarityNoSets() { + const uint appID = 42; + + HashSet items = new() { + CreateCard(1, appID, rarity: Asset.ERarity.Common), + CreateCard(1, appID, rarity: Asset.ERarity.Rare) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new(0); + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OtherRarityOneSet() { + const uint appID = 42; + + HashSet items = new() { + CreateCard(1, appID, rarity: Asset.ERarity.Common), + CreateCard(2, appID, rarity: Asset.ERarity.Common), + CreateCard(1, appID, rarity: Asset.ERarity.Uncommon), + CreateCard(2, appID, rarity: Asset.ERarity.Uncommon), + CreateCard(3, appID, rarity: Asset.ERarity.Uncommon) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 3, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { + { (appID, Asset.SteamCommunityContextID, 1), 1 }, + { (appID, Asset.SteamCommunityContextID, 2), 1 }, + { (appID, Asset.SteamCommunityContextID, 3), 1 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OtherTypeFullSets() { + const uint appID = 42; + + HashSet items = new() { + CreateCard(1, appID, type: Asset.EType.TradingCard), + CreateCard(1, appID, type: Asset.EType.FoilTradingCard) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 1, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { + { (appID, Asset.SteamCommunityContextID, 1), 2 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OtherTypeNoSets() { + const uint appID = 42; + + HashSet items = new() { + CreateCard(1, appID, type: Asset.EType.TradingCard), + CreateCard(1, appID, type: Asset.EType.FoilTradingCard) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new(0); + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OtherTypeOneSet() { + const uint appID = 42; + + HashSet items = new() { + CreateCard(1, appID, type: Asset.EType.TradingCard), + CreateCard(2, appID, type: Asset.EType.TradingCard), + CreateCard(1, appID, type: Asset.EType.FoilTradingCard), + CreateCard(2, appID, type: Asset.EType.FoilTradingCard), + CreateCard(3, appID, type: Asset.EType.FoilTradingCard) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 3, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { + { (appID, Asset.SteamCommunityContextID, 1), 1 }, + { (appID, Asset.SteamCommunityContextID, 2), 1 }, + { (appID, Asset.SteamCommunityContextID, 3), 1 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void TooHighAmount() { + const uint appID0 = 42; + + HashSet items = new() { + CreateCard(1, appID0, 2), + CreateCard(2, appID0) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID0); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new() { + { (appID0, Asset.SteamCommunityContextID, 1), 1 }, + { (appID0, Asset.SteamCommunityContextID, 2), 1 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void TooManyCardsForSingleTrade() { + const uint appID = 42; + + HashSet items = new(); + + for (byte i = 0; i < Steam.Exchange.Trading.MaxItemsPerTrade; i++) { + items.Add(CreateCard(1, appID)); + items.Add(CreateCard(2, appID)); } - private static Asset CreateCard(ulong classID, uint realAppID, uint amount = 1, Asset.EType type = Asset.EType.TradingCard, Asset.ERarity rarity = Asset.ERarity.Common) => new(Asset.SteamAppID, Asset.SteamCommunityContextID, classID, amount, realAppID: realAppID, type: type, rarity: rarity); + HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); - private static HashSet GetItemsForFullBadge(IReadOnlyCollection inventory, byte cardsPerSet, uint appID, ushort maxItems = Steam.Exchange.Trading.MaxItemsPerTrade) => GetItemsForFullBadge(inventory, new Dictionary { { appID, cardsPerSet } }, maxItems); + Assert.IsTrue(itemsToSend.Count <= Steam.Exchange.Trading.MaxItemsPerTrade); + } - private static HashSet GetItemsForFullBadge(IReadOnlyCollection inventory, IDictionary cardsPerSet, ushort maxItems = Steam.Exchange.Trading.MaxItemsPerTrade) { - Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), List> inventorySets = Steam.Exchange.Trading.GetInventorySets(inventory); + [TestMethod] + public void TooManyCardsForSingleTradeMultipleAppIDs() { + const uint appID0 = 42; + const uint appID1 = 43; - return GetItemsForFullSets(inventory, inventorySets.ToDictionary(static kv => kv.Key, kv => (SetsToExtract: inventorySets[kv.Key][0], cardsPerSet[kv.Key.RealAppID])), maxItems).ToHashSet(); + HashSet items = new(); + + for (byte i = 0; i < 100; i++) { + items.Add(CreateCard(1, appID0)); + items.Add(CreateCard(2, appID0)); + items.Add(CreateCard(1, appID1)); + items.Add(CreateCard(2, appID1)); } + + Dictionary itemsPerSet = new() { + { appID0, 2 }, + { appID1, 2 } + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, itemsPerSet); + + Assert.IsTrue(itemsToSend.Count <= Steam.Exchange.Trading.MaxItemsPerTrade); + } + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void TooManyCardsPerSet() { + const uint appID0 = 42; + const uint appID1 = 43; + const uint appID2 = 44; + + HashSet items = new() { + CreateCard(1, appID0), + CreateCard(2, appID0), + CreateCard(3, appID0), + CreateCard(4, appID0) + }; + + GetItemsForFullBadge( + items, new Dictionary { + { appID0, 3 }, + { appID1, 3 }, + { appID2, 3 } + } + ); + + Assert.Fail(); + } + + private static void AssertResultMatchesExpectation(IReadOnlyDictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult, IReadOnlyCollection itemsToSend) { + if (expectedResult == null) { + throw new ArgumentNullException(nameof(expectedResult)); + } + + if (itemsToSend == null) { + throw new ArgumentNullException(nameof(itemsToSend)); + } + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), long> realResult = itemsToSend.GroupBy(static asset => (asset.RealAppID, asset.ContextID, asset.ClassID)).ToDictionary(static group => group.Key, static group => group.Sum(static asset => asset.Amount)); + Assert.AreEqual(expectedResult.Count, realResult.Count); + Assert.IsTrue(expectedResult.All(expectation => realResult.TryGetValue(expectation.Key, out long reality) && (expectation.Value == reality))); + } + + private static Asset CreateCard(ulong classID, uint realAppID, uint amount = 1, Asset.EType type = Asset.EType.TradingCard, Asset.ERarity rarity = Asset.ERarity.Common) => new(Asset.SteamAppID, Asset.SteamCommunityContextID, classID, amount, realAppID: realAppID, type: type, rarity: rarity); + + private static HashSet GetItemsForFullBadge(IReadOnlyCollection inventory, byte cardsPerSet, uint appID, ushort maxItems = Steam.Exchange.Trading.MaxItemsPerTrade) => GetItemsForFullBadge(inventory, new Dictionary { { appID, cardsPerSet } }, maxItems); + + private static HashSet GetItemsForFullBadge(IReadOnlyCollection inventory, IDictionary cardsPerSet, ushort maxItems = Steam.Exchange.Trading.MaxItemsPerTrade) { + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), List> inventorySets = Steam.Exchange.Trading.GetInventorySets(inventory); + + return GetItemsForFullSets(inventory, inventorySets.ToDictionary(static kv => kv.Key, kv => (SetsToExtract: inventorySets[kv.Key][0], cardsPerSet[kv.Key.RealAppID])), maxItems).ToHashSet(); } } diff --git a/ArchiSteamFarm.Tests/SteamChatMessage.cs b/ArchiSteamFarm.Tests/SteamChatMessage.cs index 5367df8c8..c00d5ee54 100644 --- a/ArchiSteamFarm.Tests/SteamChatMessage.cs +++ b/ArchiSteamFarm.Tests/SteamChatMessage.cs @@ -27,179 +27,180 @@ using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using static ArchiSteamFarm.Steam.Integration.SteamChatMessage; -namespace ArchiSteamFarm.Tests { - [TestClass] - public sealed class SteamChatMessage { - [TestMethod] - public async Task CanSplitEvenWithStupidlyLongPrefix() { - string prefix = new('x', MaxMessagePrefixBytes); +namespace ArchiSteamFarm.Tests; - const string emoji = "😎"; - const string message = emoji + emoji + emoji + emoji; +[TestClass] +public sealed class SteamChatMessage { + [TestMethod] + public async Task CanSplitEvenWithStupidlyLongPrefix() { + string prefix = new('x', MaxMessagePrefixBytes); - List output = await GetMessageParts(message, prefix, true).ToListAsync().ConfigureAwait(false); + const string emoji = "😎"; + const string message = emoji + emoji + emoji + emoji; - Assert.AreEqual(4, output.Count); + List output = await GetMessageParts(message, prefix, true).ToListAsync().ConfigureAwait(false); - Assert.AreEqual(prefix + emoji + ContinuationCharacter, output[0]); - Assert.AreEqual(prefix + ContinuationCharacter + emoji + ContinuationCharacter, output[1]); - Assert.AreEqual(prefix + ContinuationCharacter + emoji + ContinuationCharacter, output[2]); - Assert.AreEqual(prefix + ContinuationCharacter + emoji, output[3]); - } + Assert.AreEqual(4, output.Count); - [TestMethod] - public void ContinuationCharacterSizeIsProperlyCalculated() => Assert.AreEqual(ContinuationCharacterBytes, Encoding.UTF8.GetByteCount(ContinuationCharacter.ToString())); + Assert.AreEqual(prefix + emoji + ContinuationCharacter, output[0]); + Assert.AreEqual(prefix + ContinuationCharacter + emoji + ContinuationCharacter, output[1]); + Assert.AreEqual(prefix + ContinuationCharacter + emoji + ContinuationCharacter, output[2]); + Assert.AreEqual(prefix + ContinuationCharacter + emoji, output[3]); + } - [TestMethod] - public async Task DoesntSkipEmptyNewlines() { - string message = $"asdf{Environment.NewLine}{Environment.NewLine}asdf"; + [TestMethod] + public void ContinuationCharacterSizeIsProperlyCalculated() => Assert.AreEqual(ContinuationCharacterBytes, Encoding.UTF8.GetByteCount(ContinuationCharacter.ToString())); - List output = await GetMessageParts(message).ToListAsync().ConfigureAwait(false); + [TestMethod] + public async Task DoesntSkipEmptyNewlines() { + string message = $"asdf{Environment.NewLine}{Environment.NewLine}asdf"; - Assert.AreEqual(1, output.Count); - Assert.AreEqual(message, output.First()); - } + List output = await GetMessageParts(message).ToListAsync().ConfigureAwait(false); - [DataRow(false)] - [DataRow(true)] - [DataTestMethod] - public async Task DoesntSplitInTheMiddleOfMultiByteChar(bool isAccountLimited) { - int maxMessageBytes = isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts; - int longLineLength = maxMessageBytes - ReservedContinuationMessageBytes; + Assert.AreEqual(1, output.Count); + Assert.AreEqual(message, output.First()); + } - const string emoji = "😎"; + [DataRow(false)] + [DataRow(true)] + [DataTestMethod] + public async Task DoesntSplitInTheMiddleOfMultiByteChar(bool isAccountLimited) { + int maxMessageBytes = isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts; + int longLineLength = maxMessageBytes - ReservedContinuationMessageBytes; - string longSequence = new('a', longLineLength - 1); - string message = longSequence + emoji; + const string emoji = "😎"; - List output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false); + string longSequence = new('a', longLineLength - 1); + string message = longSequence + emoji; - Assert.AreEqual(2, output.Count); + List output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false); - Assert.AreEqual(longSequence + ContinuationCharacter, output[0]); - Assert.AreEqual(ContinuationCharacter + emoji, output[1]); - } + Assert.AreEqual(2, output.Count); - [TestMethod] - public async Task DoesntSplitJustBecauseOfLastEscapableCharacter() { - const string message = "abcdef["; - const string escapedMessage = @"abcdef\["; + Assert.AreEqual(longSequence + ContinuationCharacter, output[0]); + Assert.AreEqual(ContinuationCharacter + emoji, output[1]); + } - List output = await GetMessageParts(message).ToListAsync().ConfigureAwait(false); + [TestMethod] + public async Task DoesntSplitJustBecauseOfLastEscapableCharacter() { + const string message = "abcdef["; + const string escapedMessage = @"abcdef\["; - Assert.AreEqual(1, output.Count); - Assert.AreEqual(escapedMessage, output.First()); - } + List output = await GetMessageParts(message).ToListAsync().ConfigureAwait(false); - [DataRow(false)] - [DataRow(true)] - [DataTestMethod] - public async Task DoesntSplitOnBackslashNotUsedForEscaping(bool isAccountLimited) { - int maxMessageBytes = isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts; - int longLineLength = maxMessageBytes - ReservedContinuationMessageBytes; + Assert.AreEqual(1, output.Count); + Assert.AreEqual(escapedMessage, output.First()); + } - string longLine = new('a', longLineLength - 2); - string message = $@"{longLine}\"; + [DataRow(false)] + [DataRow(true)] + [DataTestMethod] + public async Task DoesntSplitOnBackslashNotUsedForEscaping(bool isAccountLimited) { + int maxMessageBytes = isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts; + int longLineLength = maxMessageBytes - ReservedContinuationMessageBytes; - List output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false); + string longLine = new('a', longLineLength - 2); + string message = $@"{longLine}\"; - Assert.AreEqual(1, output.Count); - Assert.AreEqual($@"{message}\", output.First()); - } + List output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false); - [DataRow(false)] - [DataRow(true)] - [DataTestMethod] - public async Task DoesntSplitOnEscapeCharacter(bool isAccountLimited) { - int maxMessageBytes = isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts; - int longLineLength = maxMessageBytes - ReservedContinuationMessageBytes; + Assert.AreEqual(1, output.Count); + Assert.AreEqual($@"{message}\", output.First()); + } - string longLine = new('a', longLineLength - 1); - string message = $"{longLine}["; + [DataRow(false)] + [DataRow(true)] + [DataTestMethod] + public async Task DoesntSplitOnEscapeCharacter(bool isAccountLimited) { + int maxMessageBytes = isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts; + int longLineLength = maxMessageBytes - ReservedContinuationMessageBytes; - List output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false); + string longLine = new('a', longLineLength - 1); + string message = $"{longLine}["; - Assert.AreEqual(2, output.Count); + List output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false); - Assert.AreEqual(longLine + ContinuationCharacter, output[0]); - Assert.AreEqual($@"{ContinuationCharacter}\[", output[1]); - } + Assert.AreEqual(2, output.Count); - [TestMethod] - public async Task NoNeedForAnySplittingWithNewlines() { - string message = $"abcdef{Environment.NewLine}ghijkl{Environment.NewLine}mnopqr"; + Assert.AreEqual(longLine + ContinuationCharacter, output[0]); + Assert.AreEqual($@"{ContinuationCharacter}\[", output[1]); + } - List output = await GetMessageParts(message).ToListAsync().ConfigureAwait(false); + [TestMethod] + public async Task NoNeedForAnySplittingWithNewlines() { + string message = $"abcdef{Environment.NewLine}ghijkl{Environment.NewLine}mnopqr"; - Assert.AreEqual(1, output.Count); - Assert.AreEqual(message, output.First()); - } + List output = await GetMessageParts(message).ToListAsync().ConfigureAwait(false); - [TestMethod] - public async Task NoNeedForAnySplittingWithoutNewlines() { - const string message = "abcdef"; + Assert.AreEqual(1, output.Count); + Assert.AreEqual(message, output.First()); + } - List output = await GetMessageParts(message).ToListAsync().ConfigureAwait(false); + [TestMethod] + public async Task NoNeedForAnySplittingWithoutNewlines() { + const string message = "abcdef"; - Assert.AreEqual(1, output.Count); - Assert.AreEqual(message, output.First()); - } + List output = await GetMessageParts(message).ToListAsync().ConfigureAwait(false); - [TestMethod] - public void ParagraphCharacterSizeIsLessOrEqualToContinuationCharacterSize() => Assert.IsTrue(ContinuationCharacterBytes >= Encoding.UTF8.GetByteCount(ParagraphCharacter.ToString())); + Assert.AreEqual(1, output.Count); + Assert.AreEqual(message, output.First()); + } - [TestMethod] - public async Task ProperlyEscapesCharacters() { - const string message = @"[b]bold[/b] \n"; - const string escapedMessage = @"\[b]bold\[/b] \\n"; + [TestMethod] + public void ParagraphCharacterSizeIsLessOrEqualToContinuationCharacterSize() => Assert.IsTrue(ContinuationCharacterBytes >= Encoding.UTF8.GetByteCount(ParagraphCharacter.ToString())); - List output = await GetMessageParts(message).ToListAsync().ConfigureAwait(false); + [TestMethod] + public async Task ProperlyEscapesCharacters() { + const string message = @"[b]bold[/b] \n"; + const string escapedMessage = @"\[b]bold\[/b] \\n"; - Assert.AreEqual(1, output.Count); - Assert.AreEqual(escapedMessage, output.First()); - } + List output = await GetMessageParts(message).ToListAsync().ConfigureAwait(false); - [TestMethod] - public async Task ProperlyEscapesSteamMessagePrefix() { - const string prefix = "/pre []"; - const string escapedPrefix = @"/pre \[]"; + Assert.AreEqual(1, output.Count); + Assert.AreEqual(escapedMessage, output.First()); + } - const string message = "asdf"; + [TestMethod] + public async Task ProperlyEscapesSteamMessagePrefix() { + const string prefix = "/pre []"; + const string escapedPrefix = @"/pre \[]"; - List output = await GetMessageParts(message, prefix).ToListAsync().ConfigureAwait(false); + const string message = "asdf"; - Assert.AreEqual(1, output.Count); - Assert.AreEqual(escapedPrefix + message, output.First()); - } + List output = await GetMessageParts(message, prefix).ToListAsync().ConfigureAwait(false); - [DataRow(false)] - [DataRow(true)] - [DataTestMethod] - public async Task ProperlySplitsLongSingleLine(bool isAccountLimited) { - int maxMessageBytes = isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts; - int longLineLength = maxMessageBytes - ReservedContinuationMessageBytes; + Assert.AreEqual(1, output.Count); + Assert.AreEqual(escapedPrefix + message, output.First()); + } - string longLine = new('a', longLineLength); - string message = longLine + longLine + longLine + longLine; + [DataRow(false)] + [DataRow(true)] + [DataTestMethod] + public async Task ProperlySplitsLongSingleLine(bool isAccountLimited) { + int maxMessageBytes = isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts; + int longLineLength = maxMessageBytes - ReservedContinuationMessageBytes; - List output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false); + string longLine = new('a', longLineLength); + string message = longLine + longLine + longLine + longLine; - Assert.AreEqual(4, output.Count); + List output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false); - Assert.AreEqual(longLine + ContinuationCharacter, output[0]); - Assert.AreEqual(ContinuationCharacter + longLine + ContinuationCharacter, output[1]); - Assert.AreEqual(ContinuationCharacter + longLine + ContinuationCharacter, output[2]); - Assert.AreEqual(ContinuationCharacter + longLine, output[3]); - } + Assert.AreEqual(4, output.Count); - [TestMethod] - public void ReservedSizeForEscapingIsProperlyCalculated() => Assert.AreEqual(ReservedEscapeMessageBytes, Encoding.UTF8.GetByteCount(@"\") + 4); // Maximum amount of bytes per single UTF-8 character is 4, not 6 as from Encoding.UTF8.GetMaxByteCount(1) + Assert.AreEqual(longLine + ContinuationCharacter, output[0]); + Assert.AreEqual(ContinuationCharacter + longLine + ContinuationCharacter, output[1]); + Assert.AreEqual(ContinuationCharacter + longLine + ContinuationCharacter, output[2]); + Assert.AreEqual(ContinuationCharacter + longLine, output[3]); + } - [TestMethod] - public async Task RyzhehvostInitialTestForSplitting() { - const string prefix = "/me "; + [TestMethod] + public void ReservedSizeForEscapingIsProperlyCalculated() => Assert.AreEqual(ReservedEscapeMessageBytes, Encoding.UTF8.GetByteCount(@"\") + 4); // Maximum amount of bytes per single UTF-8 character is 4, not 6 as from Encoding.UTF8.GetMaxByteCount(1) - const string message = @" Уже имеет: app/1493800 | Aircraft Carrier Survival: Prolouge + [TestMethod] + public async Task RyzhehvostInitialTestForSplitting() { + const string prefix = "/me "; + + const string message = @" Уже имеет: app/1493800 | Aircraft Carrier Survival: Prolouge Уже имеет: app/349520 | Armillo Уже имеет: app/346330 | BrainBread 2 Уже имеет: app/1086690 | C-War 2 @@ -254,82 +255,81 @@ namespace ArchiSteamFarm.Tests { 1/1 ботов уже имеют игру app/269710 | Tumblestone. 1/1 ботов уже имеют игру app/304930 | Unturned."; - List output = await GetMessageParts(message, prefix).ToListAsync().ConfigureAwait(false); + List output = await GetMessageParts(message, prefix).ToListAsync().ConfigureAwait(false); - Assert.AreEqual(2, output.Count); + Assert.AreEqual(2, output.Count); - foreach (string messagePart in output) { - if ((messagePart.Length <= prefix.Length) || !messagePart.StartsWith(prefix, StringComparison.Ordinal)) { - Assert.Fail(); + foreach (string messagePart in output) { + if ((messagePart.Length <= prefix.Length) || !messagePart.StartsWith(prefix, StringComparison.Ordinal)) { + Assert.Fail(); - return; - } - - string[] lines = messagePart.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); - - int bytes = lines.Where(static line => line.Length > 0).Sum(Encoding.UTF8.GetByteCount) + ((lines.Length - 1) * NewlineWeight); - - if (bytes > MaxMessageBytesForUnlimitedAccounts) { - Assert.Fail(); - - return; - } - } - } - - [DataRow(false)] - [DataRow(true)] - [DataTestMethod] - public async Task SplitsOnNewlinesWithParagraphCharacter(bool isAccountLimited) { - int maxMessageBytes = isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts; - - StringBuilder newlinePartBuilder = new(); - - for (ushort bytes = 0; bytes < maxMessageBytes - ReservedContinuationMessageBytes - NewlineWeight;) { - if (newlinePartBuilder.Length > 0) { - bytes += NewlineWeight; - newlinePartBuilder.Append(Environment.NewLine); - } - - bytes++; - newlinePartBuilder.Append('a'); + return; } - string newlinePart = newlinePartBuilder.ToString(); - string message = newlinePart + Environment.NewLine + newlinePart + Environment.NewLine + newlinePart + Environment.NewLine + newlinePart; + string[] lines = messagePart.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); - List output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false); + int bytes = lines.Where(static line => line.Length > 0).Sum(Encoding.UTF8.GetByteCount) + ((lines.Length - 1) * NewlineWeight); - Assert.AreEqual(4, output.Count); + if (bytes > MaxMessageBytesForUnlimitedAccounts) { + Assert.Fail(); - Assert.AreEqual(newlinePart + ParagraphCharacter, output[0]); - Assert.AreEqual(newlinePart + ParagraphCharacter, output[1]); - Assert.AreEqual(newlinePart + ParagraphCharacter, output[2]); - Assert.AreEqual(newlinePart, output[3]); - } - - [ExpectedException(typeof(ArgumentOutOfRangeException))] - [TestMethod] - public async Task ThrowsOnTooLongNewlinesPrefix() { - string prefix = new('\n', (MaxMessagePrefixBytes / NewlineWeight) + 1); - - const string message = "asdf"; - - await GetMessageParts(message, prefix).ToListAsync().ConfigureAwait(false); - - Assert.Fail(); - } - - [ExpectedException(typeof(ArgumentOutOfRangeException))] - [TestMethod] - public async Task ThrowsOnTooLongPrefix() { - string prefix = new('x', MaxMessagePrefixBytes + 1); - - const string message = "asdf"; - - await GetMessageParts(message, prefix).ToListAsync().ConfigureAwait(false); - - Assert.Fail(); + return; + } } } + + [DataRow(false)] + [DataRow(true)] + [DataTestMethod] + public async Task SplitsOnNewlinesWithParagraphCharacter(bool isAccountLimited) { + int maxMessageBytes = isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts; + + StringBuilder newlinePartBuilder = new(); + + for (ushort bytes = 0; bytes < maxMessageBytes - ReservedContinuationMessageBytes - NewlineWeight;) { + if (newlinePartBuilder.Length > 0) { + bytes += NewlineWeight; + newlinePartBuilder.Append(Environment.NewLine); + } + + bytes++; + newlinePartBuilder.Append('a'); + } + + string newlinePart = newlinePartBuilder.ToString(); + string message = newlinePart + Environment.NewLine + newlinePart + Environment.NewLine + newlinePart + Environment.NewLine + newlinePart; + + List output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false); + + Assert.AreEqual(4, output.Count); + + Assert.AreEqual(newlinePart + ParagraphCharacter, output[0]); + Assert.AreEqual(newlinePart + ParagraphCharacter, output[1]); + Assert.AreEqual(newlinePart + ParagraphCharacter, output[2]); + Assert.AreEqual(newlinePart, output[3]); + } + + [ExpectedException(typeof(ArgumentOutOfRangeException))] + [TestMethod] + public async Task ThrowsOnTooLongNewlinesPrefix() { + string prefix = new('\n', (MaxMessagePrefixBytes / NewlineWeight) + 1); + + const string message = "asdf"; + + await GetMessageParts(message, prefix).ToListAsync().ConfigureAwait(false); + + Assert.Fail(); + } + + [ExpectedException(typeof(ArgumentOutOfRangeException))] + [TestMethod] + public async Task ThrowsOnTooLongPrefix() { + string prefix = new('x', MaxMessagePrefixBytes + 1); + + const string message = "asdf"; + + await GetMessageParts(message, prefix).ToListAsync().ConfigureAwait(false); + + Assert.Fail(); + } } diff --git a/ArchiSteamFarm.Tests/Trading.cs b/ArchiSteamFarm.Tests/Trading.cs index 83d1c295c..9a25bf3f4 100644 --- a/ArchiSteamFarm.Tests/Trading.cs +++ b/ArchiSteamFarm.Tests/Trading.cs @@ -24,369 +24,369 @@ using ArchiSteamFarm.Steam.Data; using Microsoft.VisualStudio.TestTools.UnitTesting; using static ArchiSteamFarm.Steam.Exchange.Trading; -namespace ArchiSteamFarm.Tests { - [TestClass] - public sealed class Trading { - [TestMethod] - public void MismatchRarityIsNotFair() { - HashSet itemsToGive = new() { CreateItem(1, rarity: Asset.ERarity.Rare) }; - HashSet itemsToReceive = new() { CreateItem(2) }; +namespace ArchiSteamFarm.Tests; - Assert.IsFalse(IsFairExchange(itemsToGive, itemsToReceive)); - } +[TestClass] +public sealed class Trading { + [TestMethod] + public void MismatchRarityIsNotFair() { + HashSet itemsToGive = new() { CreateItem(1, rarity: Asset.ERarity.Rare) }; + HashSet itemsToReceive = new() { CreateItem(2) }; - [TestMethod] - public void MismatchRealAppIDsIsNotFair() { - HashSet itemsToGive = new() { CreateItem(1, realAppID: 570) }; - HashSet itemsToReceive = new() { CreateItem(2) }; - - Assert.IsFalse(IsFairExchange(itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void MismatchTypesIsNotFair() { - HashSet itemsToGive = new() { CreateItem(1, type: Asset.EType.Emoticon) }; - HashSet itemsToReceive = new() { CreateItem(2) }; - - Assert.IsFalse(IsFairExchange(itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void MultiGameMultiTypeBadReject() { - HashSet inventory = new() { - CreateItem(1, 9), - CreateItem(3, 9, 730, Asset.EType.Emoticon), - CreateItem(4, realAppID: 730, type: Asset.EType.Emoticon) - }; - - HashSet itemsToGive = new() { - CreateItem(1), - CreateItem(4, realAppID: 730, type: Asset.EType.Emoticon) - }; - - HashSet itemsToReceive = new() { - CreateItem(2), - CreateItem(3, realAppID: 730, type: Asset.EType.Emoticon) - }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void MultiGameMultiTypeNeutralAccept() { - HashSet inventory = new() { - CreateItem(1, 9), - CreateItem(3, realAppID: 730, type: Asset.EType.Emoticon) - }; - - HashSet itemsToGive = new() { - CreateItem(1), - CreateItem(3, realAppID: 730, type: Asset.EType.Emoticon) - }; - - HashSet itemsToReceive = new() { - CreateItem(2), - CreateItem(4, realAppID: 730, type: Asset.EType.Emoticon) - }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void MultiGameSingleTypeBadReject() { - HashSet inventory = new() { - CreateItem(1, 9), - CreateItem(3, realAppID: 730), - CreateItem(4, realAppID: 730) - }; - - HashSet itemsToGive = new() { - CreateItem(1), - CreateItem(3, realAppID: 730) - }; - - HashSet itemsToReceive = new() { - CreateItem(2), - CreateItem(4, realAppID: 730) - }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void MultiGameSingleTypeNeutralAccept() { - HashSet inventory = new() { - CreateItem(1, 2), - CreateItem(3, realAppID: 730) - }; - - HashSet itemsToGive = new() { - CreateItem(1), - CreateItem(3, realAppID: 730) - }; - - HashSet itemsToReceive = new() { - CreateItem(2), - CreateItem(4, realAppID: 730) - }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void SingleGameAbrynosWasWrongNeutralAccept() { - HashSet inventory = new() { - CreateItem(1), - CreateItem(2, 2), - CreateItem(3), - CreateItem(4), - CreateItem(5) - }; - - HashSet itemsToGive = new() { - CreateItem(2) - }; - - HashSet itemsToReceive = new() { - CreateItem(3) - }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void SingleGameDonationAccept() { - HashSet inventory = new() { - CreateItem(1) - }; - - HashSet itemsToGive = new() { - CreateItem(1) - }; - - HashSet itemsToReceive = new() { - CreateItem(2), - CreateItem(3, type: Asset.EType.SteamGems) - }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void SingleGameMultiTypeBadReject() { - HashSet inventory = new() { - CreateItem(1, 9), - CreateItem(3, 9, type: Asset.EType.Emoticon), - CreateItem(4, type: Asset.EType.Emoticon) - }; - - HashSet itemsToGive = new() { - CreateItem(1), - CreateItem(4, type: Asset.EType.Emoticon) - }; - - HashSet itemsToReceive = new() { - CreateItem(2), - CreateItem(3, type: Asset.EType.Emoticon) - }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void SingleGameMultiTypeNeutralAccept() { - HashSet inventory = new() { - CreateItem(1, 9), - CreateItem(3, type: Asset.EType.Emoticon) - }; - - HashSet itemsToGive = new() { - CreateItem(1), - CreateItem(3, type: Asset.EType.Emoticon) - }; - - HashSet itemsToReceive = new() { - CreateItem(2), - CreateItem(4, type: Asset.EType.Emoticon) - }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void SingleGameQuantityBadReject() { - HashSet inventory = new() { - CreateItem(1), - CreateItem(2), - CreateItem(3) - }; - - HashSet itemsToGive = new() { - CreateItem(1), - CreateItem(2), - CreateItem(3) - }; - - HashSet itemsToReceive = new() { CreateItem(4, 3) }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void SingleGameQuantityBadReject2() { - HashSet inventory = new() { - CreateItem(1), - CreateItem(2, 2) - }; - - HashSet itemsToGive = new() { - CreateItem(1), - CreateItem(2, 2) - }; - - HashSet itemsToReceive = new() { CreateItem(3, 3) }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void SingleGameQuantityNeutralAccept() { - HashSet inventory = new() { - CreateItem(1, 2), - CreateItem(2) - }; - - HashSet itemsToGive = new() { - CreateItem(1), - CreateItem(2) - }; - - HashSet itemsToReceive = new() { CreateItem(3, 2) }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void SingleGameSingleTypeBadReject() { - HashSet inventory = new() { - CreateItem(1), - CreateItem(2) - }; - - HashSet itemsToGive = new() { CreateItem(1) }; - HashSet itemsToReceive = new() { CreateItem(2) }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void SingleGameSingleTypeBadWithOverpayingReject() { - HashSet inventory = new() { - CreateItem(1, 2), - CreateItem(2, 2), - CreateItem(3, 2) - }; - - HashSet itemsToGive = new() { CreateItem(2) }; - - HashSet itemsToReceive = new() { - CreateItem(1), - CreateItem(3) - }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void SingleGameSingleTypeBigDifferenceAccept() { - HashSet inventory = new() { - CreateItem(1), - CreateItem(2, 5), - CreateItem(3) - }; - - HashSet itemsToGive = new() { CreateItem(2) }; - HashSet itemsToReceive = new() { CreateItem(3) }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void SingleGameSingleTypeBigDifferenceReject() { - HashSet inventory = new() { - CreateItem(1), - CreateItem(2, 2), - CreateItem(3, 2), - CreateItem(4, 3), - CreateItem(5, 10) - }; - - HashSet itemsToGive = new() { - CreateItem(2), - CreateItem(5) - }; - - HashSet itemsToReceive = new() { - CreateItem(3), - CreateItem(4) - }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void SingleGameSingleTypeGoodAccept() { - HashSet inventory = new() { CreateItem(1, 2) }; - HashSet itemsToGive = new() { CreateItem(1) }; - HashSet itemsToReceive = new() { CreateItem(2) }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void SingleGameSingleTypeNeutralAccept() { - HashSet inventory = new() { CreateItem(1) }; - HashSet itemsToGive = new() { CreateItem(1) }; - HashSet itemsToReceive = new() { CreateItem(2) }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - [TestMethod] - public void SingleGameSingleTypeNeutralWithOverpayingAccept() { - HashSet inventory = new() { - CreateItem(1, 2), - CreateItem(2, 2) - }; - - HashSet itemsToGive = new() { CreateItem(2) }; - - HashSet itemsToReceive = new() { - CreateItem(1), - CreateItem(3) - }; - - Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); - Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); - } - - private static Asset CreateItem(ulong classID, uint amount = 1, uint realAppID = Asset.SteamAppID, Asset.EType type = Asset.EType.TradingCard, Asset.ERarity rarity = Asset.ERarity.Common) => new(Asset.SteamAppID, Asset.SteamCommunityContextID, classID, amount, realAppID: realAppID, type: type, rarity: rarity); + Assert.IsFalse(IsFairExchange(itemsToGive, itemsToReceive)); } + + [TestMethod] + public void MismatchRealAppIDsIsNotFair() { + HashSet itemsToGive = new() { CreateItem(1, realAppID: 570) }; + HashSet itemsToReceive = new() { CreateItem(2) }; + + Assert.IsFalse(IsFairExchange(itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void MismatchTypesIsNotFair() { + HashSet itemsToGive = new() { CreateItem(1, type: Asset.EType.Emoticon) }; + HashSet itemsToReceive = new() { CreateItem(2) }; + + Assert.IsFalse(IsFairExchange(itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void MultiGameMultiTypeBadReject() { + HashSet inventory = new() { + CreateItem(1, 9), + CreateItem(3, 9, 730, Asset.EType.Emoticon), + CreateItem(4, realAppID: 730, type: Asset.EType.Emoticon) + }; + + HashSet itemsToGive = new() { + CreateItem(1), + CreateItem(4, realAppID: 730, type: Asset.EType.Emoticon) + }; + + HashSet itemsToReceive = new() { + CreateItem(2), + CreateItem(3, realAppID: 730, type: Asset.EType.Emoticon) + }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void MultiGameMultiTypeNeutralAccept() { + HashSet inventory = new() { + CreateItem(1, 9), + CreateItem(3, realAppID: 730, type: Asset.EType.Emoticon) + }; + + HashSet itemsToGive = new() { + CreateItem(1), + CreateItem(3, realAppID: 730, type: Asset.EType.Emoticon) + }; + + HashSet itemsToReceive = new() { + CreateItem(2), + CreateItem(4, realAppID: 730, type: Asset.EType.Emoticon) + }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void MultiGameSingleTypeBadReject() { + HashSet inventory = new() { + CreateItem(1, 9), + CreateItem(3, realAppID: 730), + CreateItem(4, realAppID: 730) + }; + + HashSet itemsToGive = new() { + CreateItem(1), + CreateItem(3, realAppID: 730) + }; + + HashSet itemsToReceive = new() { + CreateItem(2), + CreateItem(4, realAppID: 730) + }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void MultiGameSingleTypeNeutralAccept() { + HashSet inventory = new() { + CreateItem(1, 2), + CreateItem(3, realAppID: 730) + }; + + HashSet itemsToGive = new() { + CreateItem(1), + CreateItem(3, realAppID: 730) + }; + + HashSet itemsToReceive = new() { + CreateItem(2), + CreateItem(4, realAppID: 730) + }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void SingleGameAbrynosWasWrongNeutralAccept() { + HashSet inventory = new() { + CreateItem(1), + CreateItem(2, 2), + CreateItem(3), + CreateItem(4), + CreateItem(5) + }; + + HashSet itemsToGive = new() { + CreateItem(2) + }; + + HashSet itemsToReceive = new() { + CreateItem(3) + }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void SingleGameDonationAccept() { + HashSet inventory = new() { + CreateItem(1) + }; + + HashSet itemsToGive = new() { + CreateItem(1) + }; + + HashSet itemsToReceive = new() { + CreateItem(2), + CreateItem(3, type: Asset.EType.SteamGems) + }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void SingleGameMultiTypeBadReject() { + HashSet inventory = new() { + CreateItem(1, 9), + CreateItem(3, 9, type: Asset.EType.Emoticon), + CreateItem(4, type: Asset.EType.Emoticon) + }; + + HashSet itemsToGive = new() { + CreateItem(1), + CreateItem(4, type: Asset.EType.Emoticon) + }; + + HashSet itemsToReceive = new() { + CreateItem(2), + CreateItem(3, type: Asset.EType.Emoticon) + }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void SingleGameMultiTypeNeutralAccept() { + HashSet inventory = new() { + CreateItem(1, 9), + CreateItem(3, type: Asset.EType.Emoticon) + }; + + HashSet itemsToGive = new() { + CreateItem(1), + CreateItem(3, type: Asset.EType.Emoticon) + }; + + HashSet itemsToReceive = new() { + CreateItem(2), + CreateItem(4, type: Asset.EType.Emoticon) + }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void SingleGameQuantityBadReject() { + HashSet inventory = new() { + CreateItem(1), + CreateItem(2), + CreateItem(3) + }; + + HashSet itemsToGive = new() { + CreateItem(1), + CreateItem(2), + CreateItem(3) + }; + + HashSet itemsToReceive = new() { CreateItem(4, 3) }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void SingleGameQuantityBadReject2() { + HashSet inventory = new() { + CreateItem(1), + CreateItem(2, 2) + }; + + HashSet itemsToGive = new() { + CreateItem(1), + CreateItem(2, 2) + }; + + HashSet itemsToReceive = new() { CreateItem(3, 3) }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void SingleGameQuantityNeutralAccept() { + HashSet inventory = new() { + CreateItem(1, 2), + CreateItem(2) + }; + + HashSet itemsToGive = new() { + CreateItem(1), + CreateItem(2) + }; + + HashSet itemsToReceive = new() { CreateItem(3, 2) }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void SingleGameSingleTypeBadReject() { + HashSet inventory = new() { + CreateItem(1), + CreateItem(2) + }; + + HashSet itemsToGive = new() { CreateItem(1) }; + HashSet itemsToReceive = new() { CreateItem(2) }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void SingleGameSingleTypeBadWithOverpayingReject() { + HashSet inventory = new() { + CreateItem(1, 2), + CreateItem(2, 2), + CreateItem(3, 2) + }; + + HashSet itemsToGive = new() { CreateItem(2) }; + + HashSet itemsToReceive = new() { + CreateItem(1), + CreateItem(3) + }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void SingleGameSingleTypeBigDifferenceAccept() { + HashSet inventory = new() { + CreateItem(1), + CreateItem(2, 5), + CreateItem(3) + }; + + HashSet itemsToGive = new() { CreateItem(2) }; + HashSet itemsToReceive = new() { CreateItem(3) }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void SingleGameSingleTypeBigDifferenceReject() { + HashSet inventory = new() { + CreateItem(1), + CreateItem(2, 2), + CreateItem(3, 2), + CreateItem(4, 3), + CreateItem(5, 10) + }; + + HashSet itemsToGive = new() { + CreateItem(2), + CreateItem(5) + }; + + HashSet itemsToReceive = new() { + CreateItem(3), + CreateItem(4) + }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void SingleGameSingleTypeGoodAccept() { + HashSet inventory = new() { CreateItem(1, 2) }; + HashSet itemsToGive = new() { CreateItem(1) }; + HashSet itemsToReceive = new() { CreateItem(2) }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void SingleGameSingleTypeNeutralAccept() { + HashSet inventory = new() { CreateItem(1) }; + HashSet itemsToGive = new() { CreateItem(1) }; + HashSet itemsToReceive = new() { CreateItem(2) }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + [TestMethod] + public void SingleGameSingleTypeNeutralWithOverpayingAccept() { + HashSet inventory = new() { + CreateItem(1, 2), + CreateItem(2, 2) + }; + + HashSet itemsToGive = new() { CreateItem(2) }; + + HashSet itemsToReceive = new() { + CreateItem(1), + CreateItem(3) + }; + + Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive)); + Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive)); + } + + private static Asset CreateItem(ulong classID, uint amount = 1, uint realAppID = Asset.SteamAppID, Asset.EType type = Asset.EType.TradingCard, Asset.ERarity rarity = Asset.ERarity.Common) => new(Asset.SteamAppID, Asset.SteamCommunityContextID, classID, amount, realAppID: realAppID, type: type, rarity: rarity); } diff --git a/ArchiSteamFarm.Tests/Utilities.cs b/ArchiSteamFarm.Tests/Utilities.cs index a6096c454..baec24cf5 100644 --- a/ArchiSteamFarm.Tests/Utilities.cs +++ b/ArchiSteamFarm.Tests/Utilities.cs @@ -23,30 +23,30 @@ using System.Collections.Generic; using Microsoft.VisualStudio.TestTools.UnitTesting; using static ArchiSteamFarm.Core.Utilities; -namespace ArchiSteamFarm.Tests { - [TestClass] +namespace ArchiSteamFarm.Tests; + +[TestClass] #pragma warning disable CA1724 // We don't care about the potential conflict, as ASF class name has a priority - public sealed class Utilities { - [TestMethod] - public void AdditionallyForbiddenWordsWeakenPassphrases() => Assert.IsTrue(TestPasswordStrength("10charsasdf", new HashSet { "chars" }).IsWeak); +public sealed class Utilities { + [TestMethod] + public void AdditionallyForbiddenWordsWeakenPassphrases() => Assert.IsTrue(TestPasswordStrength("10charsasdf", new HashSet { "chars" }).IsWeak); - [TestMethod] - public void ContextSpecificWordsWeakenPassphrases() => Assert.IsTrue(TestPasswordStrength("archisteamfarmpassword").IsWeak); + [TestMethod] + public void ContextSpecificWordsWeakenPassphrases() => Assert.IsTrue(TestPasswordStrength("archisteamfarmpassword").IsWeak); - [TestMethod] - public void LongPassphraseIsNotWeak() => Assert.IsFalse(TestPasswordStrength("10charsasdf").IsWeak); + [TestMethod] + public void LongPassphraseIsNotWeak() => Assert.IsFalse(TestPasswordStrength("10charsasdf").IsWeak); - [TestMethod] - public void RepetitiveCharactersWeakenPassphrases() => Assert.IsTrue(TestPasswordStrength("testaaaatest").IsWeak); + [TestMethod] + public void RepetitiveCharactersWeakenPassphrases() => Assert.IsTrue(TestPasswordStrength("testaaaatest").IsWeak); - [TestMethod] - public void SequentialCharactersWeakenPassphrases() => Assert.IsTrue(TestPasswordStrength("testabcdtest").IsWeak); + [TestMethod] + public void SequentialCharactersWeakenPassphrases() => Assert.IsTrue(TestPasswordStrength("testabcdtest").IsWeak); - [TestMethod] - public void SequentialDescendingCharactersWeakenPassphrases() => Assert.IsTrue(TestPasswordStrength("testdcbatest").IsWeak); + [TestMethod] + public void SequentialDescendingCharactersWeakenPassphrases() => Assert.IsTrue(TestPasswordStrength("testdcbatest").IsWeak); - [TestMethod] - public void ShortPassphraseIsWeak() => Assert.IsTrue(TestPasswordStrength("four").IsWeak); - } -#pragma warning restore CA1724 // We don't care about the potential conflict, as ASF class name has a priority + [TestMethod] + public void ShortPassphraseIsWeak() => Assert.IsTrue(TestPasswordStrength("four").IsWeak); } +#pragma warning restore CA1724 // We don't care about the potential conflict, as ASF class name has a priority diff --git a/ArchiSteamFarm/Collections/ConcurrentEnumerator.cs b/ArchiSteamFarm/Collections/ConcurrentEnumerator.cs index 71f6d86f4..029474320 100644 --- a/ArchiSteamFarm/Collections/ConcurrentEnumerator.cs +++ b/ArchiSteamFarm/Collections/ConcurrentEnumerator.cs @@ -23,30 +23,30 @@ using System; using System.Collections; using System.Collections.Generic; -namespace ArchiSteamFarm.Collections { - internal sealed class ConcurrentEnumerator : IEnumerator { - public T Current => Enumerator.Current; +namespace ArchiSteamFarm.Collections; - private readonly IEnumerator Enumerator; - private readonly IDisposable LockObject; +internal sealed class ConcurrentEnumerator : IEnumerator { + public T Current => Enumerator.Current; - object? IEnumerator.Current => Current; + private readonly IEnumerator Enumerator; + private readonly IDisposable LockObject; - internal ConcurrentEnumerator(IReadOnlyCollection collection, IDisposable lockObject) { - if (collection == null) { - throw new ArgumentNullException(nameof(collection)); - } + object? IEnumerator.Current => Current; - LockObject = lockObject ?? throw new ArgumentNullException(nameof(lockObject)); - Enumerator = collection.GetEnumerator(); + internal ConcurrentEnumerator(IReadOnlyCollection collection, IDisposable lockObject) { + if (collection == null) { + throw new ArgumentNullException(nameof(collection)); } - public void Dispose() { - Enumerator.Dispose(); - LockObject.Dispose(); - } - - public bool MoveNext() => Enumerator.MoveNext(); - public void Reset() => Enumerator.Reset(); + LockObject = lockObject ?? throw new ArgumentNullException(nameof(lockObject)); + Enumerator = collection.GetEnumerator(); } + + public void Dispose() { + Enumerator.Dispose(); + LockObject.Dispose(); + } + + public bool MoveNext() => Enumerator.MoveNext(); + public void Reset() => Enumerator.Reset(); } diff --git a/ArchiSteamFarm/Collections/ConcurrentHashSet.cs b/ArchiSteamFarm/Collections/ConcurrentHashSet.cs index 0d51287f3..cac0fe8d0 100644 --- a/ArchiSteamFarm/Collections/ConcurrentHashSet.cs +++ b/ArchiSteamFarm/Collections/ConcurrentHashSet.cs @@ -26,180 +26,180 @@ using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; -namespace ArchiSteamFarm.Collections { - public sealed class ConcurrentHashSet : IReadOnlyCollection, ISet where T : notnull { - public event EventHandler? OnModified; +namespace ArchiSteamFarm.Collections; - public int Count => BackingCollection.Count; - public bool IsReadOnly => false; +public sealed class ConcurrentHashSet : IReadOnlyCollection, ISet where T : notnull { + public event EventHandler? OnModified; - private readonly ConcurrentDictionary BackingCollection; + public int Count => BackingCollection.Count; + public bool IsReadOnly => false; - public ConcurrentHashSet() => BackingCollection = new ConcurrentDictionary(); + private readonly ConcurrentDictionary BackingCollection; - public ConcurrentHashSet(IEqualityComparer comparer) { - if (comparer == null) { - throw new ArgumentNullException(nameof(comparer)); - } + public ConcurrentHashSet() => BackingCollection = new ConcurrentDictionary(); - BackingCollection = new ConcurrentDictionary(comparer); + public ConcurrentHashSet(IEqualityComparer comparer) { + if (comparer == null) { + throw new ArgumentNullException(nameof(comparer)); } - public bool Add(T item) { - if (!BackingCollection.TryAdd(item, true)) { - return false; - } + BackingCollection = new ConcurrentDictionary(comparer); + } - OnModified?.Invoke(this, EventArgs.Empty); - - return true; + public bool Add(T item) { + if (!BackingCollection.TryAdd(item, true)) { + return false; } - public void Clear() { - if (BackingCollection.IsEmpty) { - return; - } + OnModified?.Invoke(this, EventArgs.Empty); - BackingCollection.Clear(); + return true; + } - OnModified?.Invoke(this, EventArgs.Empty); + public void Clear() { + if (BackingCollection.IsEmpty) { + return; } - public bool Contains(T item) => BackingCollection.ContainsKey(item); + BackingCollection.Clear(); - public void CopyTo(T[] array, int arrayIndex) => BackingCollection.Keys.CopyTo(array, arrayIndex); + OnModified?.Invoke(this, EventArgs.Empty); + } - public void ExceptWith(IEnumerable other) { - if (other == null) { - throw new ArgumentNullException(nameof(other)); - } + public bool Contains(T item) => BackingCollection.ContainsKey(item); - foreach (T item in other) { - Remove(item); - } + public void CopyTo(T[] array, int arrayIndex) => BackingCollection.Keys.CopyTo(array, arrayIndex); + + public void ExceptWith(IEnumerable other) { + if (other == null) { + throw new ArgumentNullException(nameof(other)); } - public IEnumerator GetEnumerator() => BackingCollection.Keys.GetEnumerator(); - - public void IntersectWith(IEnumerable other) { - ISet otherSet = other as ISet ?? other.ToHashSet(); - - foreach (T item in this.Where(item => !otherSet.Contains(item))) { - Remove(item); - } - } - - public bool IsProperSubsetOf(IEnumerable other) { - ISet otherSet = other as ISet ?? other.ToHashSet(); - - return (otherSet.Count > Count) && IsSubsetOf(otherSet); - } - - public bool IsProperSupersetOf(IEnumerable other) { - ISet otherSet = other as ISet ?? other.ToHashSet(); - - return (otherSet.Count < Count) && IsSupersetOf(otherSet); - } - - public bool IsSubsetOf(IEnumerable other) { - ISet otherSet = other as ISet ?? other.ToHashSet(); - - return this.All(otherSet.Contains); - } - - public bool IsSupersetOf(IEnumerable other) { - ISet otherSet = other as ISet ?? other.ToHashSet(); - - return otherSet.All(Contains); - } - - public bool Overlaps(IEnumerable other) { - ISet otherSet = other as ISet ?? other.ToHashSet(); - - return otherSet.Any(Contains); - } - - public bool Remove(T item) { - if (!BackingCollection.TryRemove(item, out _)) { - return false; - } - - OnModified?.Invoke(this, EventArgs.Empty); - - return true; - } - - public bool SetEquals(IEnumerable other) { - ISet otherSet = other as ISet ?? other.ToHashSet(); - - return (otherSet.Count == Count) && otherSet.All(Contains); - } - - public void SymmetricExceptWith(IEnumerable other) { - ISet otherSet = other as ISet ?? other.ToHashSet(); - HashSet removed = new(); - - foreach (T item in otherSet.Where(Contains)) { - removed.Add(item); - Remove(item); - } - - foreach (T item in otherSet.Where(item => !removed.Contains(item))) { - Add(item); - } - } - - public void UnionWith(IEnumerable other) { - if (other == null) { - throw new ArgumentNullException(nameof(other)); - } - - foreach (T otherElement in other) { - Add(otherElement); - } - } - - void ICollection.Add(T item) => Add(item); - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - [PublicAPI] - public bool AddRange(IEnumerable items) { - bool result = false; - - foreach (T _ in items.Where(Add)) { - result = true; - } - - return result; - } - - [PublicAPI] - public bool RemoveRange(IEnumerable items) { - bool result = false; - - foreach (T _ in items.Where(Remove)) { - result = true; - } - - return result; - } - - [PublicAPI] - public bool ReplaceIfNeededWith(IReadOnlyCollection other) { - if (SetEquals(other)) { - return false; - } - - ReplaceWith(other); - - return true; - } - - [PublicAPI] - public void ReplaceWith(IEnumerable other) { - Clear(); - UnionWith(other); + foreach (T item in other) { + Remove(item); } } + + public IEnumerator GetEnumerator() => BackingCollection.Keys.GetEnumerator(); + + public void IntersectWith(IEnumerable other) { + ISet otherSet = other as ISet ?? other.ToHashSet(); + + foreach (T item in this.Where(item => !otherSet.Contains(item))) { + Remove(item); + } + } + + public bool IsProperSubsetOf(IEnumerable other) { + ISet otherSet = other as ISet ?? other.ToHashSet(); + + return (otherSet.Count > Count) && IsSubsetOf(otherSet); + } + + public bool IsProperSupersetOf(IEnumerable other) { + ISet otherSet = other as ISet ?? other.ToHashSet(); + + return (otherSet.Count < Count) && IsSupersetOf(otherSet); + } + + public bool IsSubsetOf(IEnumerable other) { + ISet otherSet = other as ISet ?? other.ToHashSet(); + + return this.All(otherSet.Contains); + } + + public bool IsSupersetOf(IEnumerable other) { + ISet otherSet = other as ISet ?? other.ToHashSet(); + + return otherSet.All(Contains); + } + + public bool Overlaps(IEnumerable other) { + ISet otherSet = other as ISet ?? other.ToHashSet(); + + return otherSet.Any(Contains); + } + + public bool Remove(T item) { + if (!BackingCollection.TryRemove(item, out _)) { + return false; + } + + OnModified?.Invoke(this, EventArgs.Empty); + + return true; + } + + public bool SetEquals(IEnumerable other) { + ISet otherSet = other as ISet ?? other.ToHashSet(); + + return (otherSet.Count == Count) && otherSet.All(Contains); + } + + public void SymmetricExceptWith(IEnumerable other) { + ISet otherSet = other as ISet ?? other.ToHashSet(); + HashSet removed = new(); + + foreach (T item in otherSet.Where(Contains)) { + removed.Add(item); + Remove(item); + } + + foreach (T item in otherSet.Where(item => !removed.Contains(item))) { + Add(item); + } + } + + public void UnionWith(IEnumerable other) { + if (other == null) { + throw new ArgumentNullException(nameof(other)); + } + + foreach (T otherElement in other) { + Add(otherElement); + } + } + + void ICollection.Add(T item) => Add(item); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + [PublicAPI] + public bool AddRange(IEnumerable items) { + bool result = false; + + foreach (T _ in items.Where(Add)) { + result = true; + } + + return result; + } + + [PublicAPI] + public bool RemoveRange(IEnumerable items) { + bool result = false; + + foreach (T _ in items.Where(Remove)) { + result = true; + } + + return result; + } + + [PublicAPI] + public bool ReplaceIfNeededWith(IReadOnlyCollection other) { + if (SetEquals(other)) { + return false; + } + + ReplaceWith(other); + + return true; + } + + [PublicAPI] + public void ReplaceWith(IEnumerable other) { + Clear(); + UnionWith(other); + } } diff --git a/ArchiSteamFarm/Collections/ConcurrentList.cs b/ArchiSteamFarm/Collections/ConcurrentList.cs index d315f523c..f3e35d6d7 100644 --- a/ArchiSteamFarm/Collections/ConcurrentList.cs +++ b/ArchiSteamFarm/Collections/ConcurrentList.cs @@ -23,95 +23,95 @@ using System.Collections; using System.Collections.Generic; using Nito.AsyncEx; -namespace ArchiSteamFarm.Collections { - internal sealed class ConcurrentList : IList, IReadOnlyList { - public bool IsReadOnly => false; +namespace ArchiSteamFarm.Collections; - internal int Count { - get { - using (Lock.ReaderLock()) { - return BackingCollection.Count; - } - } - } +internal sealed class ConcurrentList : IList, IReadOnlyList { + public bool IsReadOnly => false; - private readonly List BackingCollection = new(); - private readonly AsyncReaderWriterLock Lock = new(); - - int ICollection.Count => Count; - int IReadOnlyCollection.Count => Count; - - public T this[int index] { - get { - using (Lock.ReaderLock()) { - return BackingCollection[index]; - } - } - - set { - using (Lock.WriterLock()) { - BackingCollection[index] = value; - } - } - } - - public void Add(T item) { - using (Lock.WriterLock()) { - BackingCollection.Add(item); - } - } - - public void Clear() { - using (Lock.WriterLock()) { - BackingCollection.Clear(); - } - } - - public bool Contains(T item) { + internal int Count { + get { using (Lock.ReaderLock()) { - return BackingCollection.Contains(item); - } - } - - public void CopyTo(T[] array, int arrayIndex) { - using (Lock.ReaderLock()) { - BackingCollection.CopyTo(array, arrayIndex); - } - } - - public IEnumerator GetEnumerator() => new ConcurrentEnumerator(BackingCollection, Lock.ReaderLock()); - - public int IndexOf(T item) { - using (Lock.ReaderLock()) { - return BackingCollection.IndexOf(item); - } - } - - public void Insert(int index, T item) { - using (Lock.WriterLock()) { - BackingCollection.Insert(index, item); - } - } - - public bool Remove(T item) { - using (Lock.WriterLock()) { - return BackingCollection.Remove(item); - } - } - - public void RemoveAt(int index) { - using (Lock.WriterLock()) { - BackingCollection.RemoveAt(index); - } - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - internal void ReplaceWith(IEnumerable collection) { - using (Lock.WriterLock()) { - BackingCollection.Clear(); - BackingCollection.AddRange(collection); + return BackingCollection.Count; } } } + + private readonly List BackingCollection = new(); + private readonly AsyncReaderWriterLock Lock = new(); + + int ICollection.Count => Count; + int IReadOnlyCollection.Count => Count; + + public T this[int index] { + get { + using (Lock.ReaderLock()) { + return BackingCollection[index]; + } + } + + set { + using (Lock.WriterLock()) { + BackingCollection[index] = value; + } + } + } + + public void Add(T item) { + using (Lock.WriterLock()) { + BackingCollection.Add(item); + } + } + + public void Clear() { + using (Lock.WriterLock()) { + BackingCollection.Clear(); + } + } + + public bool Contains(T item) { + using (Lock.ReaderLock()) { + return BackingCollection.Contains(item); + } + } + + public void CopyTo(T[] array, int arrayIndex) { + using (Lock.ReaderLock()) { + BackingCollection.CopyTo(array, arrayIndex); + } + } + + public IEnumerator GetEnumerator() => new ConcurrentEnumerator(BackingCollection, Lock.ReaderLock()); + + public int IndexOf(T item) { + using (Lock.ReaderLock()) { + return BackingCollection.IndexOf(item); + } + } + + public void Insert(int index, T item) { + using (Lock.WriterLock()) { + BackingCollection.Insert(index, item); + } + } + + public bool Remove(T item) { + using (Lock.WriterLock()) { + return BackingCollection.Remove(item); + } + } + + public void RemoveAt(int index) { + using (Lock.WriterLock()) { + BackingCollection.RemoveAt(index); + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + internal void ReplaceWith(IEnumerable collection) { + using (Lock.WriterLock()) { + BackingCollection.Clear(); + BackingCollection.AddRange(collection); + } + } } diff --git a/ArchiSteamFarm/Collections/FixedSizeConcurrentQueue.cs b/ArchiSteamFarm/Collections/FixedSizeConcurrentQueue.cs index e058d66fe..19c96bdee 100644 --- a/ArchiSteamFarm/Collections/FixedSizeConcurrentQueue.cs +++ b/ArchiSteamFarm/Collections/FixedSizeConcurrentQueue.cs @@ -25,53 +25,53 @@ using System.Collections.Concurrent; using System.Collections.Generic; using ArchiSteamFarm.Core; -namespace ArchiSteamFarm.Collections { - internal sealed class FixedSizeConcurrentQueue : IEnumerable { - private readonly ConcurrentQueue BackingQueue = new(); +namespace ArchiSteamFarm.Collections; - internal byte MaxCount { - get => BackingMaxCount; +internal sealed class FixedSizeConcurrentQueue : IEnumerable { + private readonly ConcurrentQueue BackingQueue = new(); - set { - if (value == 0) { - ASF.ArchiLogger.LogNullError(nameof(value)); + internal byte MaxCount { + get => BackingMaxCount; - return; - } + set { + if (value == 0) { + ASF.ArchiLogger.LogNullError(nameof(value)); - BackingMaxCount = value; - - Resize(); - } - } - - private byte BackingMaxCount; - - internal FixedSizeConcurrentQueue(byte maxCount) { - if (maxCount == 0) { - throw new ArgumentNullException(nameof(maxCount)); - } - - MaxCount = maxCount; - } - - public IEnumerator GetEnumerator() => BackingQueue.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - internal void Enqueue(T obj) { - BackingQueue.Enqueue(obj); - - Resize(); - } - - private void Resize() { - if (BackingQueue.Count <= MaxCount) { return; } - lock (BackingQueue) { - while ((BackingQueue.Count > MaxCount) && BackingQueue.TryDequeue(out _)) { } - } + BackingMaxCount = value; + + Resize(); + } + } + + private byte BackingMaxCount; + + internal FixedSizeConcurrentQueue(byte maxCount) { + if (maxCount == 0) { + throw new ArgumentNullException(nameof(maxCount)); + } + + MaxCount = maxCount; + } + + public IEnumerator GetEnumerator() => BackingQueue.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + internal void Enqueue(T obj) { + BackingQueue.Enqueue(obj); + + Resize(); + } + + private void Resize() { + if (BackingQueue.Count <= MaxCount) { + return; + } + + lock (BackingQueue) { + while ((BackingQueue.Count > MaxCount) && BackingQueue.TryDequeue(out _)) { } } } } diff --git a/ArchiSteamFarm/Core/ASF.cs b/ArchiSteamFarm/Core/ASF.cs index 059abc281..b9a34d925 100644 --- a/ArchiSteamFarm/Core/ASF.cs +++ b/ArchiSteamFarm/Core/ASF.cs @@ -47,1000 +47,1000 @@ using JetBrains.Annotations; using SteamKit2; using SteamKit2.Discovery; -namespace ArchiSteamFarm.Core { - public static class ASF { - // This is based on internal Valve guidelines, we're not using it as a hard limit - private const byte MaximumRecommendedBotsCount = 10; +namespace ArchiSteamFarm.Core; - [PublicAPI] - public static readonly ArchiLogger ArchiLogger = new(SharedInfo.ASF); +public static class ASF { + // This is based on internal Valve guidelines, we're not using it as a hard limit + private const byte MaximumRecommendedBotsCount = 10; - [PublicAPI] - public static byte LoadBalancingDelay => Math.Max(GlobalConfig?.LoginLimiterDelay ?? 0, GlobalConfig.DefaultLoginLimiterDelay); + [PublicAPI] + public static readonly ArchiLogger ArchiLogger = new(SharedInfo.ASF); - [PublicAPI] - public static GlobalConfig? GlobalConfig { get; internal set; } + [PublicAPI] + public static byte LoadBalancingDelay => Math.Max(GlobalConfig?.LoginLimiterDelay ?? 0, GlobalConfig.DefaultLoginLimiterDelay); - [PublicAPI] - public static GlobalDatabase? GlobalDatabase { get; internal set; } + [PublicAPI] + public static GlobalConfig? GlobalConfig { get; internal set; } - [PublicAPI] - public static WebBrowser? WebBrowser { get; private set; } + [PublicAPI] + public static GlobalDatabase? GlobalDatabase { get; internal set; } - internal static ICrossProcessSemaphore? ConfirmationsSemaphore { get; private set; } - internal static ICrossProcessSemaphore? GiftsSemaphore { get; private set; } - internal static ICrossProcessSemaphore? InventorySemaphore { get; private set; } - internal static ICrossProcessSemaphore? LoginRateLimitingSemaphore { get; private set; } - internal static ICrossProcessSemaphore? LoginSemaphore { get; private set; } - internal static ICrossProcessSemaphore? RateLimitingSemaphore { get; private set; } - internal static ImmutableDictionary? WebLimitingSemaphores { get; private set; } + [PublicAPI] + public static WebBrowser? WebBrowser { get; private set; } - private static readonly SemaphoreSlim UpdateSemaphore = new(1, 1); + internal static ICrossProcessSemaphore? ConfirmationsSemaphore { get; private set; } + internal static ICrossProcessSemaphore? GiftsSemaphore { get; private set; } + internal static ICrossProcessSemaphore? InventorySemaphore { get; private set; } + internal static ICrossProcessSemaphore? LoginRateLimitingSemaphore { get; private set; } + internal static ICrossProcessSemaphore? LoginSemaphore { get; private set; } + internal static ICrossProcessSemaphore? RateLimitingSemaphore { get; private set; } + internal static ImmutableDictionary? WebLimitingSemaphores { get; private set; } - private static Timer? AutoUpdatesTimer; - private static FileSystemWatcher? FileSystemWatcher; - private static ConcurrentDictionary? LastWriteEvents; + private static readonly SemaphoreSlim UpdateSemaphore = new(1, 1); - [PublicAPI] - public static bool IsOwner(ulong steamID) { - if (steamID == 0) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + private static Timer? AutoUpdatesTimer; + private static FileSystemWatcher? FileSystemWatcher; + private static ConcurrentDictionary? LastWriteEvents; - return (steamID == GlobalConfig?.SteamOwnerID) || (Debugging.IsDebugBuild && (steamID == SharedInfo.ArchiSteamID)); + [PublicAPI] + public static bool IsOwner(ulong steamID) { + if (steamID == 0) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - internal static string GetFilePath(EFileType fileType) { - if (!Enum.IsDefined(typeof(EFileType), fileType)) { - throw new InvalidEnumArgumentException(nameof(fileType), (int) fileType, typeof(EFileType)); - } + return (steamID == GlobalConfig?.SteamOwnerID) || (Debugging.IsDebugBuild && (steamID == SharedInfo.ArchiSteamID)); + } - return fileType switch { - EFileType.Config => Path.Combine(SharedInfo.ConfigDirectory, SharedInfo.GlobalConfigFileName), - EFileType.Database => Path.Combine(SharedInfo.ConfigDirectory, SharedInfo.GlobalDatabaseFileName), - _ => throw new ArgumentOutOfRangeException(nameof(fileType)) - }; + internal static string GetFilePath(EFileType fileType) { + if (!Enum.IsDefined(typeof(EFileType), fileType)) { + throw new InvalidEnumArgumentException(nameof(fileType), (int) fileType, typeof(EFileType)); } - internal static async Task Init() { - if (GlobalConfig == null) { - throw new InvalidOperationException(nameof(GlobalConfig)); - } + return fileType switch { + EFileType.Config => Path.Combine(SharedInfo.ConfigDirectory, SharedInfo.GlobalConfigFileName), + EFileType.Database => Path.Combine(SharedInfo.ConfigDirectory, SharedInfo.GlobalDatabaseFileName), + _ => throw new ArgumentOutOfRangeException(nameof(fileType)) + }; + } - if (!PluginsCore.InitPlugins()) { - await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); - } - - WebBrowser = new WebBrowser(ArchiLogger, GlobalConfig.WebProxy, true); - - await UpdateAndRestart().ConfigureAwait(false); - - await PluginsCore.OnASFInitModules(GlobalConfig.AdditionalProperties).ConfigureAwait(false); - await InitRateLimiters().ConfigureAwait(false); - - StringComparer botsComparer = await PluginsCore.GetBotsComparer().ConfigureAwait(false); - - InitBotsComparer(botsComparer); - - if (!Program.Service && !GlobalConfig.Headless && !Console.IsInputRedirected) { - Logging.StartInteractiveConsole(); - } - - if (GlobalConfig.IPC) { - await ArchiKestrel.Start().ConfigureAwait(false); - } - - uint changeNumberToStartFrom = await PluginsCore.GetChangeNumberToStartFrom().ConfigureAwait(false); - - SteamPICSChanges.Init(changeNumberToStartFrom); - - await RegisterBots().ConfigureAwait(false); - - if (Program.ConfigWatch) { - InitConfigWatchEvents(); - } + internal static async Task Init() { + if (GlobalConfig == null) { + throw new InvalidOperationException(nameof(GlobalConfig)); } - internal static bool IsValidBotName(string botName) { - if (string.IsNullOrEmpty(botName)) { - throw new ArgumentNullException(nameof(botName)); - } - - if (botName[0] == '.') { - return false; - } - - if (botName.Equals(SharedInfo.ASF, StringComparison.OrdinalIgnoreCase)) { - return false; - } - - return Path.GetRelativePath(".", botName) == botName; + if (!PluginsCore.InitPlugins()) { + await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); } - internal static async Task RestartOrExit() { - if (GlobalConfig == null) { - throw new InvalidOperationException(nameof(GlobalConfig)); - } + WebBrowser = new WebBrowser(ArchiLogger, GlobalConfig.WebProxy, true); - if (Program.RestartAllowed && GlobalConfig.AutoRestart) { - ArchiLogger.LogGenericInfo(Strings.Restarting); - await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false); - await Program.Restart().ConfigureAwait(false); - } else { - ArchiLogger.LogGenericInfo(Strings.Exiting); - await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false); - await Program.Exit().ConfigureAwait(false); - } + await UpdateAndRestart().ConfigureAwait(false); + + await PluginsCore.OnASFInitModules(GlobalConfig.AdditionalProperties).ConfigureAwait(false); + await InitRateLimiters().ConfigureAwait(false); + + StringComparer botsComparer = await PluginsCore.GetBotsComparer().ConfigureAwait(false); + + InitBotsComparer(botsComparer); + + if (!Program.Service && !GlobalConfig.Headless && !Console.IsInputRedirected) { + Logging.StartInteractiveConsole(); } - internal static async Task Update(bool updateOverride = false) { - if (GlobalConfig == null) { - throw new InvalidOperationException(nameof(GlobalConfig)); - } + if (GlobalConfig.IPC) { + await ArchiKestrel.Start().ConfigureAwait(false); + } - if (WebBrowser == null) { - throw new InvalidOperationException(nameof(WebBrowser)); - } + uint changeNumberToStartFrom = await PluginsCore.GetChangeNumberToStartFrom().ConfigureAwait(false); - if (!SharedInfo.BuildInfo.CanUpdate || (GlobalConfig.UpdateChannel == GlobalConfig.EUpdateChannel.None)) { - return null; - } + SteamPICSChanges.Init(changeNumberToStartFrom); - await UpdateSemaphore.WaitAsync().ConfigureAwait(false); + await RegisterBots().ConfigureAwait(false); - try { - // If backup directory from previous update exists, it's a good idea to purge it now - string backupDirectory = Path.Combine(SharedInfo.HomeDirectory, SharedInfo.UpdateDirectory); + if (Program.ConfigWatch) { + InitConfigWatchEvents(); + } + } - if (Directory.Exists(backupDirectory)) { - ArchiLogger.LogGenericInfo(Strings.UpdateCleanup); + internal static bool IsValidBotName(string botName) { + if (string.IsNullOrEmpty(botName)) { + throw new ArgumentNullException(nameof(botName)); + } - // It's entirely possible that old process is still running, wait a short moment for eventual cleanup - await Task.Delay(5000).ConfigureAwait(false); + if (botName[0] == '.') { + return false; + } - try { - Directory.Delete(backupDirectory, true); - } catch (Exception e) { - ArchiLogger.LogGenericException(e); + if (botName.Equals(SharedInfo.ASF, StringComparison.OrdinalIgnoreCase)) { + return false; + } - return null; - } + return Path.GetRelativePath(".", botName) == botName; + } - ArchiLogger.LogGenericInfo(Strings.Done); - } + internal static async Task RestartOrExit() { + if (GlobalConfig == null) { + throw new InvalidOperationException(nameof(GlobalConfig)); + } - ArchiLogger.LogGenericInfo(Strings.UpdateCheckingNewVersion); + if (Program.RestartAllowed && GlobalConfig.AutoRestart) { + ArchiLogger.LogGenericInfo(Strings.Restarting); + await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false); + await Program.Restart().ConfigureAwait(false); + } else { + ArchiLogger.LogGenericInfo(Strings.Exiting); + await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false); + await Program.Exit().ConfigureAwait(false); + } + } - GitHub.ReleaseResponse? releaseResponse = await GitHub.GetLatestRelease(GlobalConfig.UpdateChannel == GlobalConfig.EUpdateChannel.Stable).ConfigureAwait(false); + internal static async Task Update(bool updateOverride = false) { + if (GlobalConfig == null) { + throw new InvalidOperationException(nameof(GlobalConfig)); + } - if (releaseResponse == null) { - ArchiLogger.LogGenericWarning(Strings.ErrorUpdateCheckFailed); + if (WebBrowser == null) { + throw new InvalidOperationException(nameof(WebBrowser)); + } - return null; - } + if (!SharedInfo.BuildInfo.CanUpdate || (GlobalConfig.UpdateChannel == GlobalConfig.EUpdateChannel.None)) { + return null; + } - if (string.IsNullOrEmpty(releaseResponse.Tag)) { - ArchiLogger.LogGenericWarning(Strings.ErrorUpdateCheckFailed); + await UpdateSemaphore.WaitAsync().ConfigureAwait(false); - return null; - } + try { + // If backup directory from previous update exists, it's a good idea to purge it now + string backupDirectory = Path.Combine(SharedInfo.HomeDirectory, SharedInfo.UpdateDirectory); - Version newVersion = new(releaseResponse.Tag); + if (Directory.Exists(backupDirectory)) { + ArchiLogger.LogGenericInfo(Strings.UpdateCleanup); - ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.UpdateVersionInfo, SharedInfo.Version, newVersion)); - - if (SharedInfo.Version >= newVersion) { - return newVersion; - } - - if (!updateOverride && (GlobalConfig.UpdatePeriod == 0)) { - ArchiLogger.LogGenericInfo(Strings.UpdateNewVersionAvailable); - await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false); - - return null; - } - - // Auto update logic starts here - if (releaseResponse.Assets.IsEmpty) { - ArchiLogger.LogGenericWarning(Strings.ErrorUpdateNoAssets); - - return null; - } - - string targetFile = $"{SharedInfo.ASF}-{SharedInfo.BuildInfo.Variant}.zip"; - GitHub.ReleaseResponse.Asset? binaryAsset = releaseResponse.Assets.FirstOrDefault(asset => !string.IsNullOrEmpty(asset.Name) && asset.Name!.Equals(targetFile, StringComparison.OrdinalIgnoreCase)); - - if (binaryAsset == null) { - ArchiLogger.LogGenericWarning(Strings.ErrorUpdateNoAssetForThisVersion); - - return null; - } - - if (binaryAsset.DownloadURL == null) { - ArchiLogger.LogNullError(nameof(binaryAsset.DownloadURL)); - - return null; - } - - if (!string.IsNullOrEmpty(releaseResponse.ChangelogPlainText)) { - ArchiLogger.LogGenericInfo(releaseResponse.ChangelogPlainText!); - } - - ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.UpdateDownloadingNewVersion, newVersion, binaryAsset.Size / 1024 / 1024)); - - Progress progressReporter = new(); - - progressReporter.ProgressChanged += OnProgressChanged; - - BinaryResponse? response; + // It's entirely possible that old process is still running, wait a short moment for eventual cleanup + await Task.Delay(5000).ConfigureAwait(false); try { - response = await WebBrowser.UrlGetToBinary(binaryAsset.DownloadURL!, progressReporter: progressReporter).ConfigureAwait(false); - } finally { - progressReporter.ProgressChanged -= OnProgressChanged; - } - - if (response == null) { - return null; - } - - try { - // We disable ArchiKestrel here as the update process moves the core files and might result in IPC crash - // TODO: It might fail if the update was triggered from the API, this should be something to improve in the future, by changing the structure into request -> return response -> finish update - await ArchiKestrel.Stop().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - } - - MemoryStream ms = new(response.Content as byte[] ?? response.Content.ToArray()); - - try { - await using (ms.ConfigureAwait(false)) { - using ZipArchive zipArchive = new(ms); - - if (!UpdateFromArchive(zipArchive, SharedInfo.HomeDirectory)) { - ArchiLogger.LogGenericError(Strings.WarningFailed); - } - } + Directory.Delete(backupDirectory, true); } catch (Exception e) { ArchiLogger.LogGenericException(e); return null; } - if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { - string executable = Path.Combine(SharedInfo.HomeDirectory, SharedInfo.AssemblyName); + ArchiLogger.LogGenericInfo(Strings.Done); + } - if (File.Exists(executable)) { - OS.UnixSetFileAccess(executable, OS.EUnixPermission.Combined755); - } - } + ArchiLogger.LogGenericInfo(Strings.UpdateCheckingNewVersion); - ArchiLogger.LogGenericInfo(Strings.UpdateFinished); + GitHub.ReleaseResponse? releaseResponse = await GitHub.GetLatestRelease(GlobalConfig.UpdateChannel == GlobalConfig.EUpdateChannel.Stable).ConfigureAwait(false); + if (releaseResponse == null) { + ArchiLogger.LogGenericWarning(Strings.ErrorUpdateCheckFailed); + + return null; + } + + if (string.IsNullOrEmpty(releaseResponse.Tag)) { + ArchiLogger.LogGenericWarning(Strings.ErrorUpdateCheckFailed); + + return null; + } + + Version newVersion = new(releaseResponse.Tag); + + ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.UpdateVersionInfo, SharedInfo.Version, newVersion)); + + if (SharedInfo.Version >= newVersion) { return newVersion; - } finally { - UpdateSemaphore.Release(); } - } - private static async Task CanHandleWriteEvent(string filePath) { - if (string.IsNullOrEmpty(filePath)) { - throw new ArgumentNullException(nameof(filePath)); - } - - if (LastWriteEvents == null) { - throw new InvalidOperationException(nameof(LastWriteEvents)); - } - - // Save our event in dictionary - object currentWriteEvent = new(); - LastWriteEvents[filePath] = currentWriteEvent; - - // Wait a second for eventual other events to arrive - await Task.Delay(1000).ConfigureAwait(false); - - // We're allowed to handle this event if the one that is saved after full second is our event and we succeed in clearing it (we don't care what we're clearing anymore, it doesn't have to be atomic operation) - return LastWriteEvents.TryGetValue(filePath, out object? savedWriteEvent) && (currentWriteEvent == savedWriteEvent) && LastWriteEvents.TryRemove(filePath, out _); - } - - private static void InitBotsComparer(StringComparer botsComparer) { - if (botsComparer == null) { - throw new ArgumentNullException(nameof(botsComparer)); - } - - if (Bot.Bots != null) { - return; - } - - Bot.Init(botsComparer); - } - - private static void InitConfigWatchEvents() { - if ((FileSystemWatcher != null) || (LastWriteEvents != null)) { - return; - } - - if (Bot.BotsComparer == null) { - throw new InvalidOperationException(nameof(Bot.BotsComparer)); - } - - FileSystemWatcher = new FileSystemWatcher(SharedInfo.ConfigDirectory) { NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite }; - - FileSystemWatcher.Changed += OnChanged; - FileSystemWatcher.Created += OnCreated; - FileSystemWatcher.Deleted += OnDeleted; - FileSystemWatcher.Renamed += OnRenamed; - - LastWriteEvents = new ConcurrentDictionary(Bot.BotsComparer); - - FileSystemWatcher.EnableRaisingEvents = true; - } - - private static async Task InitRateLimiters() { - if (GlobalConfig == null) { - throw new InvalidOperationException(nameof(GlobalConfig)); - } - - // The only purpose of using hashingAlgorithm below is to cut on a potential size of the resource name - paths can be really long, and we almost certainly have some upper limit on the resource name we can allocate - // At the same time it'd be the best if we avoided all special characters, such as '/' found e.g. in base64, as we can't be sure that it's not a prohibited character in regards to native OS implementation - // Because of that, SHA256 is sufficient for our case, as it generates alphanumeric characters only, and is barely 256-bit long. We don't need any kind of complex cryptography or collision detection here, any hashing algorithm will do, and the shorter the better - string networkGroupText = ""; - - if (!string.IsNullOrEmpty(Program.NetworkGroup)) { - using SHA256 hashingAlgorithm = SHA256.Create(); - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - networkGroupText = $"-{BitConverter.ToString(hashingAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(Program.NetworkGroup!))).Replace("-", "", StringComparison.Ordinal)}"; - } else if (!string.IsNullOrEmpty(GlobalConfig.WebProxyText)) { - using SHA256 hashingAlgorithm = SHA256.Create(); - - networkGroupText = $"-{BitConverter.ToString(hashingAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(GlobalConfig.WebProxyText!))).Replace("-", "", StringComparison.Ordinal)}"; - } - - ConfirmationsSemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(ConfirmationsSemaphore) + networkGroupText).ConfigureAwait(false); - GiftsSemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(GiftsSemaphore) + networkGroupText).ConfigureAwait(false); - InventorySemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(InventorySemaphore) + networkGroupText).ConfigureAwait(false); - LoginRateLimitingSemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(LoginRateLimitingSemaphore) + networkGroupText).ConfigureAwait(false); - LoginSemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(LoginSemaphore) + networkGroupText).ConfigureAwait(false); - RateLimitingSemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(RateLimitingSemaphore) + networkGroupText).ConfigureAwait(false); - - WebLimitingSemaphores ??= new Dictionary(4) { - { ArchiWebHandler.SteamCommunityURL, (await PluginsCore.GetCrossProcessSemaphore($"{nameof(ArchiWebHandler)}{networkGroupText}-{nameof(ArchiWebHandler.SteamCommunityURL)}").ConfigureAwait(false), new SemaphoreSlim(WebBrowser.MaxConnections, WebBrowser.MaxConnections)) }, - { ArchiWebHandler.SteamHelpURL, (await PluginsCore.GetCrossProcessSemaphore($"{nameof(ArchiWebHandler)}{networkGroupText}-{nameof(ArchiWebHandler.SteamHelpURL)}").ConfigureAwait(false), new SemaphoreSlim(WebBrowser.MaxConnections, WebBrowser.MaxConnections)) }, - { ArchiWebHandler.SteamStoreURL, (await PluginsCore.GetCrossProcessSemaphore($"{nameof(ArchiWebHandler)}{networkGroupText}-{nameof(ArchiWebHandler.SteamStoreURL)}").ConfigureAwait(false), new SemaphoreSlim(WebBrowser.MaxConnections, WebBrowser.MaxConnections)) }, - { WebAPI.DefaultBaseAddress, (await PluginsCore.GetCrossProcessSemaphore($"{nameof(ArchiWebHandler)}{networkGroupText}-{nameof(WebAPI)}").ConfigureAwait(false), new SemaphoreSlim(WebBrowser.MaxConnections, WebBrowser.MaxConnections)) } - }.ToImmutableDictionary(); - } - - private static void LoadAssembliesRecursively(Assembly assembly, HashSet? loadedAssembliesNames = null) { - if (assembly == null) { - throw new ArgumentNullException(nameof(assembly)); - } - - if (loadedAssembliesNames == null) { - Assembly[] loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - loadedAssembliesNames = loadedAssemblies.Select(static loadedAssembly => loadedAssembly.FullName).Where(static name => !string.IsNullOrEmpty(name)).ToHashSet()!; - } - - foreach (AssemblyName assemblyName in assembly.GetReferencedAssemblies().Where(assemblyName => !loadedAssembliesNames.Contains(assemblyName.FullName))) { - loadedAssembliesNames.Add(assemblyName.FullName); - - LoadAssembliesRecursively(Assembly.Load(assemblyName), loadedAssembliesNames); - } - } - - private static async void OnAutoUpdatesTimer(object? state = null) => await UpdateAndRestart().ConfigureAwait(false); - - private static async void OnChanged(object sender, FileSystemEventArgs e) { - if (sender == null) { - throw new ArgumentNullException(nameof(sender)); - } - - if (e == null) { - throw new ArgumentNullException(nameof(e)); - } - - if (string.IsNullOrEmpty(e.Name)) { - throw new InvalidOperationException(nameof(e.Name)); - } - - if (string.IsNullOrEmpty(e.FullPath)) { - throw new InvalidOperationException(nameof(e.FullPath)); - } - - await OnChangedFile(e.Name, e.FullPath).ConfigureAwait(false); - } - - private static async Task OnChangedConfigFile(string name, string fullPath) { - if (string.IsNullOrEmpty(name)) { - throw new ArgumentNullException(nameof(name)); - } - - if (string.IsNullOrEmpty(fullPath)) { - throw new ArgumentNullException(nameof(fullPath)); - } - - await OnCreatedConfigFile(name, fullPath).ConfigureAwait(false); - } - - private static async Task OnChangedConfigFile(string name) { - if (string.IsNullOrEmpty(name)) { - throw new ArgumentNullException(nameof(name)); - } - - if (!name.Equals(SharedInfo.IPCConfigFile, StringComparison.OrdinalIgnoreCase) || (GlobalConfig?.IPC != true)) { - return; - } - - if (!await CanHandleWriteEvent(name).ConfigureAwait(false)) { - return; - } - - ArchiLogger.LogGenericInfo(Strings.IPCConfigChanged); - await ArchiKestrel.Stop().ConfigureAwait(false); - await ArchiKestrel.Start().ConfigureAwait(false); - } - - private static async Task OnChangedFile(string name, string fullPath) { - if (string.IsNullOrEmpty(name)) { - throw new ArgumentNullException(nameof(name)); - } - - if (string.IsNullOrEmpty(fullPath)) { - throw new ArgumentNullException(nameof(fullPath)); - } - - string extension = Path.GetExtension(name); - - switch (extension) { - case SharedInfo.JsonConfigExtension: - case SharedInfo.IPCConfigExtension: - await OnChangedConfigFile(name, fullPath).ConfigureAwait(false); - - break; - case SharedInfo.KeysExtension: - await OnChangedKeysFile(name, fullPath).ConfigureAwait(false); + if (!updateOverride && (GlobalConfig.UpdatePeriod == 0)) { + ArchiLogger.LogGenericInfo(Strings.UpdateNewVersionAvailable); + await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false); - break; + return null; } - } - private static async Task OnChangedKeysFile(string name, string fullPath) { - if (string.IsNullOrEmpty(name)) { - throw new ArgumentNullException(nameof(name)); - } - - if (string.IsNullOrEmpty(fullPath)) { - throw new ArgumentNullException(nameof(fullPath)); - } + // Auto update logic starts here + if (releaseResponse.Assets.IsEmpty) { + ArchiLogger.LogGenericWarning(Strings.ErrorUpdateNoAssets); - await OnCreatedKeysFile(name, fullPath).ConfigureAwait(false); - } - - private static async void OnCreated(object sender, FileSystemEventArgs e) { - if (sender == null) { - throw new ArgumentNullException(nameof(sender)); + return null; } - if (e == null) { - throw new ArgumentNullException(nameof(e)); - } + string targetFile = $"{SharedInfo.ASF}-{SharedInfo.BuildInfo.Variant}.zip"; + GitHub.ReleaseResponse.Asset? binaryAsset = releaseResponse.Assets.FirstOrDefault(asset => !string.IsNullOrEmpty(asset.Name) && asset.Name!.Equals(targetFile, StringComparison.OrdinalIgnoreCase)); - if (string.IsNullOrEmpty(e.Name)) { - throw new InvalidOperationException(nameof(e.Name)); - } + if (binaryAsset == null) { + ArchiLogger.LogGenericWarning(Strings.ErrorUpdateNoAssetForThisVersion); - if (string.IsNullOrEmpty(e.FullPath)) { - throw new InvalidOperationException(nameof(e.FullPath)); + return null; } - await OnCreatedFile(e.Name, e.FullPath).ConfigureAwait(false); - } + if (binaryAsset.DownloadURL == null) { + ArchiLogger.LogNullError(nameof(binaryAsset.DownloadURL)); - private static async Task OnCreatedConfigFile(string name, string fullPath) { - if (string.IsNullOrEmpty(name)) { - throw new ArgumentNullException(nameof(name)); + return null; } - if (string.IsNullOrEmpty(fullPath)) { - throw new ArgumentNullException(nameof(fullPath)); + if (!string.IsNullOrEmpty(releaseResponse.ChangelogPlainText)) { + ArchiLogger.LogGenericInfo(releaseResponse.ChangelogPlainText!); } - string extension = Path.GetExtension(name); + ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.UpdateDownloadingNewVersion, newVersion, binaryAsset.Size / 1024 / 1024)); - switch (extension) { - case SharedInfo.IPCConfigExtension: - await OnChangedConfigFile(name).ConfigureAwait(false); + Progress progressReporter = new(); - break; - case SharedInfo.JsonConfigExtension: - await OnCreatedJsonFile(name, fullPath).ConfigureAwait(false); - - break; - } - } - - private static async Task OnCreatedFile(string name, string fullPath) { - if (string.IsNullOrEmpty(name)) { - throw new ArgumentNullException(nameof(name)); - } - - if (string.IsNullOrEmpty(fullPath)) { - throw new ArgumentNullException(nameof(fullPath)); - } - - string extension = Path.GetExtension(name); - - switch (extension) { - case SharedInfo.JsonConfigExtension: - await OnCreatedConfigFile(name, fullPath).ConfigureAwait(false); - - break; - - case SharedInfo.KeysExtension: - await OnCreatedKeysFile(name, fullPath).ConfigureAwait(false); - - break; - } - } - - private static async Task OnCreatedJsonFile(string name, string fullPath) { - if (string.IsNullOrEmpty(name)) { - throw new ArgumentNullException(nameof(name)); - } - - if (string.IsNullOrEmpty(fullPath)) { - throw new ArgumentNullException(nameof(fullPath)); - } - - if (Bot.Bots == null) { - throw new InvalidOperationException(nameof(Bot.Bots)); - } - - string botName = Path.GetFileNameWithoutExtension(name); - - if (string.IsNullOrEmpty(botName) || (botName[0] == '.')) { - return; - } - - if (!await CanHandleWriteEvent(fullPath).ConfigureAwait(false)) { - return; - } - - if (botName.Equals(SharedInfo.ASF, StringComparison.OrdinalIgnoreCase)) { - ArchiLogger.LogGenericInfo(Strings.GlobalConfigChanged); - await RestartOrExit().ConfigureAwait(false); - - return; - } - - if (!IsValidBotName(botName)) { - return; - } - - if (Bot.Bots.TryGetValue(botName, out Bot? bot)) { - await bot.OnConfigChanged(false).ConfigureAwait(false); - } else { - await Bot.RegisterBot(botName).ConfigureAwait(false); - - if (Bot.Bots.Count > MaximumRecommendedBotsCount) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningExcessiveBotsCount, MaximumRecommendedBotsCount)); - } - } - } - - private static async Task OnCreatedKeysFile(string name, string fullPath) { - if (string.IsNullOrEmpty(name)) { - throw new ArgumentNullException(nameof(name)); - } - - if (string.IsNullOrEmpty(fullPath)) { - throw new ArgumentNullException(nameof(fullPath)); - } - - if (Bot.Bots == null) { - throw new InvalidOperationException(nameof(Bot.Bots)); - } - - string botName = Path.GetFileNameWithoutExtension(name); - - if (string.IsNullOrEmpty(botName) || (botName[0] == '.')) { - return; - } - - if (!await CanHandleWriteEvent(fullPath).ConfigureAwait(false)) { - return; - } - - if (!Bot.Bots.TryGetValue(botName, out Bot? bot)) { - return; - } - - await bot.ImportKeysToRedeem(fullPath).ConfigureAwait(false); - } - - private static async void OnDeleted(object sender, FileSystemEventArgs e) { - if (sender == null) { - throw new ArgumentNullException(nameof(sender)); - } - - if (e == null) { - throw new ArgumentNullException(nameof(e)); - } - - if (string.IsNullOrEmpty(e.Name)) { - throw new InvalidOperationException(nameof(e.Name)); - } - - if (string.IsNullOrEmpty(e.FullPath)) { - throw new InvalidOperationException(nameof(e.FullPath)); - } - - await OnDeletedFile(e.Name, e.FullPath).ConfigureAwait(false); - } - - private static async Task OnDeletedConfigFile(string name, string fullPath) { - if (string.IsNullOrEmpty(name)) { - throw new ArgumentNullException(nameof(name)); - } - - if (string.IsNullOrEmpty(fullPath)) { - throw new ArgumentNullException(nameof(fullPath)); - } - - string extension = Path.GetExtension(name); - - switch (extension) { - case SharedInfo.IPCConfigExtension: - await OnChangedConfigFile(name).ConfigureAwait(false); - - break; - case SharedInfo.JsonConfigExtension: - await OnDeletedJsonConfigFile(name, fullPath).ConfigureAwait(false); - - break; - } - } - - private static async Task OnDeletedFile(string name, string fullPath) { - if (string.IsNullOrEmpty(name)) { - throw new ArgumentNullException(nameof(name)); - } - - if (string.IsNullOrEmpty(fullPath)) { - throw new ArgumentNullException(nameof(fullPath)); - } - - string extension = Path.GetExtension(name); - - switch (extension) { - case SharedInfo.JsonConfigExtension: - case SharedInfo.IPCConfigExtension: - await OnDeletedConfigFile(name, fullPath).ConfigureAwait(false); - - break; - } - } - - private static async Task OnDeletedJsonConfigFile(string name, string fullPath) { - if (string.IsNullOrEmpty(name)) { - throw new ArgumentNullException(nameof(name)); - } - - if (string.IsNullOrEmpty(fullPath)) { - throw new ArgumentNullException(nameof(fullPath)); - } - - if (Bot.Bots == null) { - throw new InvalidOperationException(nameof(Bot.Bots)); - } - - string botName = Path.GetFileNameWithoutExtension(name); - - if (string.IsNullOrEmpty(botName)) { - return; - } - - if (!await CanHandleWriteEvent(fullPath).ConfigureAwait(false)) { - return; - } - - if (botName.Equals(SharedInfo.ASF, StringComparison.OrdinalIgnoreCase)) { - if (File.Exists(fullPath)) { - return; - } - - // Some editors might decide to delete file and re-create it in order to modify it - // If that's the case, we wait for maximum of 5 seconds before shutting down - await Task.Delay(5000).ConfigureAwait(false); - - if (File.Exists(fullPath)) { - return; - } - - ArchiLogger.LogGenericError(Strings.ErrorGlobalConfigRemoved); - await Program.Exit(1).ConfigureAwait(false); - - return; - } - - if (!IsValidBotName(botName)) { - return; - } - - if (Bot.Bots.TryGetValue(botName, out Bot? bot)) { - await bot.OnConfigChanged(true).ConfigureAwait(false); - } - } - - private static void OnProgressChanged(object? sender, byte progressPercentage) { - const byte printEveryPercentage = 10; - - if (progressPercentage % printEveryPercentage != 0) { - return; - } - - ArchiLogger.LogGenericDebug($"{progressPercentage}%..."); - } - - private static async void OnRenamed(object sender, RenamedEventArgs e) { - if (sender == null) { - throw new ArgumentNullException(nameof(sender)); - } - - if (e == null) { - throw new ArgumentNullException(nameof(e)); - } - - if (string.IsNullOrEmpty(e.OldName)) { - throw new InvalidOperationException(nameof(e.OldName)); - } - - if (string.IsNullOrEmpty(e.OldFullPath)) { - throw new InvalidOperationException(nameof(e.OldFullPath)); - } - - if (string.IsNullOrEmpty(e.Name)) { - throw new InvalidOperationException(nameof(e.Name)); - } - - if (string.IsNullOrEmpty(e.FullPath)) { - throw new InvalidOperationException(nameof(e.FullPath)); - } - - await OnDeletedFile(e.OldName, e.OldFullPath).ConfigureAwait(false); - await OnCreatedFile(e.Name, e.FullPath).ConfigureAwait(false); - } - - private static async Task RegisterBots() { - if ((GlobalConfig == null) || (GlobalDatabase == null) || (WebBrowser == null)) { - throw new ArgumentNullException($"{nameof(GlobalConfig)} || {nameof(GlobalDatabase)} || {nameof(WebBrowser)}"); - } - - // Ensure that we ask for a list of servers if we don't have any saved servers available - IEnumerable servers = await GlobalDatabase.ServerListProvider.FetchServerListAsync().ConfigureAwait(false); - - if (!servers.Any()) { - ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.Initializing, nameof(SteamDirectory))); - - SteamConfiguration steamConfiguration = SteamConfiguration.Create(static builder => builder.WithProtocolTypes(GlobalConfig.SteamProtocols).WithCellID(GlobalDatabase.CellID).WithServerListProvider(GlobalDatabase.ServerListProvider).WithHttpClientFactory(static () => WebBrowser.GenerateDisposableHttpClient())); - - try { - await SteamDirectory.LoadAsync(steamConfiguration).ConfigureAwait(false); - ArchiLogger.LogGenericInfo(Strings.Success); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - ArchiLogger.LogGenericWarning(Strings.BotSteamDirectoryInitializationFailed); - } - } + progressReporter.ProgressChanged += OnProgressChanged; - HashSet botNames; + BinaryResponse? response; try { - botNames = Directory.EnumerateFiles(SharedInfo.ConfigDirectory, $"*{SharedInfo.JsonConfigExtension}").Select(Path.GetFileNameWithoutExtension).Where(static botName => !string.IsNullOrEmpty(botName) && IsValidBotName(botName)).ToHashSet(Bot.BotsComparer)!; + response = await WebBrowser.UrlGetToBinary(binaryAsset.DownloadURL!, progressReporter: progressReporter).ConfigureAwait(false); + } finally { + progressReporter.ProgressChanged -= OnProgressChanged; + } + + if (response == null) { + return null; + } + + try { + // We disable ArchiKestrel here as the update process moves the core files and might result in IPC crash + // TODO: It might fail if the update was triggered from the API, this should be something to improve in the future, by changing the structure into request -> return response -> finish update + await ArchiKestrel.Stop().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + } + + MemoryStream ms = new(response.Content as byte[] ?? response.Content.ToArray()); + + try { + await using (ms.ConfigureAwait(false)) { + using ZipArchive zipArchive = new(ms); + + if (!UpdateFromArchive(zipArchive, SharedInfo.HomeDirectory)) { + ArchiLogger.LogGenericError(Strings.WarningFailed); + } + } } catch (Exception e) { ArchiLogger.LogGenericException(e); + return null; + } + + if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { + string executable = Path.Combine(SharedInfo.HomeDirectory, SharedInfo.AssemblyName); + + if (File.Exists(executable)) { + OS.UnixSetFileAccess(executable, OS.EUnixPermission.Combined755); + } + } + + ArchiLogger.LogGenericInfo(Strings.UpdateFinished); + + return newVersion; + } finally { + UpdateSemaphore.Release(); + } + } + + private static async Task CanHandleWriteEvent(string filePath) { + if (string.IsNullOrEmpty(filePath)) { + throw new ArgumentNullException(nameof(filePath)); + } + + if (LastWriteEvents == null) { + throw new InvalidOperationException(nameof(LastWriteEvents)); + } + + // Save our event in dictionary + object currentWriteEvent = new(); + LastWriteEvents[filePath] = currentWriteEvent; + + // Wait a second for eventual other events to arrive + await Task.Delay(1000).ConfigureAwait(false); + + // We're allowed to handle this event if the one that is saved after full second is our event and we succeed in clearing it (we don't care what we're clearing anymore, it doesn't have to be atomic operation) + return LastWriteEvents.TryGetValue(filePath, out object? savedWriteEvent) && (currentWriteEvent == savedWriteEvent) && LastWriteEvents.TryRemove(filePath, out _); + } + + private static void InitBotsComparer(StringComparer botsComparer) { + if (botsComparer == null) { + throw new ArgumentNullException(nameof(botsComparer)); + } + + if (Bot.Bots != null) { + return; + } + + Bot.Init(botsComparer); + } + + private static void InitConfigWatchEvents() { + if ((FileSystemWatcher != null) || (LastWriteEvents != null)) { + return; + } + + if (Bot.BotsComparer == null) { + throw new InvalidOperationException(nameof(Bot.BotsComparer)); + } + + FileSystemWatcher = new FileSystemWatcher(SharedInfo.ConfigDirectory) { NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite }; + + FileSystemWatcher.Changed += OnChanged; + FileSystemWatcher.Created += OnCreated; + FileSystemWatcher.Deleted += OnDeleted; + FileSystemWatcher.Renamed += OnRenamed; + + LastWriteEvents = new ConcurrentDictionary(Bot.BotsComparer); + + FileSystemWatcher.EnableRaisingEvents = true; + } + + private static async Task InitRateLimiters() { + if (GlobalConfig == null) { + throw new InvalidOperationException(nameof(GlobalConfig)); + } + + // The only purpose of using hashingAlgorithm below is to cut on a potential size of the resource name - paths can be really long, and we almost certainly have some upper limit on the resource name we can allocate + // At the same time it'd be the best if we avoided all special characters, such as '/' found e.g. in base64, as we can't be sure that it's not a prohibited character in regards to native OS implementation + // Because of that, SHA256 is sufficient for our case, as it generates alphanumeric characters only, and is barely 256-bit long. We don't need any kind of complex cryptography or collision detection here, any hashing algorithm will do, and the shorter the better + string networkGroupText = ""; + + if (!string.IsNullOrEmpty(Program.NetworkGroup)) { + using SHA256 hashingAlgorithm = SHA256.Create(); + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + networkGroupText = $"-{BitConverter.ToString(hashingAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(Program.NetworkGroup!))).Replace("-", "", StringComparison.Ordinal)}"; + } else if (!string.IsNullOrEmpty(GlobalConfig.WebProxyText)) { + using SHA256 hashingAlgorithm = SHA256.Create(); + + networkGroupText = $"-{BitConverter.ToString(hashingAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(GlobalConfig.WebProxyText!))).Replace("-", "", StringComparison.Ordinal)}"; + } + + ConfirmationsSemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(ConfirmationsSemaphore) + networkGroupText).ConfigureAwait(false); + GiftsSemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(GiftsSemaphore) + networkGroupText).ConfigureAwait(false); + InventorySemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(InventorySemaphore) + networkGroupText).ConfigureAwait(false); + LoginRateLimitingSemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(LoginRateLimitingSemaphore) + networkGroupText).ConfigureAwait(false); + LoginSemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(LoginSemaphore) + networkGroupText).ConfigureAwait(false); + RateLimitingSemaphore ??= await PluginsCore.GetCrossProcessSemaphore(nameof(RateLimitingSemaphore) + networkGroupText).ConfigureAwait(false); + + WebLimitingSemaphores ??= new Dictionary(4) { + { ArchiWebHandler.SteamCommunityURL, (await PluginsCore.GetCrossProcessSemaphore($"{nameof(ArchiWebHandler)}{networkGroupText}-{nameof(ArchiWebHandler.SteamCommunityURL)}").ConfigureAwait(false), new SemaphoreSlim(WebBrowser.MaxConnections, WebBrowser.MaxConnections)) }, + { ArchiWebHandler.SteamHelpURL, (await PluginsCore.GetCrossProcessSemaphore($"{nameof(ArchiWebHandler)}{networkGroupText}-{nameof(ArchiWebHandler.SteamHelpURL)}").ConfigureAwait(false), new SemaphoreSlim(WebBrowser.MaxConnections, WebBrowser.MaxConnections)) }, + { ArchiWebHandler.SteamStoreURL, (await PluginsCore.GetCrossProcessSemaphore($"{nameof(ArchiWebHandler)}{networkGroupText}-{nameof(ArchiWebHandler.SteamStoreURL)}").ConfigureAwait(false), new SemaphoreSlim(WebBrowser.MaxConnections, WebBrowser.MaxConnections)) }, + { WebAPI.DefaultBaseAddress, (await PluginsCore.GetCrossProcessSemaphore($"{nameof(ArchiWebHandler)}{networkGroupText}-{nameof(WebAPI)}").ConfigureAwait(false), new SemaphoreSlim(WebBrowser.MaxConnections, WebBrowser.MaxConnections)) } + }.ToImmutableDictionary(); + } + + private static void LoadAssembliesRecursively(Assembly assembly, HashSet? loadedAssembliesNames = null) { + if (assembly == null) { + throw new ArgumentNullException(nameof(assembly)); + } + + if (loadedAssembliesNames == null) { + Assembly[] loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + loadedAssembliesNames = loadedAssemblies.Select(static loadedAssembly => loadedAssembly.FullName).Where(static name => !string.IsNullOrEmpty(name)).ToHashSet()!; + } + + foreach (AssemblyName assemblyName in assembly.GetReferencedAssemblies().Where(assemblyName => !loadedAssembliesNames.Contains(assemblyName.FullName))) { + loadedAssembliesNames.Add(assemblyName.FullName); + + LoadAssembliesRecursively(Assembly.Load(assemblyName), loadedAssembliesNames); + } + } + + private static async void OnAutoUpdatesTimer(object? state = null) => await UpdateAndRestart().ConfigureAwait(false); + + private static async void OnChanged(object sender, FileSystemEventArgs e) { + if (sender == null) { + throw new ArgumentNullException(nameof(sender)); + } + + if (e == null) { + throw new ArgumentNullException(nameof(e)); + } + + if (string.IsNullOrEmpty(e.Name)) { + throw new InvalidOperationException(nameof(e.Name)); + } + + if (string.IsNullOrEmpty(e.FullPath)) { + throw new InvalidOperationException(nameof(e.FullPath)); + } + + await OnChangedFile(e.Name, e.FullPath).ConfigureAwait(false); + } + + private static async Task OnChangedConfigFile(string name, string fullPath) { + if (string.IsNullOrEmpty(name)) { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrEmpty(fullPath)) { + throw new ArgumentNullException(nameof(fullPath)); + } + + await OnCreatedConfigFile(name, fullPath).ConfigureAwait(false); + } + + private static async Task OnChangedConfigFile(string name) { + if (string.IsNullOrEmpty(name)) { + throw new ArgumentNullException(nameof(name)); + } + + if (!name.Equals(SharedInfo.IPCConfigFile, StringComparison.OrdinalIgnoreCase) || (GlobalConfig?.IPC != true)) { + return; + } + + if (!await CanHandleWriteEvent(name).ConfigureAwait(false)) { + return; + } + + ArchiLogger.LogGenericInfo(Strings.IPCConfigChanged); + await ArchiKestrel.Stop().ConfigureAwait(false); + await ArchiKestrel.Start().ConfigureAwait(false); + } + + private static async Task OnChangedFile(string name, string fullPath) { + if (string.IsNullOrEmpty(name)) { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrEmpty(fullPath)) { + throw new ArgumentNullException(nameof(fullPath)); + } + + string extension = Path.GetExtension(name); + + switch (extension) { + case SharedInfo.JsonConfigExtension: + case SharedInfo.IPCConfigExtension: + await OnChangedConfigFile(name, fullPath).ConfigureAwait(false); + + break; + case SharedInfo.KeysExtension: + await OnChangedKeysFile(name, fullPath).ConfigureAwait(false); + + break; + } + } + + private static async Task OnChangedKeysFile(string name, string fullPath) { + if (string.IsNullOrEmpty(name)) { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrEmpty(fullPath)) { + throw new ArgumentNullException(nameof(fullPath)); + } + + await OnCreatedKeysFile(name, fullPath).ConfigureAwait(false); + } + + private static async void OnCreated(object sender, FileSystemEventArgs e) { + if (sender == null) { + throw new ArgumentNullException(nameof(sender)); + } + + if (e == null) { + throw new ArgumentNullException(nameof(e)); + } + + if (string.IsNullOrEmpty(e.Name)) { + throw new InvalidOperationException(nameof(e.Name)); + } + + if (string.IsNullOrEmpty(e.FullPath)) { + throw new InvalidOperationException(nameof(e.FullPath)); + } + + await OnCreatedFile(e.Name, e.FullPath).ConfigureAwait(false); + } + + private static async Task OnCreatedConfigFile(string name, string fullPath) { + if (string.IsNullOrEmpty(name)) { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrEmpty(fullPath)) { + throw new ArgumentNullException(nameof(fullPath)); + } + + string extension = Path.GetExtension(name); + + switch (extension) { + case SharedInfo.IPCConfigExtension: + await OnChangedConfigFile(name).ConfigureAwait(false); + + break; + case SharedInfo.JsonConfigExtension: + await OnCreatedJsonFile(name, fullPath).ConfigureAwait(false); + + break; + } + } + + private static async Task OnCreatedFile(string name, string fullPath) { + if (string.IsNullOrEmpty(name)) { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrEmpty(fullPath)) { + throw new ArgumentNullException(nameof(fullPath)); + } + + string extension = Path.GetExtension(name); + + switch (extension) { + case SharedInfo.JsonConfigExtension: + await OnCreatedConfigFile(name, fullPath).ConfigureAwait(false); + + break; + + case SharedInfo.KeysExtension: + await OnCreatedKeysFile(name, fullPath).ConfigureAwait(false); + + break; + } + } + + private static async Task OnCreatedJsonFile(string name, string fullPath) { + if (string.IsNullOrEmpty(name)) { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrEmpty(fullPath)) { + throw new ArgumentNullException(nameof(fullPath)); + } + + if (Bot.Bots == null) { + throw new InvalidOperationException(nameof(Bot.Bots)); + } + + string botName = Path.GetFileNameWithoutExtension(name); + + if (string.IsNullOrEmpty(botName) || (botName[0] == '.')) { + return; + } + + if (!await CanHandleWriteEvent(fullPath).ConfigureAwait(false)) { + return; + } + + if (botName.Equals(SharedInfo.ASF, StringComparison.OrdinalIgnoreCase)) { + ArchiLogger.LogGenericInfo(Strings.GlobalConfigChanged); + await RestartOrExit().ConfigureAwait(false); + + return; + } + + if (!IsValidBotName(botName)) { + return; + } + + if (Bot.Bots.TryGetValue(botName, out Bot? bot)) { + await bot.OnConfigChanged(false).ConfigureAwait(false); + } else { + await Bot.RegisterBot(botName).ConfigureAwait(false); + + if (Bot.Bots.Count > MaximumRecommendedBotsCount) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningExcessiveBotsCount, MaximumRecommendedBotsCount)); + } + } + } + + private static async Task OnCreatedKeysFile(string name, string fullPath) { + if (string.IsNullOrEmpty(name)) { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrEmpty(fullPath)) { + throw new ArgumentNullException(nameof(fullPath)); + } + + if (Bot.Bots == null) { + throw new InvalidOperationException(nameof(Bot.Bots)); + } + + string botName = Path.GetFileNameWithoutExtension(name); + + if (string.IsNullOrEmpty(botName) || (botName[0] == '.')) { + return; + } + + if (!await CanHandleWriteEvent(fullPath).ConfigureAwait(false)) { + return; + } + + if (!Bot.Bots.TryGetValue(botName, out Bot? bot)) { + return; + } + + await bot.ImportKeysToRedeem(fullPath).ConfigureAwait(false); + } + + private static async void OnDeleted(object sender, FileSystemEventArgs e) { + if (sender == null) { + throw new ArgumentNullException(nameof(sender)); + } + + if (e == null) { + throw new ArgumentNullException(nameof(e)); + } + + if (string.IsNullOrEmpty(e.Name)) { + throw new InvalidOperationException(nameof(e.Name)); + } + + if (string.IsNullOrEmpty(e.FullPath)) { + throw new InvalidOperationException(nameof(e.FullPath)); + } + + await OnDeletedFile(e.Name, e.FullPath).ConfigureAwait(false); + } + + private static async Task OnDeletedConfigFile(string name, string fullPath) { + if (string.IsNullOrEmpty(name)) { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrEmpty(fullPath)) { + throw new ArgumentNullException(nameof(fullPath)); + } + + string extension = Path.GetExtension(name); + + switch (extension) { + case SharedInfo.IPCConfigExtension: + await OnChangedConfigFile(name).ConfigureAwait(false); + + break; + case SharedInfo.JsonConfigExtension: + await OnDeletedJsonConfigFile(name, fullPath).ConfigureAwait(false); + + break; + } + } + + private static async Task OnDeletedFile(string name, string fullPath) { + if (string.IsNullOrEmpty(name)) { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrEmpty(fullPath)) { + throw new ArgumentNullException(nameof(fullPath)); + } + + string extension = Path.GetExtension(name); + + switch (extension) { + case SharedInfo.JsonConfigExtension: + case SharedInfo.IPCConfigExtension: + await OnDeletedConfigFile(name, fullPath).ConfigureAwait(false); + + break; + } + } + + private static async Task OnDeletedJsonConfigFile(string name, string fullPath) { + if (string.IsNullOrEmpty(name)) { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrEmpty(fullPath)) { + throw new ArgumentNullException(nameof(fullPath)); + } + + if (Bot.Bots == null) { + throw new InvalidOperationException(nameof(Bot.Bots)); + } + + string botName = Path.GetFileNameWithoutExtension(name); + + if (string.IsNullOrEmpty(botName)) { + return; + } + + if (!await CanHandleWriteEvent(fullPath).ConfigureAwait(false)) { + return; + } + + if (botName.Equals(SharedInfo.ASF, StringComparison.OrdinalIgnoreCase)) { + if (File.Exists(fullPath)) { return; } - switch (botNames.Count) { - case 0: - ArchiLogger.LogGenericWarning(Strings.ErrorNoBotsDefined); + // Some editors might decide to delete file and re-create it in order to modify it + // If that's the case, we wait for maximum of 5 seconds before shutting down + await Task.Delay(5000).ConfigureAwait(false); - return; - case > MaximumRecommendedBotsCount: - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningExcessiveBotsCount, MaximumRecommendedBotsCount)); - await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); + if (File.Exists(fullPath)) { + return; + } + + ArchiLogger.LogGenericError(Strings.ErrorGlobalConfigRemoved); + await Program.Exit(1).ConfigureAwait(false); + + return; + } + + if (!IsValidBotName(botName)) { + return; + } + + if (Bot.Bots.TryGetValue(botName, out Bot? bot)) { + await bot.OnConfigChanged(true).ConfigureAwait(false); + } + } + + private static void OnProgressChanged(object? sender, byte progressPercentage) { + const byte printEveryPercentage = 10; + + if (progressPercentage % printEveryPercentage != 0) { + return; + } + + ArchiLogger.LogGenericDebug($"{progressPercentage}%..."); + } + + private static async void OnRenamed(object sender, RenamedEventArgs e) { + if (sender == null) { + throw new ArgumentNullException(nameof(sender)); + } + + if (e == null) { + throw new ArgumentNullException(nameof(e)); + } + + if (string.IsNullOrEmpty(e.OldName)) { + throw new InvalidOperationException(nameof(e.OldName)); + } + + if (string.IsNullOrEmpty(e.OldFullPath)) { + throw new InvalidOperationException(nameof(e.OldFullPath)); + } + + if (string.IsNullOrEmpty(e.Name)) { + throw new InvalidOperationException(nameof(e.Name)); + } + + if (string.IsNullOrEmpty(e.FullPath)) { + throw new InvalidOperationException(nameof(e.FullPath)); + } + + await OnDeletedFile(e.OldName, e.OldFullPath).ConfigureAwait(false); + await OnCreatedFile(e.Name, e.FullPath).ConfigureAwait(false); + } + + private static async Task RegisterBots() { + if ((GlobalConfig == null) || (GlobalDatabase == null) || (WebBrowser == null)) { + throw new ArgumentNullException($"{nameof(GlobalConfig)} || {nameof(GlobalDatabase)} || {nameof(WebBrowser)}"); + } + + // Ensure that we ask for a list of servers if we don't have any saved servers available + IEnumerable servers = await GlobalDatabase.ServerListProvider.FetchServerListAsync().ConfigureAwait(false); + + if (!servers.Any()) { + ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.Initializing, nameof(SteamDirectory))); + + SteamConfiguration steamConfiguration = SteamConfiguration.Create(static builder => builder.WithProtocolTypes(GlobalConfig.SteamProtocols).WithCellID(GlobalDatabase.CellID).WithServerListProvider(GlobalDatabase.ServerListProvider).WithHttpClientFactory(static () => WebBrowser.GenerateDisposableHttpClient())); + + try { + await SteamDirectory.LoadAsync(steamConfiguration).ConfigureAwait(false); + ArchiLogger.LogGenericInfo(Strings.Success); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + ArchiLogger.LogGenericWarning(Strings.BotSteamDirectoryInitializationFailed); + } + } + + HashSet botNames; + + try { + botNames = Directory.EnumerateFiles(SharedInfo.ConfigDirectory, $"*{SharedInfo.JsonConfigExtension}").Select(Path.GetFileNameWithoutExtension).Where(static botName => !string.IsNullOrEmpty(botName) && IsValidBotName(botName)).ToHashSet(Bot.BotsComparer)!; + } catch (Exception e) { + ArchiLogger.LogGenericException(e); + + return; + } + + switch (botNames.Count) { + case 0: + ArchiLogger.LogGenericWarning(Strings.ErrorNoBotsDefined); + + return; + case > MaximumRecommendedBotsCount: + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningExcessiveBotsCount, MaximumRecommendedBotsCount)); + await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); + + break; + } + + await Utilities.InParallel(botNames.OrderBy(static botName => botName, Bot.BotsComparer).Select(Bot.RegisterBot)).ConfigureAwait(false); + } + + private static async Task UpdateAndRestart() { + if (GlobalConfig == null) { + throw new ArgumentNullException(nameof(GlobalConfig)); + } + + if (!SharedInfo.BuildInfo.CanUpdate || (GlobalConfig.UpdateChannel == GlobalConfig.EUpdateChannel.None)) { + return; + } + + if ((AutoUpdatesTimer == null) && (GlobalConfig.UpdatePeriod > 0)) { + TimeSpan autoUpdatePeriod = TimeSpan.FromHours(GlobalConfig.UpdatePeriod); + + AutoUpdatesTimer = new Timer( + OnAutoUpdatesTimer, + null, + autoUpdatePeriod, // Delay + autoUpdatePeriod // Period + ); + + ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.AutoUpdateCheckInfo, autoUpdatePeriod.ToHumanReadable())); + } + + Version? newVersion = await Update().ConfigureAwait(false); + + if (newVersion == null) { + return; + } + + if (SharedInfo.Version >= newVersion) { + if (SharedInfo.Version > newVersion) { + ArchiLogger.LogGenericWarning(Strings.WarningPreReleaseVersion); + await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); + } + + return; + } + + await RestartOrExit().ConfigureAwait(false); + } + + private static bool UpdateFromArchive(ZipArchive archive, string targetDirectory) { + if (archive == null) { + throw new ArgumentNullException(nameof(archive)); + } + + if (string.IsNullOrEmpty(targetDirectory)) { + throw new ArgumentNullException(nameof(targetDirectory)); + } + + if (SharedInfo.HomeDirectory == AppContext.BaseDirectory) { + // We're running a build that includes our dependencies in ASF's home + // Before actually moving files in update procedure, let's minimize the risk of some assembly not being loaded that we may need in the process + LoadAssembliesRecursively(Assembly.GetExecutingAssembly()); + } + + // Firstly we'll move all our existing files to a backup directory + string backupDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectory); + + foreach (string file in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.AllDirectories)) { + string fileName = Path.GetFileName(file); + + if (string.IsNullOrEmpty(fileName)) { + ArchiLogger.LogNullError(nameof(fileName)); + + return false; + } + + string relativeFilePath = Path.GetRelativePath(targetDirectory, file); + + if (string.IsNullOrEmpty(relativeFilePath)) { + ArchiLogger.LogNullError(nameof(relativeFilePath)); + + return false; + } + + string? relativeDirectoryName = Path.GetDirectoryName(relativeFilePath); + + switch (relativeDirectoryName) { + case null: + ArchiLogger.LogNullError(nameof(relativeDirectoryName)); + + return false; + case "": + // No directory, root folder + switch (fileName) { + case Logging.NLogConfigurationFile: + case SharedInfo.LogFile: + // Files with those names in root directory we want to keep + continue; + } + + break; + case SharedInfo.ArchivalLogsDirectory: + case SharedInfo.ConfigDirectory: + case SharedInfo.DebugDirectory: + case SharedInfo.PluginsDirectory: + case SharedInfo.UpdateDirectory: + // Files in those directories we want to keep in their current place + continue; + default: + // Files in subdirectories of those directories we want to keep as well + if (Utilities.RelativeDirectoryStartsWith(relativeDirectoryName, SharedInfo.ArchivalLogsDirectory, SharedInfo.ConfigDirectory, SharedInfo.DebugDirectory, SharedInfo.PluginsDirectory, SharedInfo.UpdateDirectory)) { + continue; + } break; } - await Utilities.InParallel(botNames.OrderBy(static botName => botName, Bot.BotsComparer).Select(Bot.RegisterBot)).ConfigureAwait(false); + string targetBackupDirectory = relativeDirectoryName.Length > 0 ? Path.Combine(backupDirectory, relativeDirectoryName) : backupDirectory; + Directory.CreateDirectory(targetBackupDirectory); + + string targetBackupFile = Path.Combine(targetBackupDirectory, fileName); + + File.Move(file, targetBackupFile, true); } - private static async Task UpdateAndRestart() { - if (GlobalConfig == null) { - throw new ArgumentNullException(nameof(GlobalConfig)); - } + // We can now get rid of directories that are empty + Utilities.DeleteEmptyDirectoriesRecursively(targetDirectory); - if (!SharedInfo.BuildInfo.CanUpdate || (GlobalConfig.UpdateChannel == GlobalConfig.EUpdateChannel.None)) { - return; - } - - if ((AutoUpdatesTimer == null) && (GlobalConfig.UpdatePeriod > 0)) { - TimeSpan autoUpdatePeriod = TimeSpan.FromHours(GlobalConfig.UpdatePeriod); - - AutoUpdatesTimer = new Timer( - OnAutoUpdatesTimer, - null, - autoUpdatePeriod, // Delay - autoUpdatePeriod // Period - ); - - ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.AutoUpdateCheckInfo, autoUpdatePeriod.ToHumanReadable())); - } - - Version? newVersion = await Update().ConfigureAwait(false); - - if (newVersion == null) { - return; - } - - if (SharedInfo.Version >= newVersion) { - if (SharedInfo.Version > newVersion) { - ArchiLogger.LogGenericWarning(Strings.WarningPreReleaseVersion); - await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); - } - - return; - } - - await RestartOrExit().ConfigureAwait(false); + if (!Directory.Exists(targetDirectory)) { + Directory.CreateDirectory(targetDirectory); } - private static bool UpdateFromArchive(ZipArchive archive, string targetDirectory) { - if (archive == null) { - throw new ArgumentNullException(nameof(archive)); + // Now enumerate over files in the zip archive, skip directory entries that we're not interested in (we can create them ourselves if needed) + foreach (ZipArchiveEntry zipFile in archive.Entries.Where(static zipFile => !string.IsNullOrEmpty(zipFile.Name))) { + string file = Path.GetFullPath(Path.Combine(targetDirectory, zipFile.FullName)); + + if (!file.StartsWith(targetDirectory, StringComparison.Ordinal)) { + throw new InvalidOperationException(nameof(file)); } - if (string.IsNullOrEmpty(targetDirectory)) { - throw new ArgumentNullException(nameof(targetDirectory)); - } - - if (SharedInfo.HomeDirectory == AppContext.BaseDirectory) { - // We're running a build that includes our dependencies in ASF's home - // Before actually moving files in update procedure, let's minimize the risk of some assembly not being loaded that we may need in the process - LoadAssembliesRecursively(Assembly.GetExecutingAssembly()); - } - - // Firstly we'll move all our existing files to a backup directory - string backupDirectory = Path.Combine(targetDirectory, SharedInfo.UpdateDirectory); - - foreach (string file in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.AllDirectories)) { - string fileName = Path.GetFileName(file); - - if (string.IsNullOrEmpty(fileName)) { - ArchiLogger.LogNullError(nameof(fileName)); - - return false; - } - - string relativeFilePath = Path.GetRelativePath(targetDirectory, file); - - if (string.IsNullOrEmpty(relativeFilePath)) { - ArchiLogger.LogNullError(nameof(relativeFilePath)); - - return false; - } - - string? relativeDirectoryName = Path.GetDirectoryName(relativeFilePath); - - switch (relativeDirectoryName) { - case null: - ArchiLogger.LogNullError(nameof(relativeDirectoryName)); - - return false; - case "": - // No directory, root folder - switch (fileName) { - case Logging.NLogConfigurationFile: - case SharedInfo.LogFile: - // Files with those names in root directory we want to keep - continue; - } - - break; - case SharedInfo.ArchivalLogsDirectory: - case SharedInfo.ConfigDirectory: - case SharedInfo.DebugDirectory: - case SharedInfo.PluginsDirectory: - case SharedInfo.UpdateDirectory: - // Files in those directories we want to keep in their current place - continue; - default: - // Files in subdirectories of those directories we want to keep as well - if (Utilities.RelativeDirectoryStartsWith(relativeDirectoryName, SharedInfo.ArchivalLogsDirectory, SharedInfo.ConfigDirectory, SharedInfo.DebugDirectory, SharedInfo.PluginsDirectory, SharedInfo.UpdateDirectory)) { - continue; - } - - break; - } - - string targetBackupDirectory = relativeDirectoryName.Length > 0 ? Path.Combine(backupDirectory, relativeDirectoryName) : backupDirectory; - Directory.CreateDirectory(targetBackupDirectory); - - string targetBackupFile = Path.Combine(targetBackupDirectory, fileName); + if (File.Exists(file)) { + // This is possible only with files that we decided to leave in place during our backup function + string targetBackupFile = $"{file}.bak"; File.Move(file, targetBackupFile, true); } - // We can now get rid of directories that are empty - Utilities.DeleteEmptyDirectoriesRecursively(targetDirectory); + // Check if this file requires its own folder + if (zipFile.Name != zipFile.FullName) { + string? directory = Path.GetDirectoryName(file); - if (!Directory.Exists(targetDirectory)) { - Directory.CreateDirectory(targetDirectory); + if (string.IsNullOrEmpty(directory)) { + ArchiLogger.LogNullError(nameof(directory)); + + return false; + } + + if (!Directory.Exists(directory)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + Directory.CreateDirectory(directory!); + } + + // We're not interested in extracting placeholder files (but we still want directories created for them, done above) + switch (zipFile.Name) { + case ".gitkeep": + continue; + } } - // Now enumerate over files in the zip archive, skip directory entries that we're not interested in (we can create them ourselves if needed) - foreach (ZipArchiveEntry zipFile in archive.Entries.Where(static zipFile => !string.IsNullOrEmpty(zipFile.Name))) { - string file = Path.GetFullPath(Path.Combine(targetDirectory, zipFile.FullName)); - - if (!file.StartsWith(targetDirectory, StringComparison.Ordinal)) { - throw new InvalidOperationException(nameof(file)); - } - - if (File.Exists(file)) { - // This is possible only with files that we decided to leave in place during our backup function - string targetBackupFile = $"{file}.bak"; - - File.Move(file, targetBackupFile, true); - } - - // Check if this file requires its own folder - if (zipFile.Name != zipFile.FullName) { - string? directory = Path.GetDirectoryName(file); - - if (string.IsNullOrEmpty(directory)) { - ArchiLogger.LogNullError(nameof(directory)); - - return false; - } - - if (!Directory.Exists(directory)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - Directory.CreateDirectory(directory!); - } - - // We're not interested in extracting placeholder files (but we still want directories created for them, done above) - switch (zipFile.Name) { - case ".gitkeep": - continue; - } - } - - zipFile.ExtractToFile(file); - } - - return true; + zipFile.ExtractToFile(file); } - [PublicAPI] - public enum EUserInputType : byte { - None, - Login, - Password, - SteamGuard, - SteamParentalCode, - TwoFactorAuthentication - } + return true; + } - internal enum EFileType : byte { - Config, - Database - } + [PublicAPI] + public enum EUserInputType : byte { + None, + Login, + Password, + SteamGuard, + SteamParentalCode, + TwoFactorAuthentication + } + + internal enum EFileType : byte { + Config, + Database } } diff --git a/ArchiSteamFarm/Core/AprilFools.cs b/ArchiSteamFarm/Core/AprilFools.cs index fab61c4cf..1cd0b3701 100644 --- a/ArchiSteamFarm/Core/AprilFools.cs +++ b/ArchiSteamFarm/Core/AprilFools.cs @@ -23,55 +23,55 @@ using System; using System.Globalization; using System.Threading; -namespace ArchiSteamFarm.Core { - internal static class AprilFools { - private static readonly object LockObject = new(); +namespace ArchiSteamFarm.Core; - // We don't care about CurrentCulture global config property, because April Fools are never initialized in this case - private static readonly CultureInfo OriginalCulture = CultureInfo.CurrentCulture; +internal static class AprilFools { + private static readonly object LockObject = new(); - private static readonly Timer Timer = new(Init); + // We don't care about CurrentCulture global config property, because April Fools are never initialized in this case + private static readonly CultureInfo OriginalCulture = CultureInfo.CurrentCulture; - internal static void Init(object? state = null) { - DateTime now = DateTime.Now; + private static readonly Timer Timer = new(Init); - if ((now.Month == 4) && (now.Day == 1)) { - try { - CultureInfo.DefaultThreadCurrentCulture = CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.CreateSpecificCulture(SharedInfo.LolcatCultureName); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericDebuggingException(e); - - return; - } - - TimeSpan aprilFoolsEnd = TimeSpan.FromDays(1) - now.TimeOfDay; - - lock (LockObject) { - Timer.Change(aprilFoolsEnd + TimeSpan.FromMilliseconds(100), Timeout.InfiniteTimeSpan); - } - - return; - } + internal static void Init(object? state = null) { + DateTime now = DateTime.Now; + if ((now.Month == 4) && (now.Day == 1)) { try { - CultureInfo.DefaultThreadCurrentCulture = CultureInfo.DefaultThreadCurrentUICulture = OriginalCulture; + CultureInfo.DefaultThreadCurrentCulture = CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.CreateSpecificCulture(SharedInfo.LolcatCultureName); } catch (Exception e) { ASF.ArchiLogger.LogGenericDebuggingException(e); return; } - // Since we already verified that it's not April Fools right now, either we're in months 1-3 before 1st April this year, or 4-12 already after the 1st April - DateTime nextAprilFools = new(now.Month >= 4 ? now.Year + 1 : now.Year, 4, 1, 0, 0, 0, DateTimeKind.Local); - - TimeSpan aprilFoolsStart = nextAprilFools - now; - - // Timer can accept only dueTimes up to 2^32 - 2 - uint dueTime = (uint) Math.Min(uint.MaxValue - 1, (ulong) aprilFoolsStart.TotalMilliseconds + 100); + TimeSpan aprilFoolsEnd = TimeSpan.FromDays(1) - now.TimeOfDay; lock (LockObject) { - Timer.Change(dueTime, Timeout.Infinite); + Timer.Change(aprilFoolsEnd + TimeSpan.FromMilliseconds(100), Timeout.InfiniteTimeSpan); } + + return; + } + + try { + CultureInfo.DefaultThreadCurrentCulture = CultureInfo.DefaultThreadCurrentUICulture = OriginalCulture; + } catch (Exception e) { + ASF.ArchiLogger.LogGenericDebuggingException(e); + + return; + } + + // Since we already verified that it's not April Fools right now, either we're in months 1-3 before 1st April this year, or 4-12 already after the 1st April + DateTime nextAprilFools = new(now.Month >= 4 ? now.Year + 1 : now.Year, 4, 1, 0, 0, 0, DateTimeKind.Local); + + TimeSpan aprilFoolsStart = nextAprilFools - now; + + // Timer can accept only dueTimes up to 2^32 - 2 + uint dueTime = (uint) Math.Min(uint.MaxValue - 1, (ulong) aprilFoolsStart.TotalMilliseconds + 100); + + lock (LockObject) { + Timer.Change(dueTime, Timeout.Infinite); } } } diff --git a/ArchiSteamFarm/Core/Debugging.cs b/ArchiSteamFarm/Core/Debugging.cs index a6bedfed3..235fb2b4f 100644 --- a/ArchiSteamFarm/Core/Debugging.cs +++ b/ArchiSteamFarm/Core/Debugging.cs @@ -22,26 +22,26 @@ using System; using SteamKit2; -namespace ArchiSteamFarm.Core { - internal static class Debugging { +namespace ArchiSteamFarm.Core; + +internal static class Debugging { #if DEBUG - internal static bool IsDebugBuild => true; + internal static bool IsDebugBuild => true; #else internal static bool IsDebugBuild => false; #endif - internal static bool IsDebugConfigured => ASF.GlobalConfig?.Debug ?? throw new InvalidOperationException(nameof(ASF.GlobalConfig)); + internal static bool IsDebugConfigured => ASF.GlobalConfig?.Debug ?? throw new InvalidOperationException(nameof(ASF.GlobalConfig)); - internal static bool IsUserDebugging => IsDebugBuild || IsDebugConfigured; + internal static bool IsUserDebugging => IsDebugBuild || IsDebugConfigured; - internal sealed class DebugListener : IDebugListener { - public void WriteLine(string category, string msg) { - if (string.IsNullOrEmpty(category) && string.IsNullOrEmpty(msg)) { - throw new InvalidOperationException($"{nameof(category)} && {nameof(msg)}"); - } - - ASF.ArchiLogger.LogGenericDebug($"{category} | {msg}"); + internal sealed class DebugListener : IDebugListener { + public void WriteLine(string category, string msg) { + if (string.IsNullOrEmpty(category) && string.IsNullOrEmpty(msg)) { + throw new InvalidOperationException($"{nameof(category)} && {nameof(msg)}"); } + + ASF.ArchiLogger.LogGenericDebug($"{category} | {msg}"); } } } diff --git a/ArchiSteamFarm/Core/Events.cs b/ArchiSteamFarm/Core/Events.cs index 76076c04c..cbc876139 100644 --- a/ArchiSteamFarm/Core/Events.cs +++ b/ArchiSteamFarm/Core/Events.cs @@ -24,23 +24,23 @@ using System.Threading.Tasks; using ArchiSteamFarm.Localization; using ArchiSteamFarm.Steam; -namespace ArchiSteamFarm.Core { - internal static class Events { - internal static async Task OnBotShutdown() { - if (Program.ProcessRequired || ((Bot.Bots != null) && Bot.Bots.Values.Any(static bot => bot.KeepRunning))) { - return; - } +namespace ArchiSteamFarm.Core; - ASF.ArchiLogger.LogGenericInfo(Strings.NoBotsAreRunning); - - // We give user extra 5 seconds for eventual config changes - await Task.Delay(5000).ConfigureAwait(false); - - if (Program.ProcessRequired || ((Bot.Bots != null) && Bot.Bots.Values.Any(static bot => bot.KeepRunning))) { - return; - } - - await Program.Exit().ConfigureAwait(false); +internal static class Events { + internal static async Task OnBotShutdown() { + if (Program.ProcessRequired || ((Bot.Bots != null) && Bot.Bots.Values.Any(static bot => bot.KeepRunning))) { + return; } + + ASF.ArchiLogger.LogGenericInfo(Strings.NoBotsAreRunning); + + // We give user extra 5 seconds for eventual config changes + await Task.Delay(5000).ConfigureAwait(false); + + if (Program.ProcessRequired || ((Bot.Bots != null) && Bot.Bots.Values.Any(static bot => bot.KeepRunning))) { + return; + } + + await Program.Exit().ConfigureAwait(false); } } diff --git a/ArchiSteamFarm/Core/OS.cs b/ArchiSteamFarm/Core/OS.cs index 74cc466ef..bf232bf21 100644 --- a/ArchiSteamFarm/Core/OS.cs +++ b/ArchiSteamFarm/Core/OS.cs @@ -35,200 +35,201 @@ using ArchiSteamFarm.Localization; using ArchiSteamFarm.Storage; using ArchiSteamFarm.Web; -namespace ArchiSteamFarm.Core { - internal static class OS { - // We need to keep this one assigned and not calculated on-demand - internal static readonly string ProcessFileName = Process.GetCurrentProcess().MainModule?.FileName ?? throw new InvalidOperationException(nameof(ProcessFileName)); +namespace ArchiSteamFarm.Core; - internal static DateTime ProcessStartTime { +internal static class OS { + // We need to keep this one assigned and not calculated on-demand + internal static readonly string ProcessFileName = Process.GetCurrentProcess().MainModule?.FileName ?? throw new InvalidOperationException(nameof(ProcessFileName)); + + internal static DateTime ProcessStartTime { #if NETFRAMEWORK get => RuntimeMadness.ProcessStartTime.ToUniversalTime(); #else - get { - using Process process = Process.GetCurrentProcess(); + get { + using Process process = Process.GetCurrentProcess(); - return process.StartTime.ToUniversalTime(); - } -#endif + return process.StartTime.ToUniversalTime(); } +#endif + } - internal static string Version { - get { - if (!string.IsNullOrEmpty(BackingVersion)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - return BackingVersion!; - } + internal static string Version { + get { + if (!string.IsNullOrEmpty(BackingVersion)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + return BackingVersion!; + } - string framework = RuntimeInformation.FrameworkDescription.Trim(); + string framework = RuntimeInformation.FrameworkDescription.Trim(); - if (framework.Length == 0) { - framework = "Unknown Framework"; - } + if (framework.Length == 0) { + framework = "Unknown Framework"; + } #if NETFRAMEWORK string runtime = RuntimeInformation.OSArchitecture.ToString(); #else - string runtime = RuntimeInformation.RuntimeIdentifier.Trim(); + string runtime = RuntimeInformation.RuntimeIdentifier.Trim(); - if (runtime.Length == 0) { - runtime = "Unknown Runtime"; - } + if (runtime.Length == 0) { + runtime = "Unknown Runtime"; + } #endif - string description = RuntimeInformation.OSDescription.Trim(); + string description = RuntimeInformation.OSDescription.Trim(); - if (description.Length == 0) { - description = "Unknown OS"; - } + if (description.Length == 0) { + description = "Unknown OS"; + } - BackingVersion = $"{framework}; {runtime}; {description}"; + BackingVersion = $"{framework}; {runtime}; {description}"; - return BackingVersion; + return BackingVersion; + } + } + + private static string? BackingVersion; + private static Mutex? SingleInstance; + + internal static void CoreInit(bool systemRequired) { + if (OperatingSystem.IsWindows()) { + if (systemRequired) { + WindowsKeepSystemActive(); + } + + if (!Console.IsOutputRedirected) { + // Normally we should use UTF-8 console encoding as it's the most correct one for our case, and we already use it on other OSes such as Linux + // However, older Windows versions, mainly 7/8.1 can't into UTF-8 without appropriate console font, and expecting from users to change it manually is unwanted + // As irrational as it can sound, those versions actually can work with unicode encoding instead, as they magically map it into proper chars despite of incorrect font + // See https://github.com/JustArchiNET/ArchiSteamFarm/issues/1289 for more details + Console.OutputEncoding = OperatingSystem.IsWindowsVersionAtLeast(10) ? Encoding.UTF8 : Encoding.Unicode; + + // Quick edit mode will freeze when user start selecting something on the console until the selection is cancelled + // Users are very often doing it accidentally without any real purpose, and we want to avoid this common issue which causes the whole process to hang + // See http://stackoverflow.com/questions/30418886/how-and-why-does-quickedit-mode-in-command-prompt-freeze-applications for more details + WindowsDisableQuickEditMode(); } } + } - private static string? BackingVersion; - private static Mutex? SingleInstance; - - internal static void CoreInit(bool systemRequired) { - if (OperatingSystem.IsWindows()) { - if (systemRequired) { - WindowsKeepSystemActive(); - } - - if (!Console.IsOutputRedirected) { - // Normally we should use UTF-8 console encoding as it's the most correct one for our case, and we already use it on other OSes such as Linux - // However, older Windows versions, mainly 7/8.1 can't into UTF-8 without appropriate console font, and expecting from users to change it manually is unwanted - // As irrational as it can sound, those versions actually can work with unicode encoding instead, as they magically map it into proper chars despite of incorrect font - // See https://github.com/JustArchiNET/ArchiSteamFarm/issues/1289 for more details - Console.OutputEncoding = OperatingSystem.IsWindowsVersionAtLeast(10) ? Encoding.UTF8 : Encoding.Unicode; - - // Quick edit mode will freeze when user start selecting something on the console until the selection is cancelled - // Users are very often doing it accidentally without any real purpose, and we want to avoid this common issue which causes the whole process to hang - // See http://stackoverflow.com/questions/30418886/how-and-why-does-quickedit-mode-in-command-prompt-freeze-applications for more details - WindowsDisableQuickEditMode(); - } - } + internal static string GetOsResourceName(string objectName) { + if (string.IsNullOrEmpty(objectName)) { + throw new ArgumentNullException(nameof(objectName)); } - internal static string GetOsResourceName(string objectName) { - if (string.IsNullOrEmpty(objectName)) { - throw new ArgumentNullException(nameof(objectName)); - } + return $"{SharedInfo.AssemblyName}-{objectName}"; + } - return $"{SharedInfo.AssemblyName}-{objectName}"; + internal static void Init(GlobalConfig.EOptimizationMode optimizationMode) { + if (!Enum.IsDefined(typeof(GlobalConfig.EOptimizationMode), optimizationMode)) { + throw new ArgumentNullException(nameof(optimizationMode)); } - internal static void Init(GlobalConfig.EOptimizationMode optimizationMode) { - if (!Enum.IsDefined(typeof(GlobalConfig.EOptimizationMode), optimizationMode)) { - throw new ArgumentNullException(nameof(optimizationMode)); - } + switch (optimizationMode) { + case GlobalConfig.EOptimizationMode.MaxPerformance: + // No specific tuning required for now, ASF is optimized for max performance by default + break; + case GlobalConfig.EOptimizationMode.MinMemoryUsage: + // We can disable regex cache which will slightly lower memory usage (for a huge performance hit) + Regex.CacheSize = 0; - switch (optimizationMode) { - case GlobalConfig.EOptimizationMode.MaxPerformance: - // No specific tuning required for now, ASF is optimized for max performance by default - break; - case GlobalConfig.EOptimizationMode.MinMemoryUsage: - // We can disable regex cache which will slightly lower memory usage (for a huge performance hit) - Regex.CacheSize = 0; + break; + default: + throw new ArgumentOutOfRangeException(nameof(optimizationMode)); + } + } - break; - default: - throw new ArgumentOutOfRangeException(nameof(optimizationMode)); - } + internal static bool IsRunningAsRoot() { + if (OperatingSystem.IsWindows()) { + using WindowsIdentity identity = WindowsIdentity.GetCurrent(); + + return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); } - internal static bool IsRunningAsRoot() { - if (OperatingSystem.IsWindows()) { - using WindowsIdentity identity = WindowsIdentity.GetCurrent(); + if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { + return NativeMethods.GetEUID() == 0; + } - return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); - } + // We can't determine whether user is running as root or not, so fallback to that not happening + return false; + } - if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { - return NativeMethods.GetEUID() == 0; - } - - // We can't determine whether user is running as root or not, so fallback to that not happening + internal static async Task RegisterProcess() { + if (SingleInstance != null) { return false; } - internal static async Task RegisterProcess() { - if (SingleInstance != null) { - return false; - } + string uniqueName; - string uniqueName; - - // The only purpose of using hashingAlgorithm here is to cut on a potential size of the resource name - paths can be really long, and we almost certainly have some upper limit on the resource name we can allocate - // At the same time it'd be the best if we avoided all special characters, such as '/' found e.g. in base64, as we can't be sure that it's not a prohibited character in regards to native OS implementation - // Because of that, SHA256 is sufficient for our case, as it generates alphanumeric characters only, and is barely 256-bit long. We don't need any kind of complex cryptography or collision detection here, any hashing algorithm will do, and the shorter the better - using (SHA256 hashingAlgorithm = SHA256.Create()) { - uniqueName = $"Global\\{GetOsResourceName(nameof(SingleInstance))}-{BitConverter.ToString(hashingAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(Directory.GetCurrentDirectory()))).Replace("-", "", StringComparison.Ordinal)}"; - } - - Mutex? singleInstance = null; - - for (byte i = 0; i < WebBrowser.MaxTries; i++) { - if (i > 0) { - await Task.Delay(1000).ConfigureAwait(false); - } - - singleInstance = new Mutex(true, uniqueName, out bool result); - - if (result) { - break; - } - - singleInstance.Dispose(); - singleInstance = null; - } - - if (singleInstance == null) { - return false; - } - - SingleInstance = singleInstance; - - return true; + // The only purpose of using hashingAlgorithm here is to cut on a potential size of the resource name - paths can be really long, and we almost certainly have some upper limit on the resource name we can allocate + // At the same time it'd be the best if we avoided all special characters, such as '/' found e.g. in base64, as we can't be sure that it's not a prohibited character in regards to native OS implementation + // Because of that, SHA256 is sufficient for our case, as it generates alphanumeric characters only, and is barely 256-bit long. We don't need any kind of complex cryptography or collision detection here, any hashing algorithm will do, and the shorter the better + using (SHA256 hashingAlgorithm = SHA256.Create()) { + uniqueName = $"Global\\{GetOsResourceName(nameof(SingleInstance))}-{BitConverter.ToString(hashingAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(Directory.GetCurrentDirectory()))).Replace("-", "", StringComparison.Ordinal)}"; } - [SupportedOSPlatform("FreeBSD")] - [SupportedOSPlatform("Linux")] - [SupportedOSPlatform("MacOS")] - internal static void UnixSetFileAccess(string path, EUnixPermission permission) { - if (string.IsNullOrEmpty(path)) { - throw new ArgumentNullException(nameof(path)); + Mutex? singleInstance = null; + + for (byte i = 0; i < WebBrowser.MaxTries; i++) { + if (i > 0) { + await Task.Delay(1000).ConfigureAwait(false); } - if (!OperatingSystem.IsFreeBSD() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) { - throw new PlatformNotSupportedException(); + singleInstance = new Mutex(true, uniqueName, out bool result); + + if (result) { + break; } - if (!File.Exists(path) && !Directory.Exists(path)) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"!{nameof(path)}")); - - return; - } - - // Chmod() returns 0 on success, -1 on failure - if (NativeMethods.Chmod(path, (int) permission) != 0) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, Marshal.GetLastWin32Error())); - } + singleInstance.Dispose(); + singleInstance = null; } - internal static void UnregisterProcess() { - if (SingleInstance == null) { - return; - } - - // We should release the mutex here, but that can be done only from the same thread due to thread affinity - // Instead, we'll dispose the mutex which should automatically release it by the CLR - SingleInstance.Dispose(); - SingleInstance = null; + if (singleInstance == null) { + return false; } - internal static bool VerifyEnvironment() { + SingleInstance = singleInstance; + + return true; + } + + [SupportedOSPlatform("FreeBSD")] + [SupportedOSPlatform("Linux")] + [SupportedOSPlatform("MacOS")] + internal static void UnixSetFileAccess(string path, EUnixPermission permission) { + if (string.IsNullOrEmpty(path)) { + throw new ArgumentNullException(nameof(path)); + } + + if (!OperatingSystem.IsFreeBSD() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) { + throw new PlatformNotSupportedException(); + } + + if (!File.Exists(path) && !Directory.Exists(path)) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"!{nameof(path)}")); + + return; + } + + // Chmod() returns 0 on success, -1 on failure + if (NativeMethods.Chmod(path, (int) permission) != 0) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, Marshal.GetLastWin32Error())); + } + } + + internal static void UnregisterProcess() { + if (SingleInstance == null) { + return; + } + + // We should release the mutex here, but that can be done only from the same thread due to thread affinity + // Instead, we'll dispose the mutex which should automatically release it by the CLR + SingleInstance.Dispose(); + SingleInstance = null; + } + + internal static bool VerifyEnvironment() { #if NETFRAMEWORK // This is .NET Framework build, we support that one only on mono for platforms not supported by .NET Core @@ -254,121 +255,120 @@ namespace ArchiSteamFarm.Core { }; #else - // This is .NET Core build, we support all scenarios - return true; + // This is .NET Core build, we support all scenarios + return true; #endif + } + + [SupportedOSPlatform("Windows")] + private static void WindowsDisableQuickEditMode() { + if (!OperatingSystem.IsWindows()) { + throw new PlatformNotSupportedException(); } + IntPtr consoleHandle = NativeMethods.GetStdHandle(NativeMethods.StandardInputHandle); + + if (!NativeMethods.GetConsoleMode(consoleHandle, out uint consoleMode)) { + ASF.ArchiLogger.LogGenericError(Strings.WarningFailed); + + return; + } + + consoleMode &= ~NativeMethods.EnableQuickEditMode; + + if (!NativeMethods.SetConsoleMode(consoleHandle, consoleMode)) { + ASF.ArchiLogger.LogGenericError(Strings.WarningFailed); + } + } + + [SupportedOSPlatform("Windows")] + private static void WindowsKeepSystemActive() { + if (!OperatingSystem.IsWindows()) { + throw new PlatformNotSupportedException(); + } + + // This function calls unmanaged API in order to tell Windows OS that it should not enter sleep state while the program is running + // If user wishes to enter sleep mode, then he should use ShutdownOnFarmingFinished or manage ASF process with third-party tool or script + // See https://docs.microsoft.com/windows/win32/api/winbase/nf-winbase-setthreadexecutionstate for more details + NativeMethods.EExecutionState result = NativeMethods.SetThreadExecutionState(NativeMethods.AwakeExecutionState); + + // SetThreadExecutionState() returns NULL on failure, which is mapped to 0 (EExecutionState.None) in our case + if (result == NativeMethods.EExecutionState.None) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, result)); + } + } + + [Flags] + [SupportedOSPlatform("FreeBSD")] + [SupportedOSPlatform("Linux")] + [SupportedOSPlatform("MacOS")] + internal enum EUnixPermission : ushort { + OtherExecute = 0x1, + OtherWrite = 0x2, + OtherRead = 0x4, + GroupExecute = 0x8, + GroupWrite = 0x10, + GroupRead = 0x20, + UserExecute = 0x40, + UserWrite = 0x80, + UserRead = 0x100, + Combined755 = UserRead | UserWrite | UserExecute | GroupRead | GroupExecute | OtherRead | OtherExecute, + Combined777 = UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute | OtherRead | OtherWrite | OtherExecute + } + + private static class NativeMethods { + [SupportedOSPlatform("Windows")] + internal const EExecutionState AwakeExecutionState = EExecutionState.SystemRequired | EExecutionState.AwayModeRequired | EExecutionState.Continuous; + [SupportedOSPlatform("Windows")] - private static void WindowsDisableQuickEditMode() { - if (!OperatingSystem.IsWindows()) { - throw new PlatformNotSupportedException(); - } - - IntPtr consoleHandle = NativeMethods.GetStdHandle(NativeMethods.StandardInputHandle); - - if (!NativeMethods.GetConsoleMode(consoleHandle, out uint consoleMode)) { - ASF.ArchiLogger.LogGenericError(Strings.WarningFailed); - - return; - } - - consoleMode &= ~NativeMethods.EnableQuickEditMode; - - if (!NativeMethods.SetConsoleMode(consoleHandle, consoleMode)) { - ASF.ArchiLogger.LogGenericError(Strings.WarningFailed); - } - } + internal const uint EnableQuickEditMode = 0x0040; [SupportedOSPlatform("Windows")] - private static void WindowsKeepSystemActive() { - if (!OperatingSystem.IsWindows()) { - throw new PlatformNotSupportedException(); - } + internal const sbyte StandardInputHandle = -10; - // This function calls unmanaged API in order to tell Windows OS that it should not enter sleep state while the program is running - // If user wishes to enter sleep mode, then he should use ShutdownOnFarmingFinished or manage ASF process with third-party tool or script - // See https://docs.microsoft.com/windows/win32/api/winbase/nf-winbase-setthreadexecutionstate for more details - NativeMethods.EExecutionState result = NativeMethods.SetThreadExecutionState(NativeMethods.AwakeExecutionState); - - // SetThreadExecutionState() returns NULL on failure, which is mapped to 0 (EExecutionState.None) in our case - if (result == NativeMethods.EExecutionState.None) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, result)); - } - } - - [Flags] +#pragma warning disable CA2101 // False positive, we can't use unicode charset on Unix, and it uses UTF-8 by default anyway + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("libc", EntryPoint = "chmod", SetLastError = true)] [SupportedOSPlatform("FreeBSD")] [SupportedOSPlatform("Linux")] [SupportedOSPlatform("MacOS")] - internal enum EUnixPermission : ushort { - OtherExecute = 0x1, - OtherWrite = 0x2, - OtherRead = 0x4, - GroupExecute = 0x8, - GroupWrite = 0x10, - GroupRead = 0x20, - UserExecute = 0x40, - UserWrite = 0x80, - UserRead = 0x100, - Combined755 = UserRead | UserWrite | UserExecute | GroupRead | GroupExecute | OtherRead | OtherExecute, - Combined777 = UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute | OtherRead | OtherWrite | OtherExecute - } - - private static class NativeMethods { - [SupportedOSPlatform("Windows")] - internal const EExecutionState AwakeExecutionState = EExecutionState.SystemRequired | EExecutionState.AwayModeRequired | EExecutionState.Continuous; - - [SupportedOSPlatform("Windows")] - internal const uint EnableQuickEditMode = 0x0040; - - [SupportedOSPlatform("Windows")] - internal const sbyte StandardInputHandle = -10; - -#pragma warning disable CA2101 // False positive, we can't use unicode charset on Unix, and it uses UTF-8 by default anyway - [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] - [DllImport("libc", EntryPoint = "chmod", SetLastError = true)] - [SupportedOSPlatform("FreeBSD")] - [SupportedOSPlatform("Linux")] - [SupportedOSPlatform("MacOS")] - internal static extern int Chmod(string path, int mode); + internal static extern int Chmod(string path, int mode); #pragma warning restore CA2101 // False positive, we can't use unicode charset on Unix, and it uses UTF-8 by default anyway - [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] - [DllImport("kernel32.dll")] - [SupportedOSPlatform("Windows")] - internal static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode); + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("kernel32.dll")] + [SupportedOSPlatform("Windows")] + internal static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode); - [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] - [DllImport("libc", EntryPoint = "geteuid", SetLastError = true)] - [SupportedOSPlatform("FreeBSD")] - [SupportedOSPlatform("Linux")] - [SupportedOSPlatform("MacOS")] - internal static extern uint GetEUID(); + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("libc", EntryPoint = "geteuid", SetLastError = true)] + [SupportedOSPlatform("FreeBSD")] + [SupportedOSPlatform("Linux")] + [SupportedOSPlatform("MacOS")] + internal static extern uint GetEUID(); - [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] - [DllImport("kernel32.dll")] - [SupportedOSPlatform("Windows")] - internal static extern IntPtr GetStdHandle(int nStdHandle); + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("kernel32.dll")] + [SupportedOSPlatform("Windows")] + internal static extern IntPtr GetStdHandle(int nStdHandle); - [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] - [DllImport("kernel32.dll")] - [SupportedOSPlatform("Windows")] - internal static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode); + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("kernel32.dll")] + [SupportedOSPlatform("Windows")] + internal static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode); - [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] - [DllImport("kernel32.dll")] - [SupportedOSPlatform("Windows")] - internal static extern EExecutionState SetThreadExecutionState(EExecutionState executionState); + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("kernel32.dll")] + [SupportedOSPlatform("Windows")] + internal static extern EExecutionState SetThreadExecutionState(EExecutionState executionState); - [Flags] - [SupportedOSPlatform("Windows")] - internal enum EExecutionState : uint { - None = 0, - SystemRequired = 0x00000001, - AwayModeRequired = 0x00000040, - Continuous = 0x80000000 - } + [Flags] + [SupportedOSPlatform("Windows")] + internal enum EExecutionState : uint { + None = 0, + SystemRequired = 0x00000001, + AwayModeRequired = 0x00000040, + Continuous = 0x80000000 } } } diff --git a/ArchiSteamFarm/Core/Statistics.cs b/ArchiSteamFarm/Core/Statistics.cs index 768698c0b..b105a4c0e 100644 --- a/ArchiSteamFarm/Core/Statistics.cs +++ b/ArchiSteamFarm/Core/Statistics.cs @@ -41,281 +41,139 @@ using ArchiSteamFarm.Web; using ArchiSteamFarm.Web.Responses; using Newtonsoft.Json; -namespace ArchiSteamFarm.Core { - internal sealed class Statistics : IAsyncDisposable { - private const ushort MaxItemsForFairBots = ArchiWebHandler.MaxItemsInSingleInventoryRequest * WebBrowser.MaxTries; // Determines which fair bots we'll deprioritize when matching due to excessive number of inventory requests they need to make, which are likely to fail in the process or cause excessive delays - private const byte MaxMatchedBotsHard = 40; // Determines how many bots we can attempt to match in total, where match attempt is equal to analyzing bot's inventory - private const byte MaxMatchingRounds = 10; // Determines maximum amount of matching rounds we're going to consider before leaving the rest of work for the next batch - private const byte MinAnnouncementCheckTTL = 6; // Minimum amount of hours we must wait before checking eligibility for Announcement, should be lower than MinPersonaStateTTL - private const byte MinHeartBeatTTL = 10; // Minimum amount of minutes we must wait before sending next HeartBeat - private const byte MinItemsCount = 100; // Minimum amount of items to be eligible for public listing - private const byte MinPersonaStateTTL = 8; // Minimum amount of hours we must wait before requesting persona state update - private const string URL = "https://" + SharedInfo.StatisticsServer; +namespace ArchiSteamFarm.Core; - private static readonly ImmutableHashSet AcceptedMatchableTypes = ImmutableHashSet.Create( - Asset.EType.Emoticon, - Asset.EType.FoilTradingCard, - Asset.EType.ProfileBackground, - Asset.EType.TradingCard - ); +internal sealed class Statistics : IAsyncDisposable { + private const ushort MaxItemsForFairBots = ArchiWebHandler.MaxItemsInSingleInventoryRequest * WebBrowser.MaxTries; // Determines which fair bots we'll deprioritize when matching due to excessive number of inventory requests they need to make, which are likely to fail in the process or cause excessive delays + private const byte MaxMatchedBotsHard = 40; // Determines how many bots we can attempt to match in total, where match attempt is equal to analyzing bot's inventory + private const byte MaxMatchingRounds = 10; // Determines maximum amount of matching rounds we're going to consider before leaving the rest of work for the next batch + private const byte MinAnnouncementCheckTTL = 6; // Minimum amount of hours we must wait before checking eligibility for Announcement, should be lower than MinPersonaStateTTL + private const byte MinHeartBeatTTL = 10; // Minimum amount of minutes we must wait before sending next HeartBeat + private const byte MinItemsCount = 100; // Minimum amount of items to be eligible for public listing + private const byte MinPersonaStateTTL = 8; // Minimum amount of hours we must wait before requesting persona state update + private const string URL = "https://" + SharedInfo.StatisticsServer; - private readonly Bot Bot; - private readonly SemaphoreSlim MatchActivelySemaphore = new(1, 1); + private static readonly ImmutableHashSet AcceptedMatchableTypes = ImmutableHashSet.Create( + Asset.EType.Emoticon, + Asset.EType.FoilTradingCard, + Asset.EType.ProfileBackground, + Asset.EType.TradingCard + ); + + private readonly Bot Bot; + private readonly SemaphoreSlim MatchActivelySemaphore = new(1, 1); #pragma warning disable CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private readonly Timer MatchActivelyTimer; + private readonly Timer MatchActivelyTimer; #pragma warning restore CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private readonly SemaphoreSlim RequestsSemaphore = new(1, 1); + private readonly SemaphoreSlim RequestsSemaphore = new(1, 1); - private DateTime LastAnnouncementCheck; - private DateTime LastHeartBeat; - private DateTime LastPersonaStateRequest; - private bool ShouldSendHeartBeats; + private DateTime LastAnnouncementCheck; + private DateTime LastHeartBeat; + private DateTime LastPersonaStateRequest; + private bool ShouldSendHeartBeats; - internal Statistics(Bot bot) { - Bot = bot ?? throw new ArgumentNullException(nameof(bot)); + internal Statistics(Bot bot) { + Bot = bot ?? throw new ArgumentNullException(nameof(bot)); - MatchActivelyTimer = new Timer( - MatchActively, - null, - TimeSpan.FromHours(1) + TimeSpan.FromSeconds(ASF.LoadBalancingDelay * Bot.Bots?.Count ?? 0), // Delay - TimeSpan.FromHours(8) // Period - ); + MatchActivelyTimer = new Timer( + MatchActively, + null, + TimeSpan.FromHours(1) + TimeSpan.FromSeconds(ASF.LoadBalancingDelay * Bot.Bots?.Count ?? 0), // Delay + TimeSpan.FromHours(8) // Period + ); + } + + public async ValueTask DisposeAsync() { + MatchActivelySemaphore.Dispose(); + RequestsSemaphore.Dispose(); + + await MatchActivelyTimer.DisposeAsync().ConfigureAwait(false); + } + + internal async Task OnHeartBeat() { + // Request persona update if needed + if ((DateTime.UtcNow > LastPersonaStateRequest.AddHours(MinPersonaStateTTL)) && (DateTime.UtcNow > LastAnnouncementCheck.AddHours(MinAnnouncementCheckTTL))) { + LastPersonaStateRequest = DateTime.UtcNow; + Bot.RequestPersonaStateUpdate(); } - public async ValueTask DisposeAsync() { - MatchActivelySemaphore.Dispose(); - RequestsSemaphore.Dispose(); - - await MatchActivelyTimer.DisposeAsync().ConfigureAwait(false); + if (!ShouldSendHeartBeats || (DateTime.UtcNow < LastHeartBeat.AddMinutes(MinHeartBeatTTL))) { + return; } - internal async Task OnHeartBeat() { - // Request persona update if needed - if ((DateTime.UtcNow > LastPersonaStateRequest.AddHours(MinPersonaStateTTL)) && (DateTime.UtcNow > LastAnnouncementCheck.AddHours(MinAnnouncementCheckTTL))) { - LastPersonaStateRequest = DateTime.UtcNow; - Bot.RequestPersonaStateUpdate(); - } + if (!await RequestsSemaphore.WaitAsync(0).ConfigureAwait(false)) { + return; + } - if (!ShouldSendHeartBeats || (DateTime.UtcNow < LastHeartBeat.AddMinutes(MinHeartBeatTTL))) { + try { + Uri request = new($"{URL}/Api/HeartBeat"); + + Dictionary data = new(2, StringComparer.Ordinal) { + { "Guid", (ASF.GlobalDatabase?.Identifier ?? Guid.NewGuid()).ToString("N") }, + { "SteamID", Bot.SteamID.ToString(CultureInfo.InvariantCulture) } + }; + + BasicResponse? response = await Bot.ArchiWebHandler.WebBrowser.UrlPost(request, data: data, requestOptions: WebBrowser.ERequestOptions.ReturnClientErrors).ConfigureAwait(false); + + if (response == null) { return; } - if (!await RequestsSemaphore.WaitAsync(0).ConfigureAwait(false)) { + if (response.StatusCode.IsClientErrorCode()) { + LastHeartBeat = DateTime.MinValue; + ShouldSendHeartBeats = false; + return; } - try { - Uri request = new($"{URL}/Api/HeartBeat"); + LastHeartBeat = DateTime.UtcNow; + } finally { + RequestsSemaphore.Release(); + } + } - Dictionary data = new(2, StringComparer.Ordinal) { - { "Guid", (ASF.GlobalDatabase?.Identifier ?? Guid.NewGuid()).ToString("N") }, - { "SteamID", Bot.SteamID.ToString(CultureInfo.InvariantCulture) } - }; + internal async Task OnLoggedOn() { + if (!await Bot.ArchiWebHandler.JoinGroup(SharedInfo.ASFGroupSteamID).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiWebHandler.JoinGroup))); + } + } - BasicResponse? response = await Bot.ArchiWebHandler.WebBrowser.UrlPost(request, data: data, requestOptions: WebBrowser.ERequestOptions.ReturnClientErrors).ConfigureAwait(false); - - if (response == null) { - return; - } - - if (response.StatusCode.IsClientErrorCode()) { - LastHeartBeat = DateTime.MinValue; - ShouldSendHeartBeats = false; - - return; - } - - LastHeartBeat = DateTime.UtcNow; - } finally { - RequestsSemaphore.Release(); - } + internal async Task OnPersonaState(string? nickname = null, string? avatarHash = null) { + if ((DateTime.UtcNow < LastAnnouncementCheck.AddHours(MinAnnouncementCheckTTL)) && (ShouldSendHeartBeats || (LastHeartBeat == DateTime.MinValue))) { + return; } - internal async Task OnLoggedOn() { - if (!await Bot.ArchiWebHandler.JoinGroup(SharedInfo.ASFGroupSteamID).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiWebHandler.JoinGroup))); - } - } + await RequestsSemaphore.WaitAsync().ConfigureAwait(false); - internal async Task OnPersonaState(string? nickname = null, string? avatarHash = null) { + try { if ((DateTime.UtcNow < LastAnnouncementCheck.AddHours(MinAnnouncementCheckTTL)) && (ShouldSendHeartBeats || (LastHeartBeat == DateTime.MinValue))) { return; } - await RequestsSemaphore.WaitAsync().ConfigureAwait(false); + // Don't announce if we don't meet conditions + bool? eligible = await IsEligibleForListing().ConfigureAwait(false); - try { - if ((DateTime.UtcNow < LastAnnouncementCheck.AddHours(MinAnnouncementCheckTTL)) && (ShouldSendHeartBeats || (LastHeartBeat == DateTime.MinValue))) { - return; - } + if (!eligible.HasValue) { + // This is actually network failure, so we'll stop sending heartbeats but not record it as valid check + ShouldSendHeartBeats = false; - // Don't announce if we don't meet conditions - bool? eligible = await IsEligibleForListing().ConfigureAwait(false); - - if (!eligible.HasValue) { - // This is actually network failure, so we'll stop sending heartbeats but not record it as valid check - ShouldSendHeartBeats = false; - - return; - } - - if (!eligible.Value) { - LastAnnouncementCheck = DateTime.UtcNow; - ShouldSendHeartBeats = false; - - return; - } - - string? tradeToken = await Bot.ArchiHandler.GetTradeToken().ConfigureAwait(false); - - if (string.IsNullOrEmpty(tradeToken)) { - // This is actually network failure, so we'll stop sending heartbeats but not record it as valid check - ShouldSendHeartBeats = false; - - return; - } - - HashSet acceptedMatchableTypes = Bot.BotConfig.MatchableTypes.Where(AcceptedMatchableTypes.Contains).ToHashSet(); - - if (acceptedMatchableTypes.Count == 0) { - Bot.ArchiLogger.LogNullError(nameof(acceptedMatchableTypes)); - LastAnnouncementCheck = DateTime.UtcNow; - ShouldSendHeartBeats = false; - - return; - } - - HashSet inventory; - - try { - inventory = await Bot.ArchiWebHandler.GetInventoryAsync().Where(item => item.Tradable && acceptedMatchableTypes.Contains(item.Type)).ToHashSetAsync().ConfigureAwait(false); - } catch (HttpRequestException e) { - Bot.ArchiLogger.LogGenericWarningException(e); - - // This is actually inventory failure, so we'll stop sending heartbeats but not record it as valid check - ShouldSendHeartBeats = false; - - return; - } catch (Exception e) { - Bot.ArchiLogger.LogGenericException(e); - - // This is actually inventory failure, so we'll stop sending heartbeats but not record it as valid check - ShouldSendHeartBeats = false; - - return; - } + return; + } + if (!eligible.Value) { LastAnnouncementCheck = DateTime.UtcNow; + ShouldSendHeartBeats = false; - // This is actual inventory - if (inventory.Count < MinItemsCount) { - ShouldSendHeartBeats = false; - - return; - } - - Uri request = new($"{URL}/Api/Announce"); - - Dictionary data = new(9, StringComparer.Ordinal) { - { "AvatarHash", avatarHash ?? "" }, - { "GamesCount", inventory.Select(static item => item.RealAppID).Distinct().Count().ToString(CultureInfo.InvariantCulture) }, - { "Guid", (ASF.GlobalDatabase?.Identifier ?? Guid.NewGuid()).ToString("N") }, - { "ItemsCount", inventory.Count.ToString(CultureInfo.InvariantCulture) }, - { "MatchableTypes", JsonConvert.SerializeObject(acceptedMatchableTypes) }, - { "MatchEverything", Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.MatchEverything) ? "1" : "0" }, - { "Nickname", nickname ?? "" }, - { "SteamID", Bot.SteamID.ToString(CultureInfo.InvariantCulture) }, - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - { "TradeToken", tradeToken! } - }; - - BasicResponse? response = await Bot.ArchiWebHandler.WebBrowser.UrlPost(request, data: data, requestOptions: WebBrowser.ERequestOptions.ReturnClientErrors).ConfigureAwait(false); - - if (response == null) { - return; - } - - if (response.StatusCode.IsClientErrorCode()) { - LastHeartBeat = DateTime.MinValue; - ShouldSendHeartBeats = false; - - return; - } - - LastHeartBeat = DateTime.UtcNow; - ShouldSendHeartBeats = true; - } finally { - RequestsSemaphore.Release(); - } - } - - private async Task?> GetListedUsers() { - Uri request = new($"{URL}/Api/Bots"); - - ObjectResponse>? response = await Bot.ArchiWebHandler.WebBrowser.UrlGetToJsonObject>(request).ConfigureAwait(false); - - return response?.Content; - } - - private async Task IsEligibleForListing() { - bool? isEligibleForMatching = await IsEligibleForMatching().ConfigureAwait(false); - - if (isEligibleForMatching != true) { - return isEligibleForMatching; + return; } - // Bot must have public inventory - bool? hasPublicInventory = await Bot.HasPublicInventory().ConfigureAwait(false); + string? tradeToken = await Bot.ArchiHandler.GetTradeToken().ConfigureAwait(false); - if (hasPublicInventory != true) { - Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"{nameof(Bot.HasPublicInventory)}: {hasPublicInventory?.ToString() ?? "null"}")); - - return hasPublicInventory; - } - - return true; - } - - private async Task IsEligibleForMatching() { - // Bot must have ASF 2FA - if (!Bot.HasMobileAuthenticator) { - Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"{nameof(Bot.HasMobileAuthenticator)}: {Bot.HasMobileAuthenticator}")); - - return false; - } - - // Bot must have STM enable in TradingPreferences - if (!Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.SteamTradeMatcher)) { - Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"{nameof(Bot.BotConfig.TradingPreferences)}: {Bot.BotConfig.TradingPreferences}")); - - return false; - } - - // Bot must have at least one accepted matchable type set - if ((Bot.BotConfig.MatchableTypes.Count == 0) || Bot.BotConfig.MatchableTypes.All(static type => !AcceptedMatchableTypes.Contains(type))) { - Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"{nameof(Bot.BotConfig.MatchableTypes)}: {string.Join(", ", Bot.BotConfig.MatchableTypes)}")); - - return false; - } - - // Bot must have valid API key (e.g. not being restricted account) - bool? hasValidApiKey = await Bot.ArchiWebHandler.HasValidApiKey().ConfigureAwait(false); - - if (hasValidApiKey != true) { - Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"{nameof(Bot.ArchiWebHandler.HasValidApiKey)}: {hasValidApiKey?.ToString() ?? "null"}")); - - return hasValidApiKey; - } - - return true; - } - - private async void MatchActively(object? state = null) { - if (!Bot.IsConnectedAndLoggedOn || Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.MatchEverything) || !Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.MatchActively) || (await IsEligibleForMatching().ConfigureAwait(false) != true)) { - Bot.ArchiLogger.LogGenericTrace(Strings.ErrorAborted); + if (string.IsNullOrEmpty(tradeToken)) { + // This is actually network failure, so we'll stop sending heartbeats but not record it as valid check + ShouldSendHeartBeats = false; return; } @@ -323,526 +181,668 @@ namespace ArchiSteamFarm.Core { HashSet acceptedMatchableTypes = Bot.BotConfig.MatchableTypes.Where(AcceptedMatchableTypes.Contains).ToHashSet(); if (acceptedMatchableTypes.Count == 0) { - Bot.ArchiLogger.LogGenericTrace(Strings.ErrorAborted); + Bot.ArchiLogger.LogNullError(nameof(acceptedMatchableTypes)); + LastAnnouncementCheck = DateTime.UtcNow; + ShouldSendHeartBeats = false; return; } - if (!await MatchActivelySemaphore.WaitAsync(0).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericTrace(Strings.ErrorAborted); - - return; - } + HashSet inventory; try { - Bot.ArchiLogger.LogGenericTrace(Strings.Starting); - - Dictionary? GivenAssetIDs, ISet? ReceivedAssetIDs)> triedSteamIDs = new(); - - bool shouldContinueMatching = true; - bool tradedSomething = false; - - for (byte i = 0; (i < MaxMatchingRounds) && shouldContinueMatching; i++) { - if ((i > 0) && tradedSomething) { - // After each round we wait at least 5 minutes for all bots to react - await Task.Delay(5 * 60 * 1000).ConfigureAwait(false); - } - - if (!Bot.IsConnectedAndLoggedOn || Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.MatchEverything) || !Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.MatchActively) || (await IsEligibleForMatching().ConfigureAwait(false) != true)) { - Bot.ArchiLogger.LogGenericTrace(Strings.ErrorAborted); - - break; - } - -#pragma warning disable CA2000 // False positive, we're actually wrapping it in the using clause below exactly for that purpose - using (await Bot.Actions.GetTradingLock().ConfigureAwait(false)) { -#pragma warning restore CA2000 // False positive, we're actually wrapping it in the using clause below exactly for that purpose - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.ActivelyMatchingItems, i)); - (shouldContinueMatching, tradedSomething) = await MatchActivelyRound(acceptedMatchableTypes, triedSteamIDs).ConfigureAwait(false); - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.DoneActivelyMatchingItems, i)); - } - } - - Bot.ArchiLogger.LogGenericTrace(Strings.Done); - } finally { - MatchActivelySemaphore.Release(); - } - } - - private async Task<(bool ShouldContinueMatching, bool TradedSomething)> MatchActivelyRound(IReadOnlyCollection acceptedMatchableTypes, IDictionary? GivenAssetIDs, ISet? ReceivedAssetIDs)> triedSteamIDs) { - if ((acceptedMatchableTypes == null) || (acceptedMatchableTypes.Count == 0)) { - throw new ArgumentNullException(nameof(acceptedMatchableTypes)); - } - - if (triedSteamIDs == null) { - throw new ArgumentNullException(nameof(triedSteamIDs)); - } - - HashSet ourInventory; - - try { - ourInventory = await Bot.ArchiWebHandler.GetInventoryAsync().Where(item => acceptedMatchableTypes.Contains(item.Type) && !Bot.BotDatabase.MatchActivelyBlacklistedAppIDs.Contains(item.RealAppID)).ToHashSetAsync().ConfigureAwait(false); + inventory = await Bot.ArchiWebHandler.GetInventoryAsync().Where(item => item.Tradable && acceptedMatchableTypes.Contains(item.Type)).ToHashSetAsync().ConfigureAwait(false); } catch (HttpRequestException e) { Bot.ArchiLogger.LogGenericWarningException(e); - return (false, false); + // This is actually inventory failure, so we'll stop sending heartbeats but not record it as valid check + ShouldSendHeartBeats = false; + + return; } catch (Exception e) { Bot.ArchiLogger.LogGenericException(e); - return (false, false); + // This is actually inventory failure, so we'll stop sending heartbeats but not record it as valid check + ShouldSendHeartBeats = false; + + return; } - if (ourInventory.Count == 0) { - Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(ourInventory))); + LastAnnouncementCheck = DateTime.UtcNow; - return (false, false); + // This is actual inventory + if (inventory.Count < MinItemsCount) { + ShouldSendHeartBeats = false; + + return; } - (Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> ourFullState, Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> ourTradableState) = Trading.GetDividedInventoryState(ourInventory); + Uri request = new($"{URL}/Api/Announce"); - if (Trading.IsEmptyForMatching(ourFullState, ourTradableState)) { - // User doesn't have any more dupes in the inventory - Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, $"{nameof(ourFullState)} || {nameof(ourTradableState)}")); + Dictionary data = new(9, StringComparer.Ordinal) { + { "AvatarHash", avatarHash ?? "" }, + { "GamesCount", inventory.Select(static item => item.RealAppID).Distinct().Count().ToString(CultureInfo.InvariantCulture) }, + { "Guid", (ASF.GlobalDatabase?.Identifier ?? Guid.NewGuid()).ToString("N") }, + { "ItemsCount", inventory.Count.ToString(CultureInfo.InvariantCulture) }, + { "MatchableTypes", JsonConvert.SerializeObject(acceptedMatchableTypes) }, + { "MatchEverything", Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.MatchEverything) ? "1" : "0" }, + { "Nickname", nickname ?? "" }, + { "SteamID", Bot.SteamID.ToString(CultureInfo.InvariantCulture) }, - return (false, false); + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + { "TradeToken", tradeToken! } + }; + + BasicResponse? response = await Bot.ArchiWebHandler.WebBrowser.UrlPost(request, data: data, requestOptions: WebBrowser.ERequestOptions.ReturnClientErrors).ConfigureAwait(false); + + if (response == null) { + return; } - ImmutableHashSet? listedUsers = await GetListedUsers().ConfigureAwait(false); + if (response.StatusCode.IsClientErrorCode()) { + LastHeartBeat = DateTime.MinValue; + ShouldSendHeartBeats = false; - if ((listedUsers == null) || (listedUsers.Count == 0)) { - Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(listedUsers))); - - return (false, false); + return; } - byte maxTradeHoldDuration = ASF.GlobalConfig?.MaxTradeHoldDuration ?? GlobalConfig.DefaultMaxTradeHoldDuration; - byte totalMatches = 0; + LastHeartBeat = DateTime.UtcNow; + ShouldSendHeartBeats = true; + } finally { + RequestsSemaphore.Release(); + } + } - HashSet<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity)> skippedSetsThisRound = new(); + private async Task?> GetListedUsers() { + Uri request = new($"{URL}/Api/Bots"); - foreach (ListedUser listedUser in listedUsers.Where(listedUser => (listedUser.SteamID != Bot.SteamID) && acceptedMatchableTypes.Any(listedUser.MatchableTypes.Contains) && (!triedSteamIDs.TryGetValue(listedUser.SteamID, out (byte Tries, ISet? GivenAssetIDs, ISet? ReceivedAssetIDs) attempt) || (attempt.Tries < byte.MaxValue)) && !Bot.IsBlacklistedFromTrades(listedUser.SteamID)).OrderBy(listedUser => triedSteamIDs.TryGetValue(listedUser.SteamID, out (byte Tries, ISet? GivenAssetIDs, ISet? ReceivedAssetIDs) attempt) ? attempt.Tries : 0).ThenByDescending(static listedUser => listedUser.MatchEverything).ThenByDescending(static listedUser => listedUser.MatchEverything || (listedUser.ItemsCount < MaxItemsForFairBots)).ThenByDescending(static listedUser => listedUser.Score)) { - HashSet<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity)> wantedSets = ourTradableState.Keys.Where(set => !skippedSetsThisRound.Contains(set) && listedUser.MatchableTypes.Contains(set.Type)).ToHashSet(); + ObjectResponse>? response = await Bot.ArchiWebHandler.WebBrowser.UrlGetToJsonObject>(request).ConfigureAwait(false); - if (wantedSets.Count == 0) { - continue; + return response?.Content; + } + + private async Task IsEligibleForListing() { + bool? isEligibleForMatching = await IsEligibleForMatching().ConfigureAwait(false); + + if (isEligibleForMatching != true) { + return isEligibleForMatching; + } + + // Bot must have public inventory + bool? hasPublicInventory = await Bot.HasPublicInventory().ConfigureAwait(false); + + if (hasPublicInventory != true) { + Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"{nameof(Bot.HasPublicInventory)}: {hasPublicInventory?.ToString() ?? "null"}")); + + return hasPublicInventory; + } + + return true; + } + + private async Task IsEligibleForMatching() { + // Bot must have ASF 2FA + if (!Bot.HasMobileAuthenticator) { + Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"{nameof(Bot.HasMobileAuthenticator)}: {Bot.HasMobileAuthenticator}")); + + return false; + } + + // Bot must have STM enable in TradingPreferences + if (!Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.SteamTradeMatcher)) { + Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"{nameof(Bot.BotConfig.TradingPreferences)}: {Bot.BotConfig.TradingPreferences}")); + + return false; + } + + // Bot must have at least one accepted matchable type set + if ((Bot.BotConfig.MatchableTypes.Count == 0) || Bot.BotConfig.MatchableTypes.All(static type => !AcceptedMatchableTypes.Contains(type))) { + Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"{nameof(Bot.BotConfig.MatchableTypes)}: {string.Join(", ", Bot.BotConfig.MatchableTypes)}")); + + return false; + } + + // Bot must have valid API key (e.g. not being restricted account) + bool? hasValidApiKey = await Bot.ArchiWebHandler.HasValidApiKey().ConfigureAwait(false); + + if (hasValidApiKey != true) { + Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"{nameof(Bot.ArchiWebHandler.HasValidApiKey)}: {hasValidApiKey?.ToString() ?? "null"}")); + + return hasValidApiKey; + } + + return true; + } + + private async void MatchActively(object? state = null) { + if (!Bot.IsConnectedAndLoggedOn || Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.MatchEverything) || !Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.MatchActively) || (await IsEligibleForMatching().ConfigureAwait(false) != true)) { + Bot.ArchiLogger.LogGenericTrace(Strings.ErrorAborted); + + return; + } + + HashSet acceptedMatchableTypes = Bot.BotConfig.MatchableTypes.Where(AcceptedMatchableTypes.Contains).ToHashSet(); + + if (acceptedMatchableTypes.Count == 0) { + Bot.ArchiLogger.LogGenericTrace(Strings.ErrorAborted); + + return; + } + + if (!await MatchActivelySemaphore.WaitAsync(0).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericTrace(Strings.ErrorAborted); + + return; + } + + try { + Bot.ArchiLogger.LogGenericTrace(Strings.Starting); + + Dictionary? GivenAssetIDs, ISet? ReceivedAssetIDs)> triedSteamIDs = new(); + + bool shouldContinueMatching = true; + bool tradedSomething = false; + + for (byte i = 0; (i < MaxMatchingRounds) && shouldContinueMatching; i++) { + if ((i > 0) && tradedSomething) { + // After each round we wait at least 5 minutes for all bots to react + await Task.Delay(5 * 60 * 1000).ConfigureAwait(false); } - if (++totalMatches > MaxMatchedBotsHard) { + if (!Bot.IsConnectedAndLoggedOn || Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.MatchEverything) || !Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.MatchActively) || (await IsEligibleForMatching().ConfigureAwait(false) != true)) { + Bot.ArchiLogger.LogGenericTrace(Strings.ErrorAborted); + break; } - Bot.ArchiLogger.LogGenericTrace($"{listedUser.SteamID}..."); +#pragma warning disable CA2000 // False positive, we're actually wrapping it in the using clause below exactly for that purpose + using (await Bot.Actions.GetTradingLock().ConfigureAwait(false)) { +#pragma warning restore CA2000 // False positive, we're actually wrapping it in the using clause below exactly for that purpose + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.ActivelyMatchingItems, i)); + (shouldContinueMatching, tradedSomething) = await MatchActivelyRound(acceptedMatchableTypes, triedSteamIDs).ConfigureAwait(false); + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.DoneActivelyMatchingItems, i)); + } + } - byte? holdDuration = await Bot.ArchiWebHandler.GetTradeHoldDurationForUser(listedUser.SteamID, listedUser.TradeToken).ConfigureAwait(false); + Bot.ArchiLogger.LogGenericTrace(Strings.Done); + } finally { + MatchActivelySemaphore.Release(); + } + } - switch (holdDuration) { - case null: - Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(holdDuration))); + private async Task<(bool ShouldContinueMatching, bool TradedSomething)> MatchActivelyRound(IReadOnlyCollection acceptedMatchableTypes, IDictionary? GivenAssetIDs, ISet? ReceivedAssetIDs)> triedSteamIDs) { + if ((acceptedMatchableTypes == null) || (acceptedMatchableTypes.Count == 0)) { + throw new ArgumentNullException(nameof(acceptedMatchableTypes)); + } + if (triedSteamIDs == null) { + throw new ArgumentNullException(nameof(triedSteamIDs)); + } + + HashSet ourInventory; + + try { + ourInventory = await Bot.ArchiWebHandler.GetInventoryAsync().Where(item => acceptedMatchableTypes.Contains(item.Type) && !Bot.BotDatabase.MatchActivelyBlacklistedAppIDs.Contains(item.RealAppID)).ToHashSetAsync().ConfigureAwait(false); + } catch (HttpRequestException e) { + Bot.ArchiLogger.LogGenericWarningException(e); + + return (false, false); + } catch (Exception e) { + Bot.ArchiLogger.LogGenericException(e); + + return (false, false); + } + + if (ourInventory.Count == 0) { + Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(ourInventory))); + + return (false, false); + } + + (Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> ourFullState, Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> ourTradableState) = Trading.GetDividedInventoryState(ourInventory); + + if (Trading.IsEmptyForMatching(ourFullState, ourTradableState)) { + // User doesn't have any more dupes in the inventory + Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, $"{nameof(ourFullState)} || {nameof(ourTradableState)}")); + + return (false, false); + } + + ImmutableHashSet? listedUsers = await GetListedUsers().ConfigureAwait(false); + + if ((listedUsers == null) || (listedUsers.Count == 0)) { + Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(listedUsers))); + + return (false, false); + } + + byte maxTradeHoldDuration = ASF.GlobalConfig?.MaxTradeHoldDuration ?? GlobalConfig.DefaultMaxTradeHoldDuration; + byte totalMatches = 0; + + HashSet<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity)> skippedSetsThisRound = new(); + + foreach (ListedUser listedUser in listedUsers.Where(listedUser => (listedUser.SteamID != Bot.SteamID) && acceptedMatchableTypes.Any(listedUser.MatchableTypes.Contains) && (!triedSteamIDs.TryGetValue(listedUser.SteamID, out (byte Tries, ISet? GivenAssetIDs, ISet? ReceivedAssetIDs) attempt) || (attempt.Tries < byte.MaxValue)) && !Bot.IsBlacklistedFromTrades(listedUser.SteamID)).OrderBy(listedUser => triedSteamIDs.TryGetValue(listedUser.SteamID, out (byte Tries, ISet? GivenAssetIDs, ISet? ReceivedAssetIDs) attempt) ? attempt.Tries : 0).ThenByDescending(static listedUser => listedUser.MatchEverything).ThenByDescending(static listedUser => listedUser.MatchEverything || (listedUser.ItemsCount < MaxItemsForFairBots)).ThenByDescending(static listedUser => listedUser.Score)) { + HashSet<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity)> wantedSets = ourTradableState.Keys.Where(set => !skippedSetsThisRound.Contains(set) && listedUser.MatchableTypes.Contains(set.Type)).ToHashSet(); + + if (wantedSets.Count == 0) { + continue; + } + + if (++totalMatches > MaxMatchedBotsHard) { + break; + } + + Bot.ArchiLogger.LogGenericTrace($"{listedUser.SteamID}..."); + + byte? holdDuration = await Bot.ArchiWebHandler.GetTradeHoldDurationForUser(listedUser.SteamID, listedUser.TradeToken).ConfigureAwait(false); + + switch (holdDuration) { + case null: + Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(holdDuration))); + + continue; + case > 0 when holdDuration.Value > maxTradeHoldDuration: + Bot.ArchiLogger.LogGenericTrace($"{holdDuration.Value} > {maxTradeHoldDuration}"); + + continue; + } + + HashSet theirInventory; + + try { + theirInventory = await Bot.ArchiWebHandler.GetInventoryAsync(listedUser.SteamID).Where(item => (!listedUser.MatchEverything || item.Tradable) && wantedSets.Contains((item.RealAppID, item.Type, item.Rarity)) && ((holdDuration.Value == 0) || !(item.Type is Asset.EType.FoilTradingCard or Asset.EType.TradingCard && CardsFarmer.SalesBlacklist.Contains(item.RealAppID)))).ToHashSetAsync().ConfigureAwait(false); + } catch (HttpRequestException e) { + Bot.ArchiLogger.LogGenericWarningException(e); + + continue; + } catch (Exception e) { + Bot.ArchiLogger.LogGenericException(e); + + continue; + } + + if (theirInventory.Count == 0) { + Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(theirInventory))); + + continue; + } + + HashSet<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity)> skippedSetsThisUser = new(); + + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> theirTradableState = Trading.GetTradableInventoryState(theirInventory); + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> inventoryStateChanges = new(); + + for (byte i = 0; i < Trading.MaxTradesPerAccount; i++) { + byte itemsInTrade = 0; + HashSet<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity)> skippedSetsThisTrade = new(); + + Dictionary classIDsToGive = new(); + Dictionary classIDsToReceive = new(); + Dictionary fairClassIDsToGive = new(); + Dictionary fairClassIDsToReceive = new(); + + foreach (((uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) set, Dictionary ourFullItems) in ourFullState.Where(set => !skippedSetsThisUser.Contains(set.Key) && listedUser.MatchableTypes.Contains(set.Key.Type) && set.Value.Values.Any(static count => count > 1))) { + if (!ourTradableState.TryGetValue(set, out Dictionary? ourTradableItems) || (ourTradableItems.Count == 0)) { continue; - case > 0 when holdDuration.Value > maxTradeHoldDuration: - Bot.ArchiLogger.LogGenericTrace($"{holdDuration.Value} > {maxTradeHoldDuration}"); + } + if (!theirTradableState.TryGetValue(set, out Dictionary? theirTradableItems) || (theirTradableItems.Count == 0)) { continue; - } + } - HashSet theirInventory; + // Those 2 collections are on user-basis since we can't be sure that the trade passes through (and therefore we need to keep original state in case of failure) + Dictionary ourFullSet = new(ourFullItems); + Dictionary ourTradableSet = new(ourTradableItems); - try { - theirInventory = await Bot.ArchiWebHandler.GetInventoryAsync(listedUser.SteamID).Where(item => (!listedUser.MatchEverything || item.Tradable) && wantedSets.Contains((item.RealAppID, item.Type, item.Rarity)) && ((holdDuration.Value == 0) || !(item.Type is Asset.EType.FoilTradingCard or Asset.EType.TradingCard && CardsFarmer.SalesBlacklist.Contains(item.RealAppID)))).ToHashSetAsync().ConfigureAwait(false); - } catch (HttpRequestException e) { - Bot.ArchiLogger.LogGenericWarningException(e); + // We also have to take into account changes that happened in previous trades with this user, so this block will adapt to that + if (inventoryStateChanges.TryGetValue(set, out Dictionary? pastChanges) && (pastChanges.Count > 0)) { + foreach ((ulong classID, uint amount) in pastChanges) { + if (!ourFullSet.TryGetValue(classID, out uint fullAmount) || (fullAmount == 0) || (fullAmount < amount)) { + Bot.ArchiLogger.LogNullError(nameof(fullAmount)); - continue; - } catch (Exception e) { - Bot.ArchiLogger.LogGenericException(e); - - continue; - } - - if (theirInventory.Count == 0) { - Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(theirInventory))); - - continue; - } - - HashSet<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity)> skippedSetsThisUser = new(); - - Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> theirTradableState = Trading.GetTradableInventoryState(theirInventory); - Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> inventoryStateChanges = new(); - - for (byte i = 0; i < Trading.MaxTradesPerAccount; i++) { - byte itemsInTrade = 0; - HashSet<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity)> skippedSetsThisTrade = new(); - - Dictionary classIDsToGive = new(); - Dictionary classIDsToReceive = new(); - Dictionary fairClassIDsToGive = new(); - Dictionary fairClassIDsToReceive = new(); - - foreach (((uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) set, Dictionary ourFullItems) in ourFullState.Where(set => !skippedSetsThisUser.Contains(set.Key) && listedUser.MatchableTypes.Contains(set.Key.Type) && set.Value.Values.Any(static count => count > 1))) { - if (!ourTradableState.TryGetValue(set, out Dictionary? ourTradableItems) || (ourTradableItems.Count == 0)) { - continue; - } - - if (!theirTradableState.TryGetValue(set, out Dictionary? theirTradableItems) || (theirTradableItems.Count == 0)) { - continue; - } - - // Those 2 collections are on user-basis since we can't be sure that the trade passes through (and therefore we need to keep original state in case of failure) - Dictionary ourFullSet = new(ourFullItems); - Dictionary ourTradableSet = new(ourTradableItems); - - // We also have to take into account changes that happened in previous trades with this user, so this block will adapt to that - if (inventoryStateChanges.TryGetValue(set, out Dictionary? pastChanges) && (pastChanges.Count > 0)) { - foreach ((ulong classID, uint amount) in pastChanges) { - if (!ourFullSet.TryGetValue(classID, out uint fullAmount) || (fullAmount == 0) || (fullAmount < amount)) { - Bot.ArchiLogger.LogNullError(nameof(fullAmount)); - - return (false, skippedSetsThisRound.Count > 0); - } - - if (fullAmount > amount) { - ourFullSet[classID] = fullAmount - amount; - } else { - ourFullSet.Remove(classID); - } - - if (!ourTradableSet.TryGetValue(classID, out uint tradableAmount) || (tradableAmount == 0) || (tradableAmount < amount)) { - Bot.ArchiLogger.LogNullError(nameof(tradableAmount)); - - return (false, skippedSetsThisRound.Count > 0); - } - - if (fullAmount > amount) { - ourTradableSet[classID] = fullAmount - amount; - } else { - ourTradableSet.Remove(classID); - } + return (false, skippedSetsThisRound.Count > 0); } - if (Trading.IsEmptyForMatching(ourFullSet, ourTradableSet)) { + if (fullAmount > amount) { + ourFullSet[classID] = fullAmount - amount; + } else { + ourFullSet.Remove(classID); + } + + if (!ourTradableSet.TryGetValue(classID, out uint tradableAmount) || (tradableAmount == 0) || (tradableAmount < amount)) { + Bot.ArchiLogger.LogNullError(nameof(tradableAmount)); + + return (false, skippedSetsThisRound.Count > 0); + } + + if (fullAmount > amount) { + ourTradableSet[classID] = fullAmount - amount; + } else { + ourTradableSet.Remove(classID); + } + } + + if (Trading.IsEmptyForMatching(ourFullSet, ourTradableSet)) { + continue; + } + } + + bool match; + + do { + match = false; + + foreach ((ulong ourItem, uint ourFullAmount) in ourFullSet.Where(static item => item.Value > 1).OrderByDescending(static item => item.Value)) { + if (!ourTradableSet.TryGetValue(ourItem, out uint ourTradableAmount) || (ourTradableAmount == 0)) { continue; } - } - bool match; - - do { - match = false; - - foreach ((ulong ourItem, uint ourFullAmount) in ourFullSet.Where(static item => item.Value > 1).OrderByDescending(static item => item.Value)) { - if (!ourTradableSet.TryGetValue(ourItem, out uint ourTradableAmount) || (ourTradableAmount == 0)) { + foreach ((ulong theirItem, uint theirTradableAmount) in theirTradableItems.OrderBy(item => ourFullSet.TryGetValue(item.Key, out uint ourAmountOfTheirItem) ? ourAmountOfTheirItem : 0)) { + if (ourFullSet.TryGetValue(theirItem, out uint ourAmountOfTheirItem) && (ourFullAmount <= ourAmountOfTheirItem + 1)) { continue; } - foreach ((ulong theirItem, uint theirTradableAmount) in theirTradableItems.OrderBy(item => ourFullSet.TryGetValue(item.Key, out uint ourAmountOfTheirItem) ? ourAmountOfTheirItem : 0)) { - if (ourFullSet.TryGetValue(theirItem, out uint ourAmountOfTheirItem) && (ourFullAmount <= ourAmountOfTheirItem + 1)) { + if (!listedUser.MatchEverything) { + // We have a potential match, let's check fairness for them + fairClassIDsToGive.TryGetValue(ourItem, out uint fairGivenAmount); + fairClassIDsToReceive.TryGetValue(theirItem, out uint fairReceivedAmount); + fairClassIDsToGive[ourItem] = ++fairGivenAmount; + fairClassIDsToReceive[theirItem] = ++fairReceivedAmount; + + // Filter their inventory for the sets we're trading or have traded with this user + HashSet fairFiltered = theirInventory.Where(item => ((item.RealAppID == set.RealAppID) && (item.Type == set.Type) && (item.Rarity == set.Rarity)) || skippedSetsThisTrade.Contains((item.RealAppID, item.Type, item.Rarity))).Select(static item => item.CreateShallowCopy()).ToHashSet(); + + // Copy list to HashSet + HashSet fairItemsToGive = Trading.GetTradableItemsFromInventory(ourInventory.Where(item => ((item.RealAppID == set.RealAppID) && (item.Type == set.Type) && (item.Rarity == set.Rarity)) || skippedSetsThisTrade.Contains((item.RealAppID, item.Type, item.Rarity))).Select(static item => item.CreateShallowCopy()).ToHashSet(), fairClassIDsToGive.ToDictionary(static classID => classID.Key, static classID => classID.Value)); + HashSet fairItemsToReceive = Trading.GetTradableItemsFromInventory(fairFiltered.Select(static item => item.CreateShallowCopy()).ToHashSet(), fairClassIDsToReceive.ToDictionary(static classID => classID.Key, static classID => classID.Value)); + + // Actual check: + if (!Trading.IsTradeNeutralOrBetter(fairFiltered, fairItemsToReceive, fairItemsToGive)) { + if (fairGivenAmount > 1) { + fairClassIDsToGive[ourItem] = fairGivenAmount - 1; + } else { + fairClassIDsToGive.Remove(ourItem); + } + + if (fairReceivedAmount > 1) { + fairClassIDsToReceive[theirItem] = fairReceivedAmount - 1; + } else { + fairClassIDsToReceive.Remove(theirItem); + } + continue; } - - if (!listedUser.MatchEverything) { - // We have a potential match, let's check fairness for them - fairClassIDsToGive.TryGetValue(ourItem, out uint fairGivenAmount); - fairClassIDsToReceive.TryGetValue(theirItem, out uint fairReceivedAmount); - fairClassIDsToGive[ourItem] = ++fairGivenAmount; - fairClassIDsToReceive[theirItem] = ++fairReceivedAmount; - - // Filter their inventory for the sets we're trading or have traded with this user - HashSet fairFiltered = theirInventory.Where(item => ((item.RealAppID == set.RealAppID) && (item.Type == set.Type) && (item.Rarity == set.Rarity)) || skippedSetsThisTrade.Contains((item.RealAppID, item.Type, item.Rarity))).Select(static item => item.CreateShallowCopy()).ToHashSet(); - - // Copy list to HashSet - HashSet fairItemsToGive = Trading.GetTradableItemsFromInventory(ourInventory.Where(item => ((item.RealAppID == set.RealAppID) && (item.Type == set.Type) && (item.Rarity == set.Rarity)) || skippedSetsThisTrade.Contains((item.RealAppID, item.Type, item.Rarity))).Select(static item => item.CreateShallowCopy()).ToHashSet(), fairClassIDsToGive.ToDictionary(static classID => classID.Key, static classID => classID.Value)); - HashSet fairItemsToReceive = Trading.GetTradableItemsFromInventory(fairFiltered.Select(static item => item.CreateShallowCopy()).ToHashSet(), fairClassIDsToReceive.ToDictionary(static classID => classID.Key, static classID => classID.Value)); - - // Actual check: - if (!Trading.IsTradeNeutralOrBetter(fairFiltered, fairItemsToReceive, fairItemsToGive)) { - if (fairGivenAmount > 1) { - fairClassIDsToGive[ourItem] = fairGivenAmount - 1; - } else { - fairClassIDsToGive.Remove(ourItem); - } - - if (fairReceivedAmount > 1) { - fairClassIDsToReceive[theirItem] = fairReceivedAmount - 1; - } else { - fairClassIDsToReceive.Remove(theirItem); - } - - continue; - } - } - - // Skip this set from the remaining of this round - skippedSetsThisTrade.Add(set); - - // Update our state based on given items - classIDsToGive[ourItem] = classIDsToGive.TryGetValue(ourItem, out uint ourGivenAmount) ? ourGivenAmount + 1 : 1; - ourFullSet[ourItem] = ourFullAmount - 1; // We don't need to remove anything here because we can guarantee that ourItem.Value is at least 2 - - if (inventoryStateChanges.TryGetValue(set, out Dictionary? currentChanges)) { - currentChanges[ourItem] = currentChanges.TryGetValue(ourItem, out uint amount) ? amount + 1 : 1; - } else { - inventoryStateChanges[set] = new Dictionary { - { ourItem, 1 } - }; - } - - // Update our state based on received items - classIDsToReceive[theirItem] = classIDsToReceive.TryGetValue(theirItem, out uint ourReceivedAmount) ? ourReceivedAmount + 1 : 1; - ourFullSet[theirItem] = ourAmountOfTheirItem + 1; - - if (ourTradableAmount > 1) { - ourTradableSet[ourItem] = ourTradableAmount - 1; - } else { - ourTradableSet.Remove(ourItem); - } - - // Update their state based on taken items - if (theirTradableAmount > 1) { - theirTradableItems[theirItem] = theirTradableAmount - 1; - } else { - theirTradableItems.Remove(theirItem); - } - - itemsInTrade += 2; - - match = true; - - break; } - if (match) { - break; + // Skip this set from the remaining of this round + skippedSetsThisTrade.Add(set); + + // Update our state based on given items + classIDsToGive[ourItem] = classIDsToGive.TryGetValue(ourItem, out uint ourGivenAmount) ? ourGivenAmount + 1 : 1; + ourFullSet[ourItem] = ourFullAmount - 1; // We don't need to remove anything here because we can guarantee that ourItem.Value is at least 2 + + if (inventoryStateChanges.TryGetValue(set, out Dictionary? currentChanges)) { + currentChanges[ourItem] = currentChanges.TryGetValue(ourItem, out uint amount) ? amount + 1 : 1; + } else { + inventoryStateChanges[set] = new Dictionary { + { ourItem, 1 } + }; } + + // Update our state based on received items + classIDsToReceive[theirItem] = classIDsToReceive.TryGetValue(theirItem, out uint ourReceivedAmount) ? ourReceivedAmount + 1 : 1; + ourFullSet[theirItem] = ourAmountOfTheirItem + 1; + + if (ourTradableAmount > 1) { + ourTradableSet[ourItem] = ourTradableAmount - 1; + } else { + ourTradableSet.Remove(ourItem); + } + + // Update their state based on taken items + if (theirTradableAmount > 1) { + theirTradableItems[theirItem] = theirTradableAmount - 1; + } else { + theirTradableItems.Remove(theirItem); + } + + itemsInTrade += 2; + + match = true; + + break; } - } while (match && (itemsInTrade < Trading.MaxItemsPerTrade - 1)); - if (itemsInTrade >= Trading.MaxItemsPerTrade - 1) { - break; + if (match) { + break; + } } - } - - if (skippedSetsThisTrade.Count == 0) { - Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(skippedSetsThisTrade))); + } while (match && (itemsInTrade < Trading.MaxItemsPerTrade - 1)); + if (itemsInTrade >= Trading.MaxItemsPerTrade - 1) { break; } - - // Remove the items from inventories - HashSet itemsToGive = Trading.GetTradableItemsFromInventory(ourInventory, classIDsToGive); - HashSet itemsToReceive = Trading.GetTradableItemsFromInventory(theirInventory, classIDsToReceive); - - if ((itemsToGive.Count != itemsToReceive.Count) || !Trading.IsFairExchange(itemsToGive, itemsToReceive)) { - // Failsafe - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, Strings.ErrorAborted)); - - return (false, skippedSetsThisRound.Count > 0); - } - - if (triedSteamIDs.TryGetValue(listedUser.SteamID, out (byte Tries, ISet? GivenAssetIDs, ISet? ReceivedAssetIDs) previousAttempt)) { - if ((previousAttempt.GivenAssetIDs == null) || (previousAttempt.ReceivedAssetIDs == null) || (itemsToGive.Select(static item => item.AssetID).All(previousAttempt.GivenAssetIDs.Contains) && itemsToReceive.Select(static item => item.AssetID).All(previousAttempt.ReceivedAssetIDs.Contains))) { - // This user didn't respond in our previous round, avoid him for remaining ones - triedSteamIDs[listedUser.SteamID] = (byte.MaxValue, previousAttempt.GivenAssetIDs, previousAttempt.ReceivedAssetIDs); - - break; - } - - previousAttempt.GivenAssetIDs.UnionWith(itemsToGive.Select(static item => item.AssetID)); - previousAttempt.ReceivedAssetIDs.UnionWith(itemsToReceive.Select(static item => item.AssetID)); - } else { - previousAttempt.GivenAssetIDs = new HashSet(itemsToGive.Select(static item => item.AssetID)); - previousAttempt.ReceivedAssetIDs = new HashSet(itemsToReceive.Select(static item => item.AssetID)); - } - - triedSteamIDs[listedUser.SteamID] = (++previousAttempt.Tries, previousAttempt.GivenAssetIDs, previousAttempt.ReceivedAssetIDs); - - Bot.ArchiLogger.LogGenericTrace($"{Bot.SteamID} <- {string.Join(", ", itemsToReceive.Select(static item => $"{item.RealAppID}/{item.Type}-{item.ClassID} #{item.Amount}"))} | {string.Join(", ", itemsToGive.Select(static item => $"{item.RealAppID}/{item.Type}-{item.ClassID} #{item.Amount}"))} -> {listedUser.SteamID}"); - - (bool success, HashSet? mobileTradeOfferIDs) = await Bot.ArchiWebHandler.SendTradeOffer(listedUser.SteamID, itemsToGive, itemsToReceive, listedUser.TradeToken, true).ConfigureAwait(false); - - if ((mobileTradeOfferIDs?.Count > 0) && Bot.HasMobileAuthenticator) { - (bool twoFactorSuccess, _, _) = await Bot.Actions.HandleTwoFactorAuthenticationConfirmations(true, Confirmation.EType.Trade, mobileTradeOfferIDs, true).ConfigureAwait(false); - - if (!twoFactorSuccess) { - Bot.ArchiLogger.LogGenericTrace(Strings.WarningFailed); - - return (false, skippedSetsThisRound.Count > 0); - } - } - - if (!success) { - Bot.ArchiLogger.LogGenericTrace(Strings.WarningFailed); - - break; - } - - // Add itemsToGive to theirInventory to reflect their current state if we're over MaxItemsPerTrade - theirInventory.UnionWith(itemsToGive); - - skippedSetsThisUser.UnionWith(skippedSetsThisTrade); - Bot.ArchiLogger.LogGenericTrace(Strings.Success); } - if (skippedSetsThisUser.Count == 0) { - if (skippedSetsThisRound.Count == 0) { - // If we didn't find any match on clean round, this user isn't going to have anything interesting for us anytime soon - triedSteamIDs[listedUser.SteamID] = (byte.MaxValue, null, null); - } + if (skippedSetsThisTrade.Count == 0) { + Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(skippedSetsThisTrade))); - continue; - } - - skippedSetsThisRound.UnionWith(skippedSetsThisUser); - - foreach ((uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) skippedSet in skippedSetsThisUser) { - ourFullState.Remove(skippedSet); - ourTradableState.Remove(skippedSet); - } - - if (Trading.IsEmptyForMatching(ourFullState, ourTradableState)) { - // User doesn't have any more dupes in the inventory break; } - ourFullState.TrimExcess(); - ourTradableState.TrimExcess(); + // Remove the items from inventories + HashSet itemsToGive = Trading.GetTradableItemsFromInventory(ourInventory, classIDsToGive); + HashSet itemsToReceive = Trading.GetTradableItemsFromInventory(theirInventory, classIDsToReceive); + + if ((itemsToGive.Count != itemsToReceive.Count) || !Trading.IsFairExchange(itemsToGive, itemsToReceive)) { + // Failsafe + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, Strings.ErrorAborted)); + + return (false, skippedSetsThisRound.Count > 0); + } + + if (triedSteamIDs.TryGetValue(listedUser.SteamID, out (byte Tries, ISet? GivenAssetIDs, ISet? ReceivedAssetIDs) previousAttempt)) { + if ((previousAttempt.GivenAssetIDs == null) || (previousAttempt.ReceivedAssetIDs == null) || (itemsToGive.Select(static item => item.AssetID).All(previousAttempt.GivenAssetIDs.Contains) && itemsToReceive.Select(static item => item.AssetID).All(previousAttempt.ReceivedAssetIDs.Contains))) { + // This user didn't respond in our previous round, avoid him for remaining ones + triedSteamIDs[listedUser.SteamID] = (byte.MaxValue, previousAttempt.GivenAssetIDs, previousAttempt.ReceivedAssetIDs); + + break; + } + + previousAttempt.GivenAssetIDs.UnionWith(itemsToGive.Select(static item => item.AssetID)); + previousAttempt.ReceivedAssetIDs.UnionWith(itemsToReceive.Select(static item => item.AssetID)); + } else { + previousAttempt.GivenAssetIDs = new HashSet(itemsToGive.Select(static item => item.AssetID)); + previousAttempt.ReceivedAssetIDs = new HashSet(itemsToReceive.Select(static item => item.AssetID)); + } + + triedSteamIDs[listedUser.SteamID] = (++previousAttempt.Tries, previousAttempt.GivenAssetIDs, previousAttempt.ReceivedAssetIDs); + + Bot.ArchiLogger.LogGenericTrace($"{Bot.SteamID} <- {string.Join(", ", itemsToReceive.Select(static item => $"{item.RealAppID}/{item.Type}-{item.ClassID} #{item.Amount}"))} | {string.Join(", ", itemsToGive.Select(static item => $"{item.RealAppID}/{item.Type}-{item.ClassID} #{item.Amount}"))} -> {listedUser.SteamID}"); + + (bool success, HashSet? mobileTradeOfferIDs) = await Bot.ArchiWebHandler.SendTradeOffer(listedUser.SteamID, itemsToGive, itemsToReceive, listedUser.TradeToken, true).ConfigureAwait(false); + + if ((mobileTradeOfferIDs?.Count > 0) && Bot.HasMobileAuthenticator) { + (bool twoFactorSuccess, _, _) = await Bot.Actions.HandleTwoFactorAuthenticationConfirmations(true, Confirmation.EType.Trade, mobileTradeOfferIDs, true).ConfigureAwait(false); + + if (!twoFactorSuccess) { + Bot.ArchiLogger.LogGenericTrace(Strings.WarningFailed); + + return (false, skippedSetsThisRound.Count > 0); + } + } + + if (!success) { + Bot.ArchiLogger.LogGenericTrace(Strings.WarningFailed); + + break; + } + + // Add itemsToGive to theirInventory to reflect their current state if we're over MaxItemsPerTrade + theirInventory.UnionWith(itemsToGive); + + skippedSetsThisUser.UnionWith(skippedSetsThisTrade); + Bot.ArchiLogger.LogGenericTrace(Strings.Success); } - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.ActivelyMatchingItemsRound, skippedSetsThisRound.Count)); + if (skippedSetsThisUser.Count == 0) { + if (skippedSetsThisRound.Count == 0) { + // If we didn't find any match on clean round, this user isn't going to have anything interesting for us anytime soon + triedSteamIDs[listedUser.SteamID] = (byte.MaxValue, null, null); + } - // Keep matching when we either traded something this round (so it makes sense for a refresh) or if we didn't try all available bots yet (so it makes sense to keep going) - return ((totalMatches > 0) && ((skippedSetsThisRound.Count > 0) || triedSteamIDs.Values.All(static data => data.Tries < 2)), skippedSetsThisRound.Count > 0); + continue; + } + + skippedSetsThisRound.UnionWith(skippedSetsThisUser); + + foreach ((uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) skippedSet in skippedSetsThisUser) { + ourFullState.Remove(skippedSet); + ourTradableState.Remove(skippedSet); + } + + if (Trading.IsEmptyForMatching(ourFullState, ourTradableState)) { + // User doesn't have any more dupes in the inventory + break; + } + + ourFullState.TrimExcess(); + ourTradableState.TrimExcess(); } - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - private sealed class ListedUser { + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.ActivelyMatchingItemsRound, skippedSetsThisRound.Count)); + + // Keep matching when we either traded something this round (so it makes sense for a refresh) or if we didn't try all available bots yet (so it makes sense to keep going) + return ((totalMatches > 0) && ((skippedSetsThisRound.Count > 0) || triedSteamIDs.Values.All(static data => data.Tries < 2)), skippedSetsThisRound.Count > 0); + } + + [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] + private sealed class ListedUser { #pragma warning disable CS0649 // False positive, it's a field set during json deserialization - [JsonProperty(PropertyName = "items_count", Required = Required.Always)] - internal readonly ushort ItemsCount; + [JsonProperty(PropertyName = "items_count", Required = Required.Always)] + internal readonly ushort ItemsCount; #pragma warning restore CS0649 // False positive, it's a field set during json deserialization - internal readonly HashSet MatchableTypes = new(); + internal readonly HashSet MatchableTypes = new(); #pragma warning disable CS0649 // False positive, it's a field set during json deserialization - [JsonProperty(PropertyName = "steam_id", Required = Required.Always)] - internal readonly ulong SteamID; + [JsonProperty(PropertyName = "steam_id", Required = Required.Always)] + internal readonly ulong SteamID; #pragma warning restore CS0649 // False positive, it's a field set during json deserialization - [JsonProperty(PropertyName = "trade_token", Required = Required.Always)] - internal readonly string TradeToken = ""; + [JsonProperty(PropertyName = "trade_token", Required = Required.Always)] + internal readonly string TradeToken = ""; - internal float Score => GamesCount / (float) ItemsCount; + internal float Score => GamesCount / (float) ItemsCount; #pragma warning disable CS0649 // False positive, it's a field set during json deserialization - [JsonProperty(PropertyName = "games_count", Required = Required.Always)] - private readonly ushort GamesCount; + [JsonProperty(PropertyName = "games_count", Required = Required.Always)] + private readonly ushort GamesCount; #pragma warning restore CS0649 // False positive, it's a field set during json deserialization - internal bool MatchEverything { get; private set; } + internal bool MatchEverything { get; private set; } - [JsonProperty(PropertyName = "matchable_backgrounds", Required = Required.Always)] - private byte MatchableBackgroundsNumber { - set { - switch (value) { - case 0: - MatchableTypes.Remove(Asset.EType.ProfileBackground); + [JsonProperty(PropertyName = "matchable_backgrounds", Required = Required.Always)] + private byte MatchableBackgroundsNumber { + set { + switch (value) { + case 0: + MatchableTypes.Remove(Asset.EType.ProfileBackground); - break; - case 1: - MatchableTypes.Add(Asset.EType.ProfileBackground); + break; + case 1: + MatchableTypes.Add(Asset.EType.ProfileBackground); - break; - default: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(value), value)); + break; + default: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(value), value)); - return; - } + return; } } - - [JsonProperty(PropertyName = "matchable_cards", Required = Required.Always)] - private byte MatchableCardsNumber { - set { - switch (value) { - case 0: - MatchableTypes.Remove(Asset.EType.TradingCard); - - break; - case 1: - MatchableTypes.Add(Asset.EType.TradingCard); - - break; - default: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(value), value)); - - return; - } - } - } - - [JsonProperty(PropertyName = "matchable_emoticons", Required = Required.Always)] - private byte MatchableEmoticonsNumber { - set { - switch (value) { - case 0: - MatchableTypes.Remove(Asset.EType.Emoticon); - - break; - case 1: - MatchableTypes.Add(Asset.EType.Emoticon); - - break; - default: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(value), value)); - - return; - } - } - } - - [JsonProperty(PropertyName = "matchable_foil_cards", Required = Required.Always)] - private byte MatchableFoilCardsNumber { - set { - switch (value) { - case 0: - MatchableTypes.Remove(Asset.EType.FoilTradingCard); - - break; - case 1: - MatchableTypes.Add(Asset.EType.FoilTradingCard); - - break; - default: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(value), value)); - - return; - } - } - } - - [JsonProperty(PropertyName = "match_everything", Required = Required.Always)] - private byte MatchEverythingNumber { - set { - switch (value) { - case 0: - MatchEverything = false; - - break; - case 1: - MatchEverything = true; - - break; - default: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(value), value)); - - return; - } - } - } - - [JsonConstructor] - private ListedUser() { } } + + [JsonProperty(PropertyName = "matchable_cards", Required = Required.Always)] + private byte MatchableCardsNumber { + set { + switch (value) { + case 0: + MatchableTypes.Remove(Asset.EType.TradingCard); + + break; + case 1: + MatchableTypes.Add(Asset.EType.TradingCard); + + break; + default: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(value), value)); + + return; + } + } + } + + [JsonProperty(PropertyName = "matchable_emoticons", Required = Required.Always)] + private byte MatchableEmoticonsNumber { + set { + switch (value) { + case 0: + MatchableTypes.Remove(Asset.EType.Emoticon); + + break; + case 1: + MatchableTypes.Add(Asset.EType.Emoticon); + + break; + default: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(value), value)); + + return; + } + } + } + + [JsonProperty(PropertyName = "matchable_foil_cards", Required = Required.Always)] + private byte MatchableFoilCardsNumber { + set { + switch (value) { + case 0: + MatchableTypes.Remove(Asset.EType.FoilTradingCard); + + break; + case 1: + MatchableTypes.Add(Asset.EType.FoilTradingCard); + + break; + default: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(value), value)); + + return; + } + } + } + + [JsonProperty(PropertyName = "match_everything", Required = Required.Always)] + private byte MatchEverythingNumber { + set { + switch (value) { + case 0: + MatchEverything = false; + + break; + case 1: + MatchEverything = true; + + break; + default: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(value), value)); + + return; + } + } + } + + [JsonConstructor] + private ListedUser() { } } } diff --git a/ArchiSteamFarm/Core/Utilities.cs b/ArchiSteamFarm/Core/Utilities.cs index 0a18b465c..60013ce7d 100644 --- a/ArchiSteamFarm/Core/Utilities.cs +++ b/ArchiSteamFarm/Core/Utilities.cs @@ -41,381 +41,381 @@ using JetBrains.Annotations; using SteamKit2; using Zxcvbn; -namespace ArchiSteamFarm.Core { - public static class Utilities { - private const byte TimeoutForLongRunningTasksInSeconds = 60; +namespace ArchiSteamFarm.Core; - // normally we'd just use words like "steam" and "farm", but the library we're currently using is a bit iffy about banned words, so we need to also add combinations such as "steamfarm" - private static readonly ImmutableHashSet ForbiddenPasswordPhrases = ImmutableHashSet.Create(StringComparer.InvariantCultureIgnoreCase, "archisteamfarm", "archi", "steam", "farm", "archisteam", "archifarm", "steamfarm", "asf", "asffarm", "password"); +public static class Utilities { + private const byte TimeoutForLongRunningTasksInSeconds = 60; - // Normally we wouldn't need to use this singleton, but we want to ensure decent randomness across entire program's lifetime - private static readonly Random Random = new(); + // normally we'd just use words like "steam" and "farm", but the library we're currently using is a bit iffy about banned words, so we need to also add combinations such as "steamfarm" + private static readonly ImmutableHashSet ForbiddenPasswordPhrases = ImmutableHashSet.Create(StringComparer.InvariantCultureIgnoreCase, "archisteamfarm", "archi", "steam", "farm", "archisteam", "archifarm", "steamfarm", "asf", "asffarm", "password"); - [PublicAPI] - public static string GetArgsAsText(string[] args, byte argsToSkip, string delimiter) { - if (args == null) { - throw new ArgumentNullException(nameof(args)); - } + // Normally we wouldn't need to use this singleton, but we want to ensure decent randomness across entire program's lifetime + private static readonly Random Random = new(); - if (args.Length <= argsToSkip) { - throw new InvalidOperationException($"{nameof(args.Length)} && {nameof(argsToSkip)}"); - } - - if (string.IsNullOrEmpty(delimiter)) { - throw new ArgumentNullException(nameof(delimiter)); - } - - return string.Join(delimiter, args.Skip(argsToSkip)); + [PublicAPI] + public static string GetArgsAsText(string[] args, byte argsToSkip, string delimiter) { + if (args == null) { + throw new ArgumentNullException(nameof(args)); } - [PublicAPI] - public static string GetArgsAsText(string text, byte argsToSkip) { - if (string.IsNullOrEmpty(text)) { - throw new ArgumentNullException(nameof(text)); - } - - string[] args = text.Split(Array.Empty(), argsToSkip + 1, StringSplitOptions.RemoveEmptyEntries); - - return args[^1]; + if (args.Length <= argsToSkip) { + throw new InvalidOperationException($"{nameof(args.Length)} && {nameof(argsToSkip)}"); } - [PublicAPI] - public static string? GetCookieValue(this CookieContainer cookieContainer, Uri uri, string name) { - if (cookieContainer == null) { - throw new ArgumentNullException(nameof(cookieContainer)); - } + if (string.IsNullOrEmpty(delimiter)) { + throw new ArgumentNullException(nameof(delimiter)); + } - if (uri == null) { - throw new ArgumentNullException(nameof(uri)); - } + return string.Join(delimiter, args.Skip(argsToSkip)); + } - if (string.IsNullOrEmpty(name)) { - throw new ArgumentNullException(nameof(name)); - } + [PublicAPI] + public static string GetArgsAsText(string text, byte argsToSkip) { + if (string.IsNullOrEmpty(text)) { + throw new ArgumentNullException(nameof(text)); + } - CookieCollection cookies = cookieContainer.GetCookies(uri); + string[] args = text.Split(Array.Empty(), argsToSkip + 1, StringSplitOptions.RemoveEmptyEntries); + + return args[^1]; + } + + [PublicAPI] + public static string? GetCookieValue(this CookieContainer cookieContainer, Uri uri, string name) { + if (cookieContainer == null) { + throw new ArgumentNullException(nameof(cookieContainer)); + } + + if (uri == null) { + throw new ArgumentNullException(nameof(uri)); + } + + if (string.IsNullOrEmpty(name)) { + throw new ArgumentNullException(nameof(name)); + } + + CookieCollection cookies = cookieContainer.GetCookies(uri); #if NETFRAMEWORK return cookies.Count > 0 ? (from Cookie cookie in cookies where cookie.Name == name select cookie.Value).FirstOrDefault() : null; #else - return cookies.Count > 0 ? cookies.FirstOrDefault(cookie => cookie.Name == name)?.Value : null; + return cookies.Count > 0 ? cookies.FirstOrDefault(cookie => cookie.Name == name)?.Value : null; #endif + } + + [PublicAPI] + public static uint GetUnixTime() => (uint) DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + [PublicAPI] + public static async void InBackground(Action action, bool longRunning = false) { + if (action == null) { + throw new ArgumentNullException(nameof(action)); } - [PublicAPI] - public static uint GetUnixTime() => (uint) DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + TaskCreationOptions options = TaskCreationOptions.DenyChildAttach; - [PublicAPI] - public static async void InBackground(Action action, bool longRunning = false) { - if (action == null) { - throw new ArgumentNullException(nameof(action)); - } - - TaskCreationOptions options = TaskCreationOptions.DenyChildAttach; - - if (longRunning) { - options |= TaskCreationOptions.LongRunning | TaskCreationOptions.PreferFairness; - } - - await Task.Factory.StartNew(action, CancellationToken.None, options, TaskScheduler.Default).ConfigureAwait(false); + if (longRunning) { + options |= TaskCreationOptions.LongRunning | TaskCreationOptions.PreferFairness; } - [PublicAPI] - public static async void InBackground(Func function, bool longRunning = false) { - if (function == null) { - throw new ArgumentNullException(nameof(function)); - } + await Task.Factory.StartNew(action, CancellationToken.None, options, TaskScheduler.Default).ConfigureAwait(false); + } - TaskCreationOptions options = TaskCreationOptions.DenyChildAttach; - - if (longRunning) { - options |= TaskCreationOptions.LongRunning | TaskCreationOptions.PreferFairness; - } - - await Task.Factory.StartNew(function, CancellationToken.None, options, TaskScheduler.Default).ConfigureAwait(false); + [PublicAPI] + public static async void InBackground(Func function, bool longRunning = false) { + if (function == null) { + throw new ArgumentNullException(nameof(function)); } - [PublicAPI] - public static async Task> InParallel(IEnumerable> tasks) { - if (tasks == null) { - throw new ArgumentNullException(nameof(tasks)); - } + TaskCreationOptions options = TaskCreationOptions.DenyChildAttach; - IList results; - - switch (ASF.GlobalConfig?.OptimizationMode) { - case GlobalConfig.EOptimizationMode.MinMemoryUsage: - results = new List(); - - foreach (Task task in tasks) { - results.Add(await task.ConfigureAwait(false)); - } - - break; - default: - results = await Task.WhenAll(tasks).ConfigureAwait(false); - - break; - } - - return results; + if (longRunning) { + options |= TaskCreationOptions.LongRunning | TaskCreationOptions.PreferFairness; } - [PublicAPI] - public static async Task InParallel(IEnumerable tasks) { - if (tasks == null) { - throw new ArgumentNullException(nameof(tasks)); - } + await Task.Factory.StartNew(function, CancellationToken.None, options, TaskScheduler.Default).ConfigureAwait(false); + } - switch (ASF.GlobalConfig?.OptimizationMode) { - case GlobalConfig.EOptimizationMode.MinMemoryUsage: - foreach (Task task in tasks) { - await task.ConfigureAwait(false); - } - - break; - default: - await Task.WhenAll(tasks).ConfigureAwait(false); - - break; - } + [PublicAPI] + public static async Task> InParallel(IEnumerable> tasks) { + if (tasks == null) { + throw new ArgumentNullException(nameof(tasks)); } - [PublicAPI] - public static bool IsClientErrorCode(this HttpStatusCode statusCode) => statusCode is >= HttpStatusCode.BadRequest and < HttpStatusCode.InternalServerError; + IList results; - [PublicAPI] - public static bool IsServerErrorCode(this HttpStatusCode statusCode) => statusCode is >= HttpStatusCode.InternalServerError and < (HttpStatusCode) 600; + switch (ASF.GlobalConfig?.OptimizationMode) { + case GlobalConfig.EOptimizationMode.MinMemoryUsage: + results = new List(); - [PublicAPI] - public static bool IsSuccessCode(this HttpStatusCode statusCode) => statusCode is >= HttpStatusCode.OK and < HttpStatusCode.Ambiguous; - - [PublicAPI] - public static bool IsValidCdKey(string key) { - if (string.IsNullOrEmpty(key)) { - throw new ArgumentNullException(nameof(key)); - } - - return Regex.IsMatch(key, @"^[0-9A-Z]{4,7}-[0-9A-Z]{4,7}-[0-9A-Z]{4,7}(?:(?:-[0-9A-Z]{4,7})?(?:-[0-9A-Z]{4,7}))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); - } - - [PublicAPI] - public static bool IsValidHexadecimalText(string text) { - if (string.IsNullOrEmpty(text)) { - throw new ArgumentNullException(nameof(text)); - } - - return (text.Length % 2 == 0) && text.All(Uri.IsHexDigit); - } - - [PublicAPI] - public static int RandomNext() { - lock (Random) { -#pragma warning disable CA5394 // This call isn't used in a security-sensitive manner - return Random.Next(); -#pragma warning restore CA5394 // This call isn't used in a security-sensitive manner - } - } - - [Obsolete("ASF no longer uses this function, re-implement it yourself if needed.")] - [PublicAPI] - public static int RandomNext(int maxValue) { - switch (maxValue) { - case < 0: - throw new ArgumentOutOfRangeException(nameof(maxValue)); - case <= 1: - return 0; - default: - lock (Random) { -#pragma warning disable CA5394 // This call isn't used in a security-sensitive manner - return Random.Next(maxValue); -#pragma warning restore CA5394 // This call isn't used in a security-sensitive manner - } - } - } - - [PublicAPI] - public static int RandomNext(int minValue, int maxValue) { - if (minValue > maxValue) { - throw new InvalidOperationException($"{nameof(minValue)} && {nameof(maxValue)}"); - } - - if (minValue >= maxValue - 1) { - return minValue; - } - - lock (Random) { -#pragma warning disable CA5394 // This call isn't used in a security-sensitive manner - return Random.Next(minValue, maxValue); -#pragma warning restore CA5394 // This call isn't used in a security-sensitive manner - } - } - - [Obsolete("ASF no longer uses this function, re-implement it yourself if needed.")] - [PublicAPI] - public static IEnumerable SelectElementNodes(this IElement element, string xpath) => element.SelectNodes(xpath).OfType(); - - [PublicAPI] - public static IEnumerable SelectNodes(this IDocument document, string xpath) { - if (document == null) { - throw new ArgumentNullException(nameof(document)); - } - - return document.Body.SelectNodes(xpath).OfType(); - } - - [PublicAPI] - public static IElement? SelectSingleElementNode(this IElement element, string xpath) => (IElement?) element.SelectSingleNode(xpath); - - [PublicAPI] - public static IElement? SelectSingleNode(this IDocument document, string xpath) { - if (document == null) { - throw new ArgumentNullException(nameof(document)); - } - - return (IElement?) document.Body.SelectSingleNode(xpath); - } - - [PublicAPI] - public static IEnumerable ToEnumerable(this T item) { - yield return item; - } - - [PublicAPI] - public static string ToHumanReadable(this TimeSpan timeSpan) => timeSpan.Humanize(3, maxUnit: TimeUnit.Year, minUnit: TimeUnit.Second); - - [PublicAPI] - public static Task ToLongRunningTask(this AsyncJob job) where T : CallbackMsg { - if (job == null) { - throw new ArgumentNullException(nameof(job)); - } - - job.Timeout = TimeSpan.FromSeconds(TimeoutForLongRunningTasksInSeconds); - - return job.ToTask(); - } - - [PublicAPI] - public static Task.ResultSet> ToLongRunningTask(this AsyncJobMultiple job) where T : CallbackMsg { - if (job == null) { - throw new ArgumentNullException(nameof(job)); - } - - job.Timeout = TimeSpan.FromSeconds(TimeoutForLongRunningTasksInSeconds); - - return job.ToTask(); - } - - internal static void DeleteEmptyDirectoriesRecursively(string directory) { - if (string.IsNullOrEmpty(directory)) { - throw new ArgumentNullException(nameof(directory)); - } - - if (!Directory.Exists(directory)) { - return; - } - - try { - foreach (string subDirectory in Directory.EnumerateDirectories(directory)) { - DeleteEmptyDirectoriesRecursively(subDirectory); + foreach (Task task in tasks) { + results.Add(await task.ConfigureAwait(false)); } - if (!Directory.EnumerateFileSystemEntries(directory).Any()) { - Directory.Delete(directory); - } - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } + break; + default: + results = await Task.WhenAll(tasks).ConfigureAwait(false); + + break; } - internal static bool RelativeDirectoryStartsWith(string directory, params string[] prefixes) { - if (string.IsNullOrEmpty(directory)) { - throw new ArgumentNullException(nameof(directory)); + return results; + } + + [PublicAPI] + public static async Task InParallel(IEnumerable tasks) { + if (tasks == null) { + throw new ArgumentNullException(nameof(tasks)); + } + + switch (ASF.GlobalConfig?.OptimizationMode) { + case GlobalConfig.EOptimizationMode.MinMemoryUsage: + foreach (Task task in tasks) { + await task.ConfigureAwait(false); + } + + break; + default: + await Task.WhenAll(tasks).ConfigureAwait(false); + + break; + } + } + + [PublicAPI] + public static bool IsClientErrorCode(this HttpStatusCode statusCode) => statusCode is >= HttpStatusCode.BadRequest and < HttpStatusCode.InternalServerError; + + [PublicAPI] + public static bool IsServerErrorCode(this HttpStatusCode statusCode) => statusCode is >= HttpStatusCode.InternalServerError and < (HttpStatusCode) 600; + + [PublicAPI] + public static bool IsSuccessCode(this HttpStatusCode statusCode) => statusCode is >= HttpStatusCode.OK and < HttpStatusCode.Ambiguous; + + [PublicAPI] + public static bool IsValidCdKey(string key) { + if (string.IsNullOrEmpty(key)) { + throw new ArgumentNullException(nameof(key)); + } + + return Regex.IsMatch(key, @"^[0-9A-Z]{4,7}-[0-9A-Z]{4,7}-[0-9A-Z]{4,7}(?:(?:-[0-9A-Z]{4,7})?(?:-[0-9A-Z]{4,7}))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + } + + [PublicAPI] + public static bool IsValidHexadecimalText(string text) { + if (string.IsNullOrEmpty(text)) { + throw new ArgumentNullException(nameof(text)); + } + + return (text.Length % 2 == 0) && text.All(Uri.IsHexDigit); + } + + [PublicAPI] + public static int RandomNext() { + lock (Random) { +#pragma warning disable CA5394 // This call isn't used in a security-sensitive manner + return Random.Next(); +#pragma warning restore CA5394 // This call isn't used in a security-sensitive manner + } + } + + [Obsolete("ASF no longer uses this function, re-implement it yourself if needed.")] + [PublicAPI] + public static int RandomNext(int maxValue) { + switch (maxValue) { + case < 0: + throw new ArgumentOutOfRangeException(nameof(maxValue)); + case <= 1: + return 0; + default: + lock (Random) { +#pragma warning disable CA5394 // This call isn't used in a security-sensitive manner + return Random.Next(maxValue); +#pragma warning restore CA5394 // This call isn't used in a security-sensitive manner + } + } + } + + [PublicAPI] + public static int RandomNext(int minValue, int maxValue) { + if (minValue > maxValue) { + throw new InvalidOperationException($"{nameof(minValue)} && {nameof(maxValue)}"); + } + + if (minValue >= maxValue - 1) { + return minValue; + } + + lock (Random) { +#pragma warning disable CA5394 // This call isn't used in a security-sensitive manner + return Random.Next(minValue, maxValue); +#pragma warning restore CA5394 // This call isn't used in a security-sensitive manner + } + } + + [Obsolete("ASF no longer uses this function, re-implement it yourself if needed.")] + [PublicAPI] + public static IEnumerable SelectElementNodes(this IElement element, string xpath) => element.SelectNodes(xpath).OfType(); + + [PublicAPI] + public static IEnumerable SelectNodes(this IDocument document, string xpath) { + if (document == null) { + throw new ArgumentNullException(nameof(document)); + } + + return document.Body.SelectNodes(xpath).OfType(); + } + + [PublicAPI] + public static IElement? SelectSingleElementNode(this IElement element, string xpath) => (IElement?) element.SelectSingleNode(xpath); + + [PublicAPI] + public static IElement? SelectSingleNode(this IDocument document, string xpath) { + if (document == null) { + throw new ArgumentNullException(nameof(document)); + } + + return (IElement?) document.Body.SelectSingleNode(xpath); + } + + [PublicAPI] + public static IEnumerable ToEnumerable(this T item) { + yield return item; + } + + [PublicAPI] + public static string ToHumanReadable(this TimeSpan timeSpan) => timeSpan.Humanize(3, maxUnit: TimeUnit.Year, minUnit: TimeUnit.Second); + + [PublicAPI] + public static Task ToLongRunningTask(this AsyncJob job) where T : CallbackMsg { + if (job == null) { + throw new ArgumentNullException(nameof(job)); + } + + job.Timeout = TimeSpan.FromSeconds(TimeoutForLongRunningTasksInSeconds); + + return job.ToTask(); + } + + [PublicAPI] + public static Task.ResultSet> ToLongRunningTask(this AsyncJobMultiple job) where T : CallbackMsg { + if (job == null) { + throw new ArgumentNullException(nameof(job)); + } + + job.Timeout = TimeSpan.FromSeconds(TimeoutForLongRunningTasksInSeconds); + + return job.ToTask(); + } + + internal static void DeleteEmptyDirectoriesRecursively(string directory) { + if (string.IsNullOrEmpty(directory)) { + throw new ArgumentNullException(nameof(directory)); + } + + if (!Directory.Exists(directory)) { + return; + } + + try { + foreach (string subDirectory in Directory.EnumerateDirectories(directory)) { + DeleteEmptyDirectoriesRecursively(subDirectory); } + if (!Directory.EnumerateFileSystemEntries(directory).Any()) { + Directory.Delete(directory); + } + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + internal static bool RelativeDirectoryStartsWith(string directory, params string[] prefixes) { + if (string.IsNullOrEmpty(directory)) { + throw new ArgumentNullException(nameof(directory)); + } + #pragma warning disable CA1508 // False positive, params could be null when explicitly set - if ((prefixes == null) || (prefixes.Length == 0)) { + if ((prefixes == null) || (prefixes.Length == 0)) { #pragma warning restore CA1508 // False positive, params could be null when explicitly set - throw new ArgumentNullException(nameof(prefixes)); - } - - return (from prefix in prefixes where directory.Length > prefix.Length let pathSeparator = directory[prefix.Length] where (pathSeparator == Path.DirectorySeparatorChar) || (pathSeparator == Path.AltDirectorySeparatorChar) select prefix).Any(prefix => directory.StartsWith(prefix, StringComparison.Ordinal)); + throw new ArgumentNullException(nameof(prefixes)); } - internal static (bool IsWeak, string? Reason) TestPasswordStrength(string password, ISet? additionallyForbiddenPhrases = null) { - if (string.IsNullOrEmpty(password)) { - throw new ArgumentNullException(nameof(password)); - } + return (from prefix in prefixes where directory.Length > prefix.Length let pathSeparator = directory[prefix.Length] where (pathSeparator == Path.DirectorySeparatorChar) || (pathSeparator == Path.AltDirectorySeparatorChar) select prefix).Any(prefix => directory.StartsWith(prefix, StringComparison.Ordinal)); + } - HashSet forbiddenPhrases = ForbiddenPasswordPhrases.ToHashSet(StringComparer.InvariantCultureIgnoreCase); - - if (additionallyForbiddenPhrases != null) { - forbiddenPhrases.UnionWith(additionallyForbiddenPhrases); - } - - Result result = Zxcvbn.Core.EvaluatePassword(password, forbiddenPhrases); - FeedbackItem feedback = result.Feedback; - - return (result.Score < 4, string.IsNullOrEmpty(feedback.Warning) ? feedback.Suggestions.FirstOrDefault() : feedback.Warning); + internal static (bool IsWeak, string? Reason) TestPasswordStrength(string password, ISet? additionallyForbiddenPhrases = null) { + if (string.IsNullOrEmpty(password)) { + throw new ArgumentNullException(nameof(password)); } - internal static void WarnAboutIncompleteTranslation(ResourceManager resourceManager) { - if (resourceManager == null) { - throw new ArgumentNullException(nameof(resourceManager)); - } + HashSet forbiddenPhrases = ForbiddenPasswordPhrases.ToHashSet(StringComparer.InvariantCultureIgnoreCase); - // Skip translation progress for English and invariant (such as "C") cultures - switch (CultureInfo.CurrentUICulture.TwoLetterISOLanguageName) { - case "en": - case "iv": - case "qps": - return; - } + if (additionallyForbiddenPhrases != null) { + forbiddenPhrases.UnionWith(additionallyForbiddenPhrases); + } - // We can't dispose this resource set, as we can't be sure if it isn't used somewhere else, rely on GC in this case - ResourceSet? defaultResourceSet = resourceManager.GetResourceSet(CultureInfo.GetCultureInfo("en-US"), true, true); + Result result = Zxcvbn.Core.EvaluatePassword(password, forbiddenPhrases); + FeedbackItem feedback = result.Feedback; - if (defaultResourceSet == null) { - ASF.ArchiLogger.LogNullError(nameof(defaultResourceSet)); + return (result.Score < 4, string.IsNullOrEmpty(feedback.Warning) ? feedback.Suggestions.FirstOrDefault() : feedback.Warning); + } + internal static void WarnAboutIncompleteTranslation(ResourceManager resourceManager) { + if (resourceManager == null) { + throw new ArgumentNullException(nameof(resourceManager)); + } + + // Skip translation progress for English and invariant (such as "C") cultures + switch (CultureInfo.CurrentUICulture.TwoLetterISOLanguageName) { + case "en": + case "iv": + case "qps": return; + } + + // We can't dispose this resource set, as we can't be sure if it isn't used somewhere else, rely on GC in this case + ResourceSet? defaultResourceSet = resourceManager.GetResourceSet(CultureInfo.GetCultureInfo("en-US"), true, true); + + if (defaultResourceSet == null) { + ASF.ArchiLogger.LogNullError(nameof(defaultResourceSet)); + + return; + } + + HashSet defaultStringObjects = defaultResourceSet.Cast().ToHashSet(); + + if (defaultStringObjects.Count == 0) { + ASF.ArchiLogger.LogNullError(nameof(defaultStringObjects)); + + return; + } + + // We can't dispose this resource set, as we can't be sure if it isn't used somewhere else, rely on GC in this case + ResourceSet? currentResourceSet = resourceManager.GetResourceSet(CultureInfo.CurrentUICulture, true, true); + + if (currentResourceSet == null) { + ASF.ArchiLogger.LogNullError(nameof(currentResourceSet)); + + return; + } + + HashSet currentStringObjects = currentResourceSet.Cast().ToHashSet(); + + if (currentStringObjects.Count >= defaultStringObjects.Count) { + // Either we have 100% finished translation, or we're missing it entirely and using en-US + HashSet testStringObjects = currentStringObjects.ToHashSet(); + testStringObjects.ExceptWith(defaultStringObjects); + + // If we got 0 as final result, this is the missing language + // Otherwise it's just a small amount of strings that happen to be the same + if (testStringObjects.Count == 0) { + currentStringObjects = testStringObjects; } + } - HashSet defaultStringObjects = defaultResourceSet.Cast().ToHashSet(); - - if (defaultStringObjects.Count == 0) { - ASF.ArchiLogger.LogNullError(nameof(defaultStringObjects)); - - return; - } - - // We can't dispose this resource set, as we can't be sure if it isn't used somewhere else, rely on GC in this case - ResourceSet? currentResourceSet = resourceManager.GetResourceSet(CultureInfo.CurrentUICulture, true, true); - - if (currentResourceSet == null) { - ASF.ArchiLogger.LogNullError(nameof(currentResourceSet)); - - return; - } - - HashSet currentStringObjects = currentResourceSet.Cast().ToHashSet(); - - if (currentStringObjects.Count >= defaultStringObjects.Count) { - // Either we have 100% finished translation, or we're missing it entirely and using en-US - HashSet testStringObjects = currentStringObjects.ToHashSet(); - testStringObjects.ExceptWith(defaultStringObjects); - - // If we got 0 as final result, this is the missing language - // Otherwise it's just a small amount of strings that happen to be the same - if (testStringObjects.Count == 0) { - currentStringObjects = testStringObjects; - } - } - - if (currentStringObjects.Count < defaultStringObjects.Count) { - float translationCompleteness = currentStringObjects.Count / (float) defaultStringObjects.Count; - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.TranslationIncomplete, $"{CultureInfo.CurrentUICulture.Name} ({CultureInfo.CurrentUICulture.EnglishName})", translationCompleteness.ToString("P1", CultureInfo.CurrentCulture))); - } + if (currentStringObjects.Count < defaultStringObjects.Count) { + float translationCompleteness = currentStringObjects.Count / (float) defaultStringObjects.Count; + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.TranslationIncomplete, $"{CultureInfo.CurrentUICulture.Name} ({CultureInfo.CurrentUICulture.EnglishName})", translationCompleteness.ToString("P1", CultureInfo.CurrentCulture))); } } } diff --git a/ArchiSteamFarm/Helpers/ArchiCacheable.cs b/ArchiSteamFarm/Helpers/ArchiCacheable.cs index 1f8fdd1ce..f795b5f1d 100644 --- a/ArchiSteamFarm/Helpers/ArchiCacheable.cs +++ b/ArchiSteamFarm/Helpers/ArchiCacheable.cs @@ -25,87 +25,87 @@ using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; -namespace ArchiSteamFarm.Helpers { - public sealed class ArchiCacheable : IDisposable { - private readonly TimeSpan CacheLifetime; - private readonly SemaphoreSlim InitSemaphore = new(1, 1); - private readonly Func> ResolveFunction; +namespace ArchiSteamFarm.Helpers; - private bool IsInitialized => InitializedAt > DateTime.MinValue; - private bool IsPermanentCache => CacheLifetime == Timeout.InfiniteTimeSpan; - private bool IsRecent => IsPermanentCache || (DateTime.UtcNow.Subtract(InitializedAt) < CacheLifetime); +public sealed class ArchiCacheable : IDisposable { + private readonly TimeSpan CacheLifetime; + private readonly SemaphoreSlim InitSemaphore = new(1, 1); + private readonly Func> ResolveFunction; - private DateTime InitializedAt; - private T? InitializedValue; + private bool IsInitialized => InitializedAt > DateTime.MinValue; + private bool IsPermanentCache => CacheLifetime == Timeout.InfiniteTimeSpan; + private bool IsRecent => IsPermanentCache || (DateTime.UtcNow.Subtract(InitializedAt) < CacheLifetime); - public ArchiCacheable(Func> resolveFunction, TimeSpan? cacheLifetime = null) { - ResolveFunction = resolveFunction ?? throw new ArgumentNullException(nameof(resolveFunction)); - CacheLifetime = cacheLifetime ?? Timeout.InfiniteTimeSpan; + private DateTime InitializedAt; + private T? InitializedValue; + + public ArchiCacheable(Func> resolveFunction, TimeSpan? cacheLifetime = null) { + ResolveFunction = resolveFunction ?? throw new ArgumentNullException(nameof(resolveFunction)); + CacheLifetime = cacheLifetime ?? Timeout.InfiniteTimeSpan; + } + + public void Dispose() => InitSemaphore.Dispose(); + + [PublicAPI] + public async Task<(bool Success, T? Result)> GetValue(EFallback fallback = EFallback.DefaultForType) { + if (!Enum.IsDefined(typeof(EFallback), fallback)) { + throw new InvalidEnumArgumentException(nameof(fallback), (int) fallback, typeof(EFallback)); } - public void Dispose() => InitSemaphore.Dispose(); + if (IsInitialized && IsRecent) { + return (true, InitializedValue); + } - [PublicAPI] - public async Task<(bool Success, T? Result)> GetValue(EFallback fallback = EFallback.DefaultForType) { - if (!Enum.IsDefined(typeof(EFallback), fallback)) { - throw new InvalidEnumArgumentException(nameof(fallback), (int) fallback, typeof(EFallback)); - } + await InitSemaphore.WaitAsync().ConfigureAwait(false); + try { if (IsInitialized && IsRecent) { return (true, InitializedValue); } - await InitSemaphore.WaitAsync().ConfigureAwait(false); + (bool success, T? result) = await ResolveFunction().ConfigureAwait(false); - try { - if (IsInitialized && IsRecent) { - return (true, InitializedValue); - } - - (bool success, T? result) = await ResolveFunction().ConfigureAwait(false); - - if (!success) { - return fallback switch { - EFallback.DefaultForType => (false, default(T?)), - EFallback.FailedNow => (false, result), - EFallback.SuccessPreviously => (false, InitializedValue), - _ => throw new InvalidOperationException(nameof(fallback)) - }; - } - - InitializedValue = result; - InitializedAt = DateTime.UtcNow; - - return (true, result); - } finally { - InitSemaphore.Release(); + if (!success) { + return fallback switch { + EFallback.DefaultForType => (false, default(T?)), + EFallback.FailedNow => (false, result), + EFallback.SuccessPreviously => (false, InitializedValue), + _ => throw new InvalidOperationException(nameof(fallback)) + }; } + + InitializedValue = result; + InitializedAt = DateTime.UtcNow; + + return (true, result); + } finally { + InitSemaphore.Release(); + } + } + + [PublicAPI] + public async Task Reset() { + if (!IsInitialized) { + return; } - [PublicAPI] - public async Task Reset() { + await InitSemaphore.WaitAsync().ConfigureAwait(false); + + try { if (!IsInitialized) { return; } - await InitSemaphore.WaitAsync().ConfigureAwait(false); - - try { - if (!IsInitialized) { - return; - } - - InitializedAt = DateTime.MinValue; - InitializedValue = default(T?); - } finally { - InitSemaphore.Release(); - } - } - - public enum EFallback : byte { - DefaultForType, - FailedNow, - SuccessPreviously + InitializedAt = DateTime.MinValue; + InitializedValue = default(T?); + } finally { + InitSemaphore.Release(); } } + + public enum EFallback : byte { + DefaultForType, + FailedNow, + SuccessPreviously + } } diff --git a/ArchiSteamFarm/Helpers/ArchiCryptoHelper.cs b/ArchiSteamFarm/Helpers/ArchiCryptoHelper.cs index 3b6d7b932..9a96d96fe 100644 --- a/ArchiSteamFarm/Helpers/ArchiCryptoHelper.cs +++ b/ArchiSteamFarm/Helpers/ArchiCryptoHelper.cs @@ -32,267 +32,267 @@ using ArchiSteamFarm.Localization; using CryptSharp.Utility; using SteamKit2; -namespace ArchiSteamFarm.Helpers { - public static class ArchiCryptoHelper { - private const byte DefaultHashLength = 32; - private const byte MinimumRecommendedCryptKeyBytes = 32; - private const ushort SteamParentalPbkdf2Iterations = 10000; - private const byte SteamParentalSCryptBlocksCount = 8; - private const ushort SteamParentalSCryptIterations = 8192; +namespace ArchiSteamFarm.Helpers; - internal static bool HasDefaultCryptKey { get; private set; } = true; +public static class ArchiCryptoHelper { + private const byte DefaultHashLength = 32; + private const byte MinimumRecommendedCryptKeyBytes = 32; + private const ushort SteamParentalPbkdf2Iterations = 10000; + private const byte SteamParentalSCryptBlocksCount = 8; + private const ushort SteamParentalSCryptIterations = 8192; - private static readonly ImmutableHashSet ForbiddenCryptKeyPhrases = ImmutableHashSet.Create(StringComparer.InvariantCultureIgnoreCase, "crypt", "key", "cryptkey"); + internal static bool HasDefaultCryptKey { get; private set; } = true; - private static IEnumerable SteamParentalCharacters => Enumerable.Range('0', 10).Select(static character => (byte) character); + private static readonly ImmutableHashSet ForbiddenCryptKeyPhrases = ImmutableHashSet.Create(StringComparer.InvariantCultureIgnoreCase, "crypt", "key", "cryptkey"); - private static IEnumerable SteamParentalCodes { - get { - HashSet steamParentalCharacters = SteamParentalCharacters.ToHashSet(); + private static IEnumerable SteamParentalCharacters => Enumerable.Range('0', 10).Select(static character => (byte) character); - return from a in steamParentalCharacters from b in steamParentalCharacters from c in steamParentalCharacters from d in steamParentalCharacters select new[] { a, b, c, d }; - } - } + private static IEnumerable SteamParentalCodes { + get { + HashSet steamParentalCharacters = SteamParentalCharacters.ToHashSet(); - private static byte[] EncryptionKey = Encoding.UTF8.GetBytes(nameof(ArchiSteamFarm)); - - internal static string? Decrypt(ECryptoMethod cryptoMethod, string encryptedString) { - if (!Enum.IsDefined(typeof(ECryptoMethod), cryptoMethod)) { - throw new InvalidEnumArgumentException(nameof(cryptoMethod), (int) cryptoMethod, typeof(ECryptoMethod)); - } - - if (string.IsNullOrEmpty(encryptedString)) { - throw new ArgumentNullException(nameof(encryptedString)); - } - - return cryptoMethod switch { - ECryptoMethod.PlainText => encryptedString, - ECryptoMethod.AES => DecryptAES(encryptedString), - ECryptoMethod.ProtectedDataForCurrentUser => DecryptProtectedDataForCurrentUser(encryptedString), - _ => throw new ArgumentOutOfRangeException(nameof(cryptoMethod)) - }; - } - - internal static string? Encrypt(ECryptoMethod cryptoMethod, string decryptedString) { - if (!Enum.IsDefined(typeof(ECryptoMethod), cryptoMethod)) { - throw new InvalidEnumArgumentException(nameof(cryptoMethod), (int) cryptoMethod, typeof(ECryptoMethod)); - } - - if (string.IsNullOrEmpty(decryptedString)) { - throw new ArgumentNullException(nameof(decryptedString)); - } - - return cryptoMethod switch { - ECryptoMethod.PlainText => decryptedString, - ECryptoMethod.AES => EncryptAES(decryptedString), - ECryptoMethod.ProtectedDataForCurrentUser => EncryptProtectedDataForCurrentUser(decryptedString), - _ => throw new ArgumentOutOfRangeException(nameof(cryptoMethod)) - }; - } - - internal static string Hash(EHashingMethod hashingMethod, string stringToHash) { - if (!Enum.IsDefined(typeof(EHashingMethod), hashingMethod)) { - throw new InvalidEnumArgumentException(nameof(hashingMethod), (int) hashingMethod, typeof(EHashingMethod)); - } - - if (string.IsNullOrEmpty(stringToHash)) { - throw new ArgumentNullException(nameof(stringToHash)); - } - - if (hashingMethod == EHashingMethod.PlainText) { - return stringToHash; - } - - byte[] passwordBytes = Encoding.UTF8.GetBytes(stringToHash); - byte[] hashBytes = Hash(passwordBytes, EncryptionKey, DefaultHashLength, hashingMethod); - - return Convert.ToBase64String(hashBytes); - } - - internal static byte[] Hash(byte[] password, byte[] salt, byte hashLength, EHashingMethod hashingMethod) { - if ((password == null) || (password.Length == 0)) { - throw new ArgumentNullException(nameof(password)); - } - - if ((salt == null) || (salt.Length == 0)) { - throw new ArgumentNullException(nameof(salt)); - } - - if (hashLength == 0) { - throw new ArgumentOutOfRangeException(nameof(hashLength)); - } - - if (!Enum.IsDefined(typeof(EHashingMethod), hashingMethod)) { - throw new InvalidEnumArgumentException(nameof(hashingMethod), (int) hashingMethod, typeof(EHashingMethod)); - } - - switch (hashingMethod) { - case EHashingMethod.PlainText: - return password; - case EHashingMethod.SCrypt: - return SCrypt.ComputeDerivedKey(password, salt, SteamParentalSCryptIterations, SteamParentalSCryptBlocksCount, 1, null, hashLength); - case EHashingMethod.Pbkdf2: - using (HMACSHA256 hmacAlgorithm = new(password)) { - return Pbkdf2.ComputeDerivedKey(hmacAlgorithm, salt, SteamParentalPbkdf2Iterations, hashLength); - } - default: - throw new ArgumentOutOfRangeException(nameof(hashingMethod)); - } - } - - internal static string? RecoverSteamParentalCode(byte[] passwordHash, byte[] salt, EHashingMethod hashingMethod) { - if ((passwordHash == null) || (passwordHash.Length == 0)) { - throw new ArgumentNullException(nameof(passwordHash)); - } - - if ((salt == null) || (salt.Length == 0)) { - throw new ArgumentNullException(nameof(salt)); - } - - if (!Enum.IsDefined(typeof(EHashingMethod), hashingMethod)) { - throw new InvalidEnumArgumentException(nameof(hashingMethod), (int) hashingMethod, typeof(EHashingMethod)); - } - - byte[]? password = SteamParentalCodes.AsParallel().FirstOrDefault(passwordToTry => Hash(passwordToTry, salt, (byte) passwordHash.Length, hashingMethod).SequenceEqual(passwordHash)); - - return password != null ? Encoding.UTF8.GetString(password) : null; - } - - internal static void SetEncryptionKey(string key) { - if (string.IsNullOrEmpty(key)) { - throw new ArgumentNullException(nameof(key)); - } - - if (!HasDefaultCryptKey) { - ASF.ArchiLogger.LogGenericError(Strings.ErrorAborted); - - return; - } - - Utilities.InBackground( - () => { - (bool isWeak, string? reason) = Utilities.TestPasswordStrength(key, ForbiddenCryptKeyPhrases); - - if (isWeak) { - ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningWeakCryptKey, reason)); - } - } - ); - - byte[] encryptionKey = Encoding.UTF8.GetBytes(key); - - if (encryptionKey.Length < MinimumRecommendedCryptKeyBytes) { - ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningTooShortCryptKey, MinimumRecommendedCryptKeyBytes)); - } - - HasDefaultCryptKey = encryptionKey.SequenceEqual(EncryptionKey); - EncryptionKey = encryptionKey; - } - - private static string? DecryptAES(string encryptedString) { - if (string.IsNullOrEmpty(encryptedString)) { - throw new ArgumentNullException(nameof(encryptedString)); - } - - try { - byte[] key; - - using (SHA256 sha256 = SHA256.Create()) { - key = sha256.ComputeHash(EncryptionKey); - } - - byte[] decryptedData = Convert.FromBase64String(encryptedString); - decryptedData = CryptoHelper.SymmetricDecrypt(decryptedData, key); - - return Encoding.UTF8.GetString(decryptedData); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return null; - } - } - - private static string? DecryptProtectedDataForCurrentUser(string encryptedString) { - if (string.IsNullOrEmpty(encryptedString)) { - throw new ArgumentNullException(nameof(encryptedString)); - } - - if (!OperatingSystem.IsWindows()) { - return null; - } - - try { - byte[] decryptedData = ProtectedData.Unprotect( - Convert.FromBase64String(encryptedString), - EncryptionKey, - DataProtectionScope.CurrentUser - ); - - return Encoding.UTF8.GetString(decryptedData); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return null; - } - } - - private static string? EncryptAES(string decryptedString) { - if (string.IsNullOrEmpty(decryptedString)) { - throw new ArgumentNullException(nameof(decryptedString)); - } - - try { - byte[] key; - - using (SHA256 sha256 = SHA256.Create()) { - key = sha256.ComputeHash(EncryptionKey); - } - - byte[] encryptedData = Encoding.UTF8.GetBytes(decryptedString); - encryptedData = CryptoHelper.SymmetricEncrypt(encryptedData, key); - - return Convert.ToBase64String(encryptedData); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return null; - } - } - - private static string? EncryptProtectedDataForCurrentUser(string decryptedString) { - if (string.IsNullOrEmpty(decryptedString)) { - throw new ArgumentNullException(nameof(decryptedString)); - } - - if (!OperatingSystem.IsWindows()) { - return null; - } - - try { - byte[] encryptedData = ProtectedData.Protect( - Encoding.UTF8.GetBytes(decryptedString), - EncryptionKey, - DataProtectionScope.CurrentUser - ); - - return Convert.ToBase64String(encryptedData); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return null; - } - } - - public enum ECryptoMethod : byte { - PlainText, - AES, - ProtectedDataForCurrentUser - } - - public enum EHashingMethod : byte { - PlainText, - SCrypt, - Pbkdf2 + return from a in steamParentalCharacters from b in steamParentalCharacters from c in steamParentalCharacters from d in steamParentalCharacters select new[] { a, b, c, d }; } } + + private static byte[] EncryptionKey = Encoding.UTF8.GetBytes(nameof(ArchiSteamFarm)); + + internal static string? Decrypt(ECryptoMethod cryptoMethod, string encryptedString) { + if (!Enum.IsDefined(typeof(ECryptoMethod), cryptoMethod)) { + throw new InvalidEnumArgumentException(nameof(cryptoMethod), (int) cryptoMethod, typeof(ECryptoMethod)); + } + + if (string.IsNullOrEmpty(encryptedString)) { + throw new ArgumentNullException(nameof(encryptedString)); + } + + return cryptoMethod switch { + ECryptoMethod.PlainText => encryptedString, + ECryptoMethod.AES => DecryptAES(encryptedString), + ECryptoMethod.ProtectedDataForCurrentUser => DecryptProtectedDataForCurrentUser(encryptedString), + _ => throw new ArgumentOutOfRangeException(nameof(cryptoMethod)) + }; + } + + internal static string? Encrypt(ECryptoMethod cryptoMethod, string decryptedString) { + if (!Enum.IsDefined(typeof(ECryptoMethod), cryptoMethod)) { + throw new InvalidEnumArgumentException(nameof(cryptoMethod), (int) cryptoMethod, typeof(ECryptoMethod)); + } + + if (string.IsNullOrEmpty(decryptedString)) { + throw new ArgumentNullException(nameof(decryptedString)); + } + + return cryptoMethod switch { + ECryptoMethod.PlainText => decryptedString, + ECryptoMethod.AES => EncryptAES(decryptedString), + ECryptoMethod.ProtectedDataForCurrentUser => EncryptProtectedDataForCurrentUser(decryptedString), + _ => throw new ArgumentOutOfRangeException(nameof(cryptoMethod)) + }; + } + + internal static string Hash(EHashingMethod hashingMethod, string stringToHash) { + if (!Enum.IsDefined(typeof(EHashingMethod), hashingMethod)) { + throw new InvalidEnumArgumentException(nameof(hashingMethod), (int) hashingMethod, typeof(EHashingMethod)); + } + + if (string.IsNullOrEmpty(stringToHash)) { + throw new ArgumentNullException(nameof(stringToHash)); + } + + if (hashingMethod == EHashingMethod.PlainText) { + return stringToHash; + } + + byte[] passwordBytes = Encoding.UTF8.GetBytes(stringToHash); + byte[] hashBytes = Hash(passwordBytes, EncryptionKey, DefaultHashLength, hashingMethod); + + return Convert.ToBase64String(hashBytes); + } + + internal static byte[] Hash(byte[] password, byte[] salt, byte hashLength, EHashingMethod hashingMethod) { + if ((password == null) || (password.Length == 0)) { + throw new ArgumentNullException(nameof(password)); + } + + if ((salt == null) || (salt.Length == 0)) { + throw new ArgumentNullException(nameof(salt)); + } + + if (hashLength == 0) { + throw new ArgumentOutOfRangeException(nameof(hashLength)); + } + + if (!Enum.IsDefined(typeof(EHashingMethod), hashingMethod)) { + throw new InvalidEnumArgumentException(nameof(hashingMethod), (int) hashingMethod, typeof(EHashingMethod)); + } + + switch (hashingMethod) { + case EHashingMethod.PlainText: + return password; + case EHashingMethod.SCrypt: + return SCrypt.ComputeDerivedKey(password, salt, SteamParentalSCryptIterations, SteamParentalSCryptBlocksCount, 1, null, hashLength); + case EHashingMethod.Pbkdf2: + using (HMACSHA256 hmacAlgorithm = new(password)) { + return Pbkdf2.ComputeDerivedKey(hmacAlgorithm, salt, SteamParentalPbkdf2Iterations, hashLength); + } + default: + throw new ArgumentOutOfRangeException(nameof(hashingMethod)); + } + } + + internal static string? RecoverSteamParentalCode(byte[] passwordHash, byte[] salt, EHashingMethod hashingMethod) { + if ((passwordHash == null) || (passwordHash.Length == 0)) { + throw new ArgumentNullException(nameof(passwordHash)); + } + + if ((salt == null) || (salt.Length == 0)) { + throw new ArgumentNullException(nameof(salt)); + } + + if (!Enum.IsDefined(typeof(EHashingMethod), hashingMethod)) { + throw new InvalidEnumArgumentException(nameof(hashingMethod), (int) hashingMethod, typeof(EHashingMethod)); + } + + byte[]? password = SteamParentalCodes.AsParallel().FirstOrDefault(passwordToTry => Hash(passwordToTry, salt, (byte) passwordHash.Length, hashingMethod).SequenceEqual(passwordHash)); + + return password != null ? Encoding.UTF8.GetString(password) : null; + } + + internal static void SetEncryptionKey(string key) { + if (string.IsNullOrEmpty(key)) { + throw new ArgumentNullException(nameof(key)); + } + + if (!HasDefaultCryptKey) { + ASF.ArchiLogger.LogGenericError(Strings.ErrorAborted); + + return; + } + + Utilities.InBackground( + () => { + (bool isWeak, string? reason) = Utilities.TestPasswordStrength(key, ForbiddenCryptKeyPhrases); + + if (isWeak) { + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningWeakCryptKey, reason)); + } + } + ); + + byte[] encryptionKey = Encoding.UTF8.GetBytes(key); + + if (encryptionKey.Length < MinimumRecommendedCryptKeyBytes) { + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningTooShortCryptKey, MinimumRecommendedCryptKeyBytes)); + } + + HasDefaultCryptKey = encryptionKey.SequenceEqual(EncryptionKey); + EncryptionKey = encryptionKey; + } + + private static string? DecryptAES(string encryptedString) { + if (string.IsNullOrEmpty(encryptedString)) { + throw new ArgumentNullException(nameof(encryptedString)); + } + + try { + byte[] key; + + using (SHA256 sha256 = SHA256.Create()) { + key = sha256.ComputeHash(EncryptionKey); + } + + byte[] decryptedData = Convert.FromBase64String(encryptedString); + decryptedData = CryptoHelper.SymmetricDecrypt(decryptedData, key); + + return Encoding.UTF8.GetString(decryptedData); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return null; + } + } + + private static string? DecryptProtectedDataForCurrentUser(string encryptedString) { + if (string.IsNullOrEmpty(encryptedString)) { + throw new ArgumentNullException(nameof(encryptedString)); + } + + if (!OperatingSystem.IsWindows()) { + return null; + } + + try { + byte[] decryptedData = ProtectedData.Unprotect( + Convert.FromBase64String(encryptedString), + EncryptionKey, + DataProtectionScope.CurrentUser + ); + + return Encoding.UTF8.GetString(decryptedData); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return null; + } + } + + private static string? EncryptAES(string decryptedString) { + if (string.IsNullOrEmpty(decryptedString)) { + throw new ArgumentNullException(nameof(decryptedString)); + } + + try { + byte[] key; + + using (SHA256 sha256 = SHA256.Create()) { + key = sha256.ComputeHash(EncryptionKey); + } + + byte[] encryptedData = Encoding.UTF8.GetBytes(decryptedString); + encryptedData = CryptoHelper.SymmetricEncrypt(encryptedData, key); + + return Convert.ToBase64String(encryptedData); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return null; + } + } + + private static string? EncryptProtectedDataForCurrentUser(string decryptedString) { + if (string.IsNullOrEmpty(decryptedString)) { + throw new ArgumentNullException(nameof(decryptedString)); + } + + if (!OperatingSystem.IsWindows()) { + return null; + } + + try { + byte[] encryptedData = ProtectedData.Protect( + Encoding.UTF8.GetBytes(decryptedString), + EncryptionKey, + DataProtectionScope.CurrentUser + ); + + return Convert.ToBase64String(encryptedData); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return null; + } + } + + public enum ECryptoMethod : byte { + PlainText, + AES, + ProtectedDataForCurrentUser + } + + public enum EHashingMethod : byte { + PlainText, + SCrypt, + Pbkdf2 + } } diff --git a/ArchiSteamFarm/Helpers/CrossProcessFileBasedSemaphore.cs b/ArchiSteamFarm/Helpers/CrossProcessFileBasedSemaphore.cs index f6788be04..028b57aff 100644 --- a/ArchiSteamFarm/Helpers/CrossProcessFileBasedSemaphore.cs +++ b/ArchiSteamFarm/Helpers/CrossProcessFileBasedSemaphore.cs @@ -27,180 +27,180 @@ using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm.Core; -namespace ArchiSteamFarm.Helpers { - internal sealed class CrossProcessFileBasedSemaphore : ICrossProcessSemaphore, IDisposable { - private const ushort SpinLockDelay = 1000; // In milliseconds +namespace ArchiSteamFarm.Helpers; - private readonly string FilePath; - private readonly SemaphoreSlim LocalSemaphore = new(1, 1); +internal sealed class CrossProcessFileBasedSemaphore : ICrossProcessSemaphore, IDisposable { + private const ushort SpinLockDelay = 1000; // In milliseconds - private FileStream? FileLock; + private readonly string FilePath; + private readonly SemaphoreSlim LocalSemaphore = new(1, 1); - internal CrossProcessFileBasedSemaphore(string name) { - if (string.IsNullOrEmpty(name)) { - throw new ArgumentNullException(nameof(name)); - } + private FileStream? FileLock; - FilePath = Path.Combine(Path.GetTempPath(), SharedInfo.ASF, name); - - EnsureFileExists(); + internal CrossProcessFileBasedSemaphore(string name) { + if (string.IsNullOrEmpty(name)) { + throw new ArgumentNullException(nameof(name)); } - public void Dispose() { - LocalSemaphore.Dispose(); + FilePath = Path.Combine(Path.GetTempPath(), SharedInfo.ASF, name); - FileLock?.Dispose(); - } + EnsureFileExists(); + } - void ICrossProcessSemaphore.Release() { - // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that - lock (LocalSemaphore) { - if (FileLock == null) { - throw new InvalidOperationException(nameof(FileLock)); - } + public void Dispose() { + LocalSemaphore.Dispose(); - FileLock.Dispose(); - FileLock = null; + FileLock?.Dispose(); + } + + void ICrossProcessSemaphore.Release() { + // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that + lock (LocalSemaphore) { + if (FileLock == null) { + throw new InvalidOperationException(nameof(FileLock)); } - LocalSemaphore.Release(); + FileLock.Dispose(); + FileLock = null; } - async Task ICrossProcessSemaphore.WaitAsync() { - await LocalSemaphore.WaitAsync().ConfigureAwait(false); + LocalSemaphore.Release(); + } - bool success = false; + async Task ICrossProcessSemaphore.WaitAsync() { + await LocalSemaphore.WaitAsync().ConfigureAwait(false); - try { - while (true) { - try { - // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that - lock (LocalSemaphore) { - if (FileLock != null) { - throw new InvalidOperationException(nameof(FileLock)); - } + bool success = false; - EnsureFileExists(); - - FileLock = new FileStream(FilePath, FileMode.OpenOrCreate, FileAccess.Read, FileShare.None); - success = true; - - return; - } - } catch (IOException) { - await Task.Delay(SpinLockDelay).ConfigureAwait(false); - } - } - } finally { - if (!success) { - LocalSemaphore.Release(); - } - } - } - - async Task ICrossProcessSemaphore.WaitAsync(int millisecondsTimeout) { - Stopwatch stopwatch = Stopwatch.StartNew(); - - if (!await LocalSemaphore.WaitAsync(millisecondsTimeout).ConfigureAwait(false)) { - stopwatch.Stop(); - - return false; - } - - bool success = false; - - try { - stopwatch.Stop(); - - millisecondsTimeout -= (int) stopwatch.ElapsedMilliseconds; - - if (millisecondsTimeout <= 0) { - return false; - } - - while (true) { - try { - // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that - lock (LocalSemaphore) { - if (FileLock != null) { - throw new InvalidOperationException(nameof(FileLock)); - } - - EnsureFileExists(); - - FileLock = new FileStream(FilePath, FileMode.OpenOrCreate, FileAccess.Read, FileShare.None); - success = true; - - return true; - } - } catch (IOException) { - if (millisecondsTimeout <= SpinLockDelay) { - return false; + try { + while (true) { + try { + // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that + lock (LocalSemaphore) { + if (FileLock != null) { + throw new InvalidOperationException(nameof(FileLock)); } - await Task.Delay(SpinLockDelay).ConfigureAwait(false); - millisecondsTimeout -= SpinLockDelay; + EnsureFileExists(); + + FileLock = new FileStream(FilePath, FileMode.OpenOrCreate, FileAccess.Read, FileShare.None); + success = true; + + return; } - } - } finally { - if (!success) { - LocalSemaphore.Release(); + } catch (IOException) { + await Task.Delay(SpinLockDelay).ConfigureAwait(false); } } - } - - private void EnsureFileExists() { - if (File.Exists(FilePath)) { - return; - } - - string? directoryPath = Path.GetDirectoryName(FilePath); - - if (string.IsNullOrEmpty(directoryPath)) { - ASF.ArchiLogger.LogNullError(nameof(directoryPath)); - - return; - } - - if (!Directory.Exists(directoryPath)) { - Directory.CreateDirectory(directoryPath); - - if (OperatingSystem.IsWindows()) { - DirectoryInfo directoryInfo = new(directoryPath); - - try { - DirectorySecurity directorySecurity = new(directoryPath, AccessControlSections.All); - - directoryInfo.SetAccessControl(directorySecurity); - } catch (PrivilegeNotHeldException e) { - // Non-critical, user might have no rights to manage the resource - ASF.ArchiLogger.LogGenericDebuggingException(e); - } - } else if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { - OS.UnixSetFileAccess(directoryPath!, OS.EUnixPermission.Combined777); - } - } - - try { - new FileStream(FilePath, FileMode.CreateNew).Dispose(); - - if (OperatingSystem.IsWindows()) { - FileInfo fileInfo = new(FilePath); - - try { - FileSecurity fileSecurity = new(FilePath, AccessControlSections.All); - - fileInfo.SetAccessControl(fileSecurity); - } catch (PrivilegeNotHeldException e) { - // Non-critical, user might have no rights to manage the resource - ASF.ArchiLogger.LogGenericDebuggingException(e); - } - } else if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { - OS.UnixSetFileAccess(FilePath, OS.EUnixPermission.Combined777); - } - } catch (IOException) { - // Ignored, if the file was already created in the meantime by another instance, this is fine + } finally { + if (!success) { + LocalSemaphore.Release(); } } } + + async Task ICrossProcessSemaphore.WaitAsync(int millisecondsTimeout) { + Stopwatch stopwatch = Stopwatch.StartNew(); + + if (!await LocalSemaphore.WaitAsync(millisecondsTimeout).ConfigureAwait(false)) { + stopwatch.Stop(); + + return false; + } + + bool success = false; + + try { + stopwatch.Stop(); + + millisecondsTimeout -= (int) stopwatch.ElapsedMilliseconds; + + if (millisecondsTimeout <= 0) { + return false; + } + + while (true) { + try { + // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that + lock (LocalSemaphore) { + if (FileLock != null) { + throw new InvalidOperationException(nameof(FileLock)); + } + + EnsureFileExists(); + + FileLock = new FileStream(FilePath, FileMode.OpenOrCreate, FileAccess.Read, FileShare.None); + success = true; + + return true; + } + } catch (IOException) { + if (millisecondsTimeout <= SpinLockDelay) { + return false; + } + + await Task.Delay(SpinLockDelay).ConfigureAwait(false); + millisecondsTimeout -= SpinLockDelay; + } + } + } finally { + if (!success) { + LocalSemaphore.Release(); + } + } + } + + private void EnsureFileExists() { + if (File.Exists(FilePath)) { + return; + } + + string? directoryPath = Path.GetDirectoryName(FilePath); + + if (string.IsNullOrEmpty(directoryPath)) { + ASF.ArchiLogger.LogNullError(nameof(directoryPath)); + + return; + } + + if (!Directory.Exists(directoryPath)) { + Directory.CreateDirectory(directoryPath); + + if (OperatingSystem.IsWindows()) { + DirectoryInfo directoryInfo = new(directoryPath); + + try { + DirectorySecurity directorySecurity = new(directoryPath, AccessControlSections.All); + + directoryInfo.SetAccessControl(directorySecurity); + } catch (PrivilegeNotHeldException e) { + // Non-critical, user might have no rights to manage the resource + ASF.ArchiLogger.LogGenericDebuggingException(e); + } + } else if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { + OS.UnixSetFileAccess(directoryPath!, OS.EUnixPermission.Combined777); + } + } + + try { + new FileStream(FilePath, FileMode.CreateNew).Dispose(); + + if (OperatingSystem.IsWindows()) { + FileInfo fileInfo = new(FilePath); + + try { + FileSecurity fileSecurity = new(FilePath, AccessControlSections.All); + + fileInfo.SetAccessControl(fileSecurity); + } catch (PrivilegeNotHeldException e) { + // Non-critical, user might have no rights to manage the resource + ASF.ArchiLogger.LogGenericDebuggingException(e); + } + } else if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { + OS.UnixSetFileAccess(FilePath, OS.EUnixPermission.Combined777); + } + } catch (IOException) { + // Ignored, if the file was already created in the meantime by another instance, this is fine + } + } } diff --git a/ArchiSteamFarm/Helpers/ICrossProcessSemaphore.cs b/ArchiSteamFarm/Helpers/ICrossProcessSemaphore.cs index fdffe4912..f84e177a6 100644 --- a/ArchiSteamFarm/Helpers/ICrossProcessSemaphore.cs +++ b/ArchiSteamFarm/Helpers/ICrossProcessSemaphore.cs @@ -22,11 +22,11 @@ using System.Threading.Tasks; using JetBrains.Annotations; -namespace ArchiSteamFarm.Helpers { - [PublicAPI] - public interface ICrossProcessSemaphore { - void Release(); - Task WaitAsync(); - Task WaitAsync(int millisecondsTimeout); - } +namespace ArchiSteamFarm.Helpers; + +[PublicAPI] +public interface ICrossProcessSemaphore { + void Release(); + Task WaitAsync(); + Task WaitAsync(int millisecondsTimeout); } diff --git a/ArchiSteamFarm/Helpers/SemaphoreLock.cs b/ArchiSteamFarm/Helpers/SemaphoreLock.cs index 023a3d232..8cc734798 100644 --- a/ArchiSteamFarm/Helpers/SemaphoreLock.cs +++ b/ArchiSteamFarm/Helpers/SemaphoreLock.cs @@ -22,12 +22,12 @@ using System; using System.Threading; -namespace ArchiSteamFarm.Helpers { - internal sealed class SemaphoreLock : IDisposable { - private readonly SemaphoreSlim Semaphore; +namespace ArchiSteamFarm.Helpers; - internal SemaphoreLock(SemaphoreSlim semaphore) => Semaphore = semaphore ?? throw new ArgumentNullException(nameof(semaphore)); +internal sealed class SemaphoreLock : IDisposable { + private readonly SemaphoreSlim Semaphore; - public void Dispose() => Semaphore.Release(); - } + internal SemaphoreLock(SemaphoreSlim semaphore) => Semaphore = semaphore ?? throw new ArgumentNullException(nameof(semaphore)); + + public void Dispose() => Semaphore.Release(); } diff --git a/ArchiSteamFarm/Helpers/SerializableFile.cs b/ArchiSteamFarm/Helpers/SerializableFile.cs index 84f516096..3dbefcc83 100644 --- a/ArchiSteamFarm/Helpers/SerializableFile.cs +++ b/ArchiSteamFarm/Helpers/SerializableFile.cs @@ -26,146 +26,146 @@ using System.Threading.Tasks; using ArchiSteamFarm.Core; using Newtonsoft.Json; -namespace ArchiSteamFarm.Helpers { - public abstract class SerializableFile : IDisposable { - private static readonly SemaphoreSlim GlobalFileSemaphore = new(1, 1); +namespace ArchiSteamFarm.Helpers; - private readonly SemaphoreSlim FileSemaphore = new(1, 1); +public abstract class SerializableFile : IDisposable { + private static readonly SemaphoreSlim GlobalFileSemaphore = new(1, 1); - protected string? FilePath { get; set; } + private readonly SemaphoreSlim FileSemaphore = new(1, 1); - private bool ReadOnly; - private bool SavingScheduled; + protected string? FilePath { get; set; } - public void Dispose() { - Dispose(true); - GC.SuppressFinalize(this); + private bool ReadOnly; + private bool SavingScheduled; + + public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) { + if (disposing) { + FileSemaphore.Dispose(); + } + } + + protected async Task Save() { + if (string.IsNullOrEmpty(FilePath)) { + throw new InvalidOperationException(nameof(FilePath)); } - protected virtual void Dispose(bool disposing) { - if (disposing) { - FileSemaphore.Dispose(); - } + if (ReadOnly) { + return; } - protected async Task Save() { - if (string.IsNullOrEmpty(FilePath)) { - throw new InvalidOperationException(nameof(FilePath)); - } - - if (ReadOnly) { + // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that + lock (FileSemaphore) { + if (SavingScheduled) { return; } + SavingScheduled = true; + } + + await FileSemaphore.WaitAsync().ConfigureAwait(false); + + try { // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that lock (FileSemaphore) { - if (SavingScheduled) { - return; - } - - SavingScheduled = true; + SavingScheduled = false; } - await FileSemaphore.WaitAsync().ConfigureAwait(false); - - try { - // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that - lock (FileSemaphore) { - SavingScheduled = false; - } - - if (ReadOnly) { - return; - } - - string json = JsonConvert.SerializeObject(this, Debugging.IsUserDebugging ? Formatting.Indented : Formatting.None); - - if (string.IsNullOrEmpty(json)) { - throw new InvalidOperationException(nameof(json)); - } - - // We always want to write entire content to temporary file first, in order to never load corrupted data, also when target file doesn't exist - string newFilePath = $"{FilePath}.new"; - - if (File.Exists(FilePath)) { - string currentJson = await File.ReadAllTextAsync(FilePath!).ConfigureAwait(false); - - if (json == currentJson) { - return; - } - - await File.WriteAllTextAsync(newFilePath, json).ConfigureAwait(false); - - File.Replace(newFilePath, FilePath!, null); - } else { - await File.WriteAllTextAsync(newFilePath, json).ConfigureAwait(false); - - File.Move(newFilePath, FilePath!); - } - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } finally { - FileSemaphore.Release(); - } - } - - internal async Task MakeReadOnly() { if (ReadOnly) { return; } - await FileSemaphore.WaitAsync().ConfigureAwait(false); + string json = JsonConvert.SerializeObject(this, Debugging.IsUserDebugging ? Formatting.Indented : Formatting.None); - try { - if (ReadOnly) { + if (string.IsNullOrEmpty(json)) { + throw new InvalidOperationException(nameof(json)); + } + + // We always want to write entire content to temporary file first, in order to never load corrupted data, also when target file doesn't exist + string newFilePath = $"{FilePath}.new"; + + if (File.Exists(FilePath)) { + string currentJson = await File.ReadAllTextAsync(FilePath!).ConfigureAwait(false); + + if (json == currentJson) { return; } - ReadOnly = true; - } finally { - FileSemaphore.Release(); + await File.WriteAllTextAsync(newFilePath, json).ConfigureAwait(false); + + File.Replace(newFilePath, FilePath!, null); + } else { + await File.WriteAllTextAsync(newFilePath, json).ConfigureAwait(false); + + File.Move(newFilePath, FilePath!); } + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } finally { + FileSemaphore.Release(); + } + } + + internal async Task MakeReadOnly() { + if (ReadOnly) { + return; } - internal static async Task Write(string filePath, string json) { - if (string.IsNullOrEmpty(filePath)) { - throw new ArgumentNullException(nameof(filePath)); + await FileSemaphore.WaitAsync().ConfigureAwait(false); + + try { + if (ReadOnly) { + return; } - if (string.IsNullOrEmpty(json)) { - throw new ArgumentNullException(nameof(json)); - } + ReadOnly = true; + } finally { + FileSemaphore.Release(); + } + } - string newFilePath = $"{filePath}.new"; + internal static async Task Write(string filePath, string json) { + if (string.IsNullOrEmpty(filePath)) { + throw new ArgumentNullException(nameof(filePath)); + } - await GlobalFileSemaphore.WaitAsync().ConfigureAwait(false); + if (string.IsNullOrEmpty(json)) { + throw new ArgumentNullException(nameof(json)); + } - try { - // We always want to write entire content to temporary file first, in order to never load corrupted data, also when target file doesn't exist - if (File.Exists(filePath)) { - string currentJson = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); + string newFilePath = $"{filePath}.new"; - if (json == currentJson) { - return true; - } + await GlobalFileSemaphore.WaitAsync().ConfigureAwait(false); - await File.WriteAllTextAsync(newFilePath, json).ConfigureAwait(false); + try { + // We always want to write entire content to temporary file first, in order to never load corrupted data, also when target file doesn't exist + if (File.Exists(filePath)) { + string currentJson = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); - File.Replace(newFilePath, filePath, null); - } else { - await File.WriteAllTextAsync(newFilePath, json).ConfigureAwait(false); - - File.Move(newFilePath, filePath); + if (json == currentJson) { + return true; } - return true; - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); + await File.WriteAllTextAsync(newFilePath, json).ConfigureAwait(false); - return false; - } finally { - GlobalFileSemaphore.Release(); + File.Replace(newFilePath, filePath, null); + } else { + await File.WriteAllTextAsync(newFilePath, json).ConfigureAwait(false); + + File.Move(newFilePath, filePath); } + + return true; + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return false; + } finally { + GlobalFileSemaphore.Release(); } } } diff --git a/ArchiSteamFarm/IPC/ArchiKestrel.cs b/ArchiSteamFarm/IPC/ArchiKestrel.cs index ee8cdda19..1aa3ade27 100644 --- a/ArchiSteamFarm/IPC/ArchiKestrel.cs +++ b/ArchiSteamFarm/IPC/ArchiKestrel.cs @@ -39,126 +39,126 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NLog.Web; -namespace ArchiSteamFarm.IPC { - internal static class ArchiKestrel { - internal static HistoryTarget? HistoryTarget { get; private set; } +namespace ArchiSteamFarm.IPC; - private static IHost? KestrelWebHost; +internal static class ArchiKestrel { + internal static HistoryTarget? HistoryTarget { get; private set; } - internal static void OnNewHistoryTarget(HistoryTarget? historyTarget = null) { - if (HistoryTarget != null) { - HistoryTarget.NewHistoryEntry -= NLogController.OnNewHistoryEntry; - HistoryTarget = null; - } + private static IHost? KestrelWebHost; - if (historyTarget != null) { - historyTarget.NewHistoryEntry += NLogController.OnNewHistoryEntry; - HistoryTarget = historyTarget; - } + internal static void OnNewHistoryTarget(HistoryTarget? historyTarget = null) { + if (HistoryTarget != null) { + HistoryTarget.NewHistoryEntry -= NLogController.OnNewHistoryEntry; + HistoryTarget = null; } - internal static async Task Start() { - if (KestrelWebHost != null) { - return; - } - - ASF.ArchiLogger.LogGenericInfo(Strings.IPCStarting); - - // The order of dependency injection matters, pay attention to it - HostBuilder builder = new(); - - string customDirectory = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.WebsiteDirectory); - string websiteDirectory = Directory.Exists(customDirectory) ? customDirectory : Path.Combine(AppContext.BaseDirectory, SharedInfo.WebsiteDirectory); - - // Set default content root - builder.UseContentRoot(SharedInfo.HomeDirectory); - - // Firstly initialize settings that user is free to override - builder.ConfigureLogging( - static logging => { - logging.ClearProviders(); - logging.SetMinimumLevel(Debugging.IsUserDebugging ? LogLevel.Trace : LogLevel.Warning); - } - ); - - // Check if custom config is available - string absoluteConfigDirectory = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.ConfigDirectory); - string customConfigPath = Path.Combine(absoluteConfigDirectory, SharedInfo.IPCConfigFile); - - bool customConfigExists = File.Exists(customConfigPath); - - if (customConfigExists) { - if (Debugging.IsDebugConfigured) { - try { - string json = await File.ReadAllTextAsync(customConfigPath).ConfigureAwait(false); - - if (!string.IsNullOrEmpty(json)) { - JObject jObject = JObject.Parse(json); - - ASF.ArchiLogger.LogGenericDebug($"{SharedInfo.IPCConfigFile}: {jObject.ToString(Formatting.Indented)}"); - } - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - // Use custom config for logging configuration - builder.ConfigureLogging(static (hostingContext, logging) => logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging"))); - } - - // Enable NLog integration for logging - builder.UseNLog(); - - builder.ConfigureWebHostDefaults( - webBuilder => { - // Set default web root - webBuilder.UseWebRoot(websiteDirectory); - - // Now conditionally initialize settings that are not possible to override - if (customConfigExists) { - // Set up custom config to be used - webBuilder.UseConfiguration(new ConfigurationBuilder().SetBasePath(absoluteConfigDirectory).AddJsonFile(SharedInfo.IPCConfigFile, false, Program.ConfigWatch).Build()); - - // Use custom config for Kestrel configuration - webBuilder.UseKestrel(static (builderContext, options) => options.Configure(builderContext.Configuration.GetSection("Kestrel"))); - } else { - // Use ASF defaults for Kestrel - webBuilder.UseKestrel(static options => options.ListenLocalhost(1242)); - } - - // Specify Startup class for IPC - webBuilder.UseStartup(); - } - ); - - // Init history logger for /Api/Log usage - Logging.InitHistoryLogger(); - - // Start the server - IHost? kestrelWebHost = null; - - try { - kestrelWebHost = builder.Build(); - await kestrelWebHost.StartAsync().ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - kestrelWebHost?.Dispose(); - - return; - } - - KestrelWebHost = kestrelWebHost; - ASF.ArchiLogger.LogGenericInfo(Strings.IPCReady); - } - - internal static async Task Stop() { - if (KestrelWebHost == null) { - return; - } - - await KestrelWebHost.StopAsync().ConfigureAwait(false); - KestrelWebHost.Dispose(); - KestrelWebHost = null; + if (historyTarget != null) { + historyTarget.NewHistoryEntry += NLogController.OnNewHistoryEntry; + HistoryTarget = historyTarget; } } + + internal static async Task Start() { + if (KestrelWebHost != null) { + return; + } + + ASF.ArchiLogger.LogGenericInfo(Strings.IPCStarting); + + // The order of dependency injection matters, pay attention to it + HostBuilder builder = new(); + + string customDirectory = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.WebsiteDirectory); + string websiteDirectory = Directory.Exists(customDirectory) ? customDirectory : Path.Combine(AppContext.BaseDirectory, SharedInfo.WebsiteDirectory); + + // Set default content root + builder.UseContentRoot(SharedInfo.HomeDirectory); + + // Firstly initialize settings that user is free to override + builder.ConfigureLogging( + static logging => { + logging.ClearProviders(); + logging.SetMinimumLevel(Debugging.IsUserDebugging ? LogLevel.Trace : LogLevel.Warning); + } + ); + + // Check if custom config is available + string absoluteConfigDirectory = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.ConfigDirectory); + string customConfigPath = Path.Combine(absoluteConfigDirectory, SharedInfo.IPCConfigFile); + + bool customConfigExists = File.Exists(customConfigPath); + + if (customConfigExists) { + if (Debugging.IsDebugConfigured) { + try { + string json = await File.ReadAllTextAsync(customConfigPath).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(json)) { + JObject jObject = JObject.Parse(json); + + ASF.ArchiLogger.LogGenericDebug($"{SharedInfo.IPCConfigFile}: {jObject.ToString(Formatting.Indented)}"); + } + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + // Use custom config for logging configuration + builder.ConfigureLogging(static (hostingContext, logging) => logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging"))); + } + + // Enable NLog integration for logging + builder.UseNLog(); + + builder.ConfigureWebHostDefaults( + webBuilder => { + // Set default web root + webBuilder.UseWebRoot(websiteDirectory); + + // Now conditionally initialize settings that are not possible to override + if (customConfigExists) { + // Set up custom config to be used + webBuilder.UseConfiguration(new ConfigurationBuilder().SetBasePath(absoluteConfigDirectory).AddJsonFile(SharedInfo.IPCConfigFile, false, Program.ConfigWatch).Build()); + + // Use custom config for Kestrel configuration + webBuilder.UseKestrel(static (builderContext, options) => options.Configure(builderContext.Configuration.GetSection("Kestrel"))); + } else { + // Use ASF defaults for Kestrel + webBuilder.UseKestrel(static options => options.ListenLocalhost(1242)); + } + + // Specify Startup class for IPC + webBuilder.UseStartup(); + } + ); + + // Init history logger for /Api/Log usage + Logging.InitHistoryLogger(); + + // Start the server + IHost? kestrelWebHost = null; + + try { + kestrelWebHost = builder.Build(); + await kestrelWebHost.StartAsync().ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + kestrelWebHost?.Dispose(); + + return; + } + + KestrelWebHost = kestrelWebHost; + ASF.ArchiLogger.LogGenericInfo(Strings.IPCReady); + } + + internal static async Task Stop() { + if (KestrelWebHost == null) { + return; + } + + await KestrelWebHost.StopAsync().ConfigureAwait(false); + KestrelWebHost.Dispose(); + KestrelWebHost = null; + } } diff --git a/ArchiSteamFarm/IPC/Controllers/Api/ASFController.cs b/ArchiSteamFarm/IPC/Controllers/Api/ASFController.cs index c68cab74e..7e5d01f28 100644 --- a/ArchiSteamFarm/IPC/Controllers/Api/ASFController.cs +++ b/ArchiSteamFarm/IPC/Controllers/Api/ASFController.cs @@ -34,158 +34,158 @@ using ArchiSteamFarm.Storage; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; -namespace ArchiSteamFarm.IPC.Controllers.Api { - [Route("Api/ASF")] - public sealed class ASFController : ArchiController { - /// - /// Encrypts data with ASF encryption mechanisms using provided details. - /// - [Consumes("application/json")] - [HttpPost("Encrypt")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public ActionResult ASFEncryptPost([FromBody] ASFEncryptRequest request) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } +namespace ArchiSteamFarm.IPC.Controllers.Api; - if (string.IsNullOrEmpty(request.StringToEncrypt)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(request.StringToEncrypt)))); - } - - string? encryptedString = Actions.Encrypt(request.CryptoMethod, request.StringToEncrypt); - - return Ok(new GenericResponse(encryptedString)); +[Route("Api/ASF")] +public sealed class ASFController : ArchiController { + /// + /// Encrypts data with ASF encryption mechanisms using provided details. + /// + [Consumes("application/json")] + [HttpPost("Encrypt")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public ActionResult ASFEncryptPost([FromBody] ASFEncryptRequest request) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); } - /// - /// Fetches common info related to ASF as a whole. - /// - [HttpGet] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - public ActionResult> ASFGet() { - if (ASF.GlobalConfig == null) { - throw new InvalidOperationException(nameof(ASF.GlobalConfig)); - } - - uint memoryUsage = (uint) GC.GetTotalMemory(false) / 1024; - - ASFResponse result = new(SharedInfo.BuildInfo.Variant, SharedInfo.BuildInfo.CanUpdate, ASF.GlobalConfig, memoryUsage, OS.ProcessStartTime, SharedInfo.Version); - - return Ok(new GenericResponse(result)); + if (string.IsNullOrEmpty(request.StringToEncrypt)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(request.StringToEncrypt)))); } - /// - /// Encrypts data with ASF encryption mechanisms using provided details. - /// - [Consumes("application/json")] - [HttpPost("Hash")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public ActionResult ASFHashPost([FromBody] ASFHashRequest request) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } + string? encryptedString = Actions.Encrypt(request.CryptoMethod, request.StringToEncrypt); - if (string.IsNullOrEmpty(request.StringToHash)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(request.StringToHash)))); - } + return Ok(new GenericResponse(encryptedString)); + } - string hash = Actions.Hash(request.HashingMethod, request.StringToHash); - - return Ok(new GenericResponse(hash)); + /// + /// Fetches common info related to ASF as a whole. + /// + [HttpGet] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + public ActionResult> ASFGet() { + if (ASF.GlobalConfig == null) { + throw new InvalidOperationException(nameof(ASF.GlobalConfig)); } - /// - /// Updates ASF's global config. - /// - [Consumes("application/json")] - [HttpPost] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> ASFPost([FromBody] ASFRequest request) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } + uint memoryUsage = (uint) GC.GetTotalMemory(false) / 1024; - if (ASF.GlobalConfig == null) { - throw new InvalidOperationException(nameof(ASF.GlobalConfig)); - } + ASFResponse result = new(SharedInfo.BuildInfo.Variant, SharedInfo.BuildInfo.CanUpdate, ASF.GlobalConfig, memoryUsage, OS.ProcessStartTime, SharedInfo.Version); - (bool valid, string? errorMessage) = request.GlobalConfig.CheckValidation(); + return Ok(new GenericResponse(result)); + } - if (!valid) { - return BadRequest(new GenericResponse(false, errorMessage)); - } - - request.GlobalConfig.Saving = true; - - if (!request.GlobalConfig.IsIPCPasswordSet && ASF.GlobalConfig.IsIPCPasswordSet) { - request.GlobalConfig.IPCPassword = ASF.GlobalConfig.IPCPassword; - } - - if (!request.GlobalConfig.IsWebProxyPasswordSet && ASF.GlobalConfig.IsWebProxyPasswordSet) { - request.GlobalConfig.WebProxyPassword = ASF.GlobalConfig.WebProxyPassword; - } - - if (ASF.GlobalConfig.AdditionalProperties is { Count: > 0 }) { - request.GlobalConfig.AdditionalProperties ??= new Dictionary(ASF.GlobalConfig.AdditionalProperties.Count, ASF.GlobalConfig.AdditionalProperties.Comparer); - - foreach ((string key, JToken value) in ASF.GlobalConfig.AdditionalProperties.Where(property => !request.GlobalConfig.AdditionalProperties.ContainsKey(property.Key))) { - request.GlobalConfig.AdditionalProperties.Add(key, value); - } - - request.GlobalConfig.AdditionalProperties.TrimExcess(); - } - - string filePath = ASF.GetFilePath(ASF.EFileType.Config); - - if (string.IsNullOrEmpty(filePath)) { - ASF.ArchiLogger.LogNullError(filePath); - - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(filePath)))); - } - - bool result = await GlobalConfig.Write(filePath, request.GlobalConfig).ConfigureAwait(false); - - return Ok(new GenericResponse(result)); + /// + /// Encrypts data with ASF encryption mechanisms using provided details. + /// + [Consumes("application/json")] + [HttpPost("Hash")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public ActionResult ASFHashPost([FromBody] ASFHashRequest request) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); } - /// - /// Makes ASF shutdown itself. - /// - [HttpPost("Exit")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - public ActionResult ExitPost() { - (bool success, string message) = Actions.Exit(); - - return Ok(new GenericResponse(success, message)); + if (string.IsNullOrEmpty(request.StringToHash)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(request.StringToHash)))); } - /// - /// Makes ASF restart itself. - /// - [HttpPost("Restart")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - public ActionResult RestartPost() { - (bool success, string message) = Actions.Restart(); + string hash = Actions.Hash(request.HashingMethod, request.StringToHash); - return Ok(new GenericResponse(success, message)); + return Ok(new GenericResponse(hash)); + } + + /// + /// Updates ASF's global config. + /// + [Consumes("application/json")] + [HttpPost] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> ASFPost([FromBody] ASFRequest request) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); } - /// - /// Makes ASF update itself. - /// - [HttpPost("Update")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - public async Task>> UpdatePost() { - (bool success, string? message, Version? version) = await Actions.Update().ConfigureAwait(false); + if (ASF.GlobalConfig == null) { + throw new InvalidOperationException(nameof(ASF.GlobalConfig)); + } - if (string.IsNullOrEmpty(message)) { - message = success ? Strings.Success : Strings.WarningFailed; + (bool valid, string? errorMessage) = request.GlobalConfig.CheckValidation(); + + if (!valid) { + return BadRequest(new GenericResponse(false, errorMessage)); + } + + request.GlobalConfig.Saving = true; + + if (!request.GlobalConfig.IsIPCPasswordSet && ASF.GlobalConfig.IsIPCPasswordSet) { + request.GlobalConfig.IPCPassword = ASF.GlobalConfig.IPCPassword; + } + + if (!request.GlobalConfig.IsWebProxyPasswordSet && ASF.GlobalConfig.IsWebProxyPasswordSet) { + request.GlobalConfig.WebProxyPassword = ASF.GlobalConfig.WebProxyPassword; + } + + if (ASF.GlobalConfig.AdditionalProperties is { Count: > 0 }) { + request.GlobalConfig.AdditionalProperties ??= new Dictionary(ASF.GlobalConfig.AdditionalProperties.Count, ASF.GlobalConfig.AdditionalProperties.Comparer); + + foreach ((string key, JToken value) in ASF.GlobalConfig.AdditionalProperties.Where(property => !request.GlobalConfig.AdditionalProperties.ContainsKey(property.Key))) { + request.GlobalConfig.AdditionalProperties.Add(key, value); } - return Ok(new GenericResponse(success, message!, version?.ToString())); + request.GlobalConfig.AdditionalProperties.TrimExcess(); } + + string filePath = ASF.GetFilePath(ASF.EFileType.Config); + + if (string.IsNullOrEmpty(filePath)) { + ASF.ArchiLogger.LogNullError(filePath); + + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(filePath)))); + } + + bool result = await GlobalConfig.Write(filePath, request.GlobalConfig).ConfigureAwait(false); + + return Ok(new GenericResponse(result)); + } + + /// + /// Makes ASF shutdown itself. + /// + [HttpPost("Exit")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + public ActionResult ExitPost() { + (bool success, string message) = Actions.Exit(); + + return Ok(new GenericResponse(success, message)); + } + + /// + /// Makes ASF restart itself. + /// + [HttpPost("Restart")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + public ActionResult RestartPost() { + (bool success, string message) = Actions.Restart(); + + return Ok(new GenericResponse(success, message)); + } + + /// + /// Makes ASF update itself. + /// + [HttpPost("Update")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + public async Task>> UpdatePost() { + (bool success, string? message, Version? version) = await Actions.Update().ConfigureAwait(false); + + if (string.IsNullOrEmpty(message)) { + message = success ? Strings.Success : Strings.WarningFailed; + } + + return Ok(new GenericResponse(success, message!, version?.ToString())); } } diff --git a/ArchiSteamFarm/IPC/Controllers/Api/ArchiController.cs b/ArchiSteamFarm/IPC/Controllers/Api/ArchiController.cs index 6c4064808..cb25dad83 100644 --- a/ArchiSteamFarm/IPC/Controllers/Api/ArchiController.cs +++ b/ArchiSteamFarm/IPC/Controllers/Api/ArchiController.cs @@ -25,14 +25,14 @@ using ArchiSteamFarm.Storage; using Microsoft.AspNetCore.Mvc; using Swashbuckle.AspNetCore.Annotations; -namespace ArchiSteamFarm.IPC.Controllers.Api { - [ApiController] - [Produces("application/json")] - [Route("Api")] - [SwaggerResponse((int) HttpStatusCode.BadRequest, "The request has failed, check " + nameof(GenericResponse.Message) + " from response body for actual reason. Most of the time this is ASF, understanding the request, but refusing to execute it due to provided reason.", typeof(GenericResponse))] - [SwaggerResponse((int) HttpStatusCode.Unauthorized, "ASF has " + nameof(GlobalConfig.IPCPassword) + " set, but you've failed to authenticate. See " + SharedInfo.ProjectURL + "/wiki/IPC#authentication.", typeof(GenericResponse))] - [SwaggerResponse((int) HttpStatusCode.Forbidden, "ASF lacks " + nameof(GlobalConfig.IPCPassword) + " and you're not permitted to access the API, or " + nameof(GlobalConfig.IPCPassword) + " is set and you've failed to authenticate too many times (try again in an hour). See " + SharedInfo.ProjectURL + "/wiki/IPC#authentication.", typeof(GenericResponse))] - [SwaggerResponse((int) HttpStatusCode.InternalServerError, "ASF has encountered an unexpected error while serving the request. The log may include extra info related to this issue.")] - [SwaggerResponse((int) HttpStatusCode.ServiceUnavailable, "ASF has encountered an error while requesting a third-party resource. Try again later.")] - public abstract class ArchiController : ControllerBase { } -} +namespace ArchiSteamFarm.IPC.Controllers.Api; + +[ApiController] +[Produces("application/json")] +[Route("Api")] +[SwaggerResponse((int) HttpStatusCode.BadRequest, "The request has failed, check " + nameof(GenericResponse.Message) + " from response body for actual reason. Most of the time this is ASF, understanding the request, but refusing to execute it due to provided reason.", typeof(GenericResponse))] +[SwaggerResponse((int) HttpStatusCode.Unauthorized, "ASF has " + nameof(GlobalConfig.IPCPassword) + " set, but you've failed to authenticate. See " + SharedInfo.ProjectURL + "/wiki/IPC#authentication.", typeof(GenericResponse))] +[SwaggerResponse((int) HttpStatusCode.Forbidden, "ASF lacks " + nameof(GlobalConfig.IPCPassword) + " and you're not permitted to access the API, or " + nameof(GlobalConfig.IPCPassword) + " is set and you've failed to authenticate too many times (try again in an hour). See " + SharedInfo.ProjectURL + "/wiki/IPC#authentication.", typeof(GenericResponse))] +[SwaggerResponse((int) HttpStatusCode.InternalServerError, "ASF has encountered an unexpected error while serving the request. The log may include extra info related to this issue.")] +[SwaggerResponse((int) HttpStatusCode.ServiceUnavailable, "ASF has encountered an error while requesting a third-party resource. Try again later.")] +public abstract class ArchiController : ControllerBase { } diff --git a/ArchiSteamFarm/IPC/Controllers/Api/BotController.cs b/ArchiSteamFarm/IPC/Controllers/Api/BotController.cs index 7b56118aa..eed4de88f 100644 --- a/ArchiSteamFarm/IPC/Controllers/Api/BotController.cs +++ b/ArchiSteamFarm/IPC/Controllers/Api/BotController.cs @@ -36,423 +36,423 @@ using ArchiSteamFarm.Steam.Storage; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; -namespace ArchiSteamFarm.IPC.Controllers.Api { - [Route("Api/Bot")] - public sealed class BotController : ArchiController { - /// - /// Deletes all files related to given bots. - /// - [HttpDelete("{botNames:required}")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> BotDelete(string botNames) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } +namespace ArchiSteamFarm.IPC.Controllers.Api; - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); - } - - IList results = await Utilities.InParallel(bots.Select(static bot => bot.DeleteAllRelatedFiles())).ConfigureAwait(false); - - return Ok(new GenericResponse(results.All(static result => result))); +[Route("Api/Bot")] +public sealed class BotController : ArchiController { + /// + /// Deletes all files related to given bots. + /// + [HttpDelete("{botNames:required}")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> BotDelete(string botNames) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); } - /// - /// Fetches common info related to given bots. - /// - [HttpGet("{botNames:required}")] - [ProducesResponseType(typeof(GenericResponse>), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public ActionResult BotGet(string botNames) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } + HashSet? bots = Bot.GetBots(botNames); - HashSet? bots = Bot.GetBots(botNames); - - if (bots == null) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(bots)))); - } - - return Ok(new GenericResponse>(bots.Where(static bot => !string.IsNullOrEmpty(bot.BotName)).ToDictionary(static bot => bot.BotName, static bot => bot, Bot.BotsComparer))); + if ((bots == null) || (bots.Count == 0)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); } - /// - /// Updates bot config of given bot. - /// - [Consumes("application/json")] - [HttpPost("{botNames:required}")] - [ProducesResponseType(typeof(GenericResponse>), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> BotPost(string botNames, [FromBody] BotRequest request) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } + IList results = await Utilities.InParallel(bots.Select(static bot => bot.DeleteAllRelatedFiles())).ConfigureAwait(false); - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } + return Ok(new GenericResponse(results.All(static result => result))); + } - if (Bot.Bots == null) { - throw new InvalidOperationException(nameof(Bot.Bots)); - } + /// + /// Fetches common info related to given bots. + /// + [HttpGet("{botNames:required}")] + [ProducesResponseType(typeof(GenericResponse>), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public ActionResult BotGet(string botNames) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } - (bool valid, string? errorMessage) = request.BotConfig.CheckValidation(); + HashSet? bots = Bot.GetBots(botNames); - if (!valid) { - return BadRequest(new GenericResponse(false, errorMessage)); - } + if (bots == null) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(bots)))); + } - request.BotConfig.Saving = true; + return Ok(new GenericResponse>(bots.Where(static bot => !string.IsNullOrEmpty(bot.BotName)).ToDictionary(static bot => bot.BotName, static bot => bot, Bot.BotsComparer))); + } - HashSet bots = botNames.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToHashSet(Bot.BotsComparer); + /// + /// Updates bot config of given bot. + /// + [Consumes("application/json")] + [HttpPost("{botNames:required}")] + [ProducesResponseType(typeof(GenericResponse>), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> BotPost(string botNames, [FromBody] BotRequest request) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } - if (bots.Any(static botName => !ASF.IsValidBotName(botName))) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(botNames)))); - } + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } - Dictionary result = new(bots.Count, Bot.BotsComparer); + if (Bot.Bots == null) { + throw new InvalidOperationException(nameof(Bot.Bots)); + } - foreach (string botName in bots) { - if (Bot.Bots.TryGetValue(botName, out Bot? bot)) { - if (!request.BotConfig.IsSteamLoginSet && bot.BotConfig.IsSteamLoginSet) { - request.BotConfig.SteamLogin = bot.BotConfig.SteamLogin; - } + (bool valid, string? errorMessage) = request.BotConfig.CheckValidation(); - if (!request.BotConfig.IsSteamPasswordSet && bot.BotConfig.IsSteamPasswordSet) { - request.BotConfig.DecryptedSteamPassword = bot.BotConfig.DecryptedSteamPassword; - } + if (!valid) { + return BadRequest(new GenericResponse(false, errorMessage)); + } - if (!request.BotConfig.IsSteamParentalCodeSet && bot.BotConfig.IsSteamParentalCodeSet) { - request.BotConfig.SteamParentalCode = bot.BotConfig.SteamParentalCode; - } + request.BotConfig.Saving = true; - if (bot.BotConfig.AdditionalProperties?.Count > 0) { - request.BotConfig.AdditionalProperties ??= new Dictionary(bot.BotConfig.AdditionalProperties.Count, bot.BotConfig.AdditionalProperties.Comparer); + HashSet bots = botNames.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToHashSet(Bot.BotsComparer); - foreach ((string key, JToken value) in bot.BotConfig.AdditionalProperties.Where(property => !request.BotConfig.AdditionalProperties.ContainsKey(property.Key))) { - request.BotConfig.AdditionalProperties.Add(key, value); - } + if (bots.Any(static botName => !ASF.IsValidBotName(botName))) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(botNames)))); + } - request.BotConfig.AdditionalProperties.TrimExcess(); - } + Dictionary result = new(bots.Count, Bot.BotsComparer); + + foreach (string botName in bots) { + if (Bot.Bots.TryGetValue(botName, out Bot? bot)) { + if (!request.BotConfig.IsSteamLoginSet && bot.BotConfig.IsSteamLoginSet) { + request.BotConfig.SteamLogin = bot.BotConfig.SteamLogin; } - string filePath = Bot.GetFilePath(botName, Bot.EFileType.Config); - - if (string.IsNullOrEmpty(filePath)) { - ASF.ArchiLogger.LogNullError(filePath); - - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(filePath)))); + if (!request.BotConfig.IsSteamPasswordSet && bot.BotConfig.IsSteamPasswordSet) { + request.BotConfig.DecryptedSteamPassword = bot.BotConfig.DecryptedSteamPassword; } - result[botName] = await BotConfig.Write(filePath, request.BotConfig).ConfigureAwait(false); - } + if (!request.BotConfig.IsSteamParentalCodeSet && bot.BotConfig.IsSteamParentalCodeSet) { + request.BotConfig.SteamParentalCode = bot.BotConfig.SteamParentalCode; + } - return Ok(new GenericResponse>(result.Values.All(static value => value), result)); - } + if (bot.BotConfig.AdditionalProperties?.Count > 0) { + request.BotConfig.AdditionalProperties ??= new Dictionary(bot.BotConfig.AdditionalProperties.Count, bot.BotConfig.AdditionalProperties.Comparer); - /// - /// Removes BGR output files of given bots. - /// - [HttpDelete("{botNames:required}/GamesToRedeemInBackground")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> GamesToRedeemInBackgroundDelete(string botNames) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } + foreach ((string key, JToken value) in bot.BotConfig.AdditionalProperties.Where(property => !request.BotConfig.AdditionalProperties.ContainsKey(property.Key))) { + request.BotConfig.AdditionalProperties.Add(key, value); + } - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); - } - - IList results = await Utilities.InParallel(bots.Select(static bot => Task.Run(bot.DeleteRedeemedKeysFiles))).ConfigureAwait(false); - - return Ok(results.All(static result => result) ? new GenericResponse(true) : new GenericResponse(false, Strings.WarningFailed)); - } - - /// - /// Fetches BGR output files of given bots. - /// - [HttpGet("{botNames:required}/GamesToRedeemInBackground")] - [ProducesResponseType(typeof(GenericResponse>), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> GamesToRedeemInBackgroundGet(string botNames) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); - } - - IList<(Dictionary? UnusedKeys, Dictionary? UsedKeys)> results = await Utilities.InParallel(bots.Select(static bot => bot.GetUsedAndUnusedKeys())).ConfigureAwait(false); - - Dictionary result = new(bots.Count, Bot.BotsComparer); - - foreach (Bot bot in bots) { - (Dictionary? unusedKeys, Dictionary? usedKeys) = results[result.Count]; - result[bot.BotName] = new GamesToRedeemInBackgroundResponse(unusedKeys, usedKeys); - } - - return Ok(new GenericResponse>(result)); - } - - /// - /// Adds keys to redeem using BGR to given bot. - /// - [Consumes("application/json")] - [HttpPost("{botNames:required}/GamesToRedeemInBackground")] - [ProducesResponseType(typeof(GenericResponse>), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> GamesToRedeemInBackgroundPost(string botNames, [FromBody] BotGamesToRedeemInBackgroundRequest request) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (request.GamesToRedeemInBackground.Count == 0) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(request.GamesToRedeemInBackground)))); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); - } - - IOrderedDictionary validGamesToRedeemInBackground = Bot.ValidateGamesToRedeemInBackground(request.GamesToRedeemInBackground); - - if (validGamesToRedeemInBackground.Count == 0) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(validGamesToRedeemInBackground)))); - } - - await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.AddGamesToRedeemInBackground(validGamesToRedeemInBackground)))).ConfigureAwait(false); - - Dictionary result = new(bots.Count, Bot.BotsComparer); - - foreach (Bot bot in bots) { - result[bot.BotName] = validGamesToRedeemInBackground; - } - - return Ok(new GenericResponse>(result)); - } - - /// - /// Provides input value to given bot for next usage. - /// - [Consumes("application/json")] - [HttpPost("{botNames:required}/Input")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> InputPost(string botNames, [FromBody] BotInputRequest request) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if ((request.Type == ASF.EUserInputType.None) || !Enum.IsDefined(typeof(ASF.EUserInputType), request.Type) || string.IsNullOrEmpty(request.Value)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, $"{nameof(request.Type)} || {nameof(request.Value)}"))); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.SetUserInput(request.Type, request.Value)))).ConfigureAwait(false); - - return Ok(results.All(static result => result) ? new GenericResponse(true) : new GenericResponse(false, Strings.WarningFailed)); - } - - /// - /// Pauses given bots. - /// - [Consumes("application/json")] - [HttpPost("{botNames:required}/Pause")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> PausePost(string botNames, [FromBody] BotPauseRequest request) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); - } - - IList<(bool Success, string Message)> results = await Utilities.InParallel(bots.Select(bot => bot.Actions.Pause(request.Permanent, request.ResumeInSeconds))).ConfigureAwait(false); - - return Ok(new GenericResponse(results.All(static result => result.Success), string.Join(Environment.NewLine, results.Select(static result => result.Message)))); - } - - /// - /// Redeems cd-keys on given bot. - /// - /// - /// Response contains a map that maps each provided cd-key to its redeem result. - /// Redeem result can be a null value, this means that ASF didn't even attempt to send a request (e.g. because of bot not being connected to Steam network). - /// - [Consumes("application/json")] - [HttpPost("{botNames:required}/Redeem")] - [ProducesResponseType(typeof(GenericResponse>>), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> RedeemPost(string botNames, [FromBody] BotRedeemRequest request) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (request.KeysToRedeem.Count == 0) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(request.KeysToRedeem)))); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); - } - - IList results = await Utilities.InParallel(bots.Select(bot => request.KeysToRedeem.Select(key => bot.Actions.RedeemKey(key))).SelectMany(static task => task)).ConfigureAwait(false); - - Dictionary> result = new(bots.Count, Bot.BotsComparer); - - int count = 0; - - foreach (Bot bot in bots) { - Dictionary responses = new(request.KeysToRedeem.Count, StringComparer.Ordinal); - result[bot.BotName] = responses; - - foreach (string key in request.KeysToRedeem) { - responses[key] = results[count++]; + request.BotConfig.AdditionalProperties.TrimExcess(); } } - return Ok(new GenericResponse>>(result.Values.SelectMany(static responses => responses.Values).All(static value => value != null), result)); + string filePath = Bot.GetFilePath(botName, Bot.EFileType.Config); + + if (string.IsNullOrEmpty(filePath)) { + ASF.ArchiLogger.LogNullError(filePath); + + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(filePath)))); + } + + result[botName] = await BotConfig.Write(filePath, request.BotConfig).ConfigureAwait(false); } - /// - /// Renames given bot along with all its related files. - /// - [Consumes("application/json")] - [HttpPost("{botName:required}/Rename")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> RenamePost(string botName, [FromBody] BotRenameRequest request) { - if (string.IsNullOrEmpty(botName)) { - throw new ArgumentNullException(nameof(botName)); - } + return Ok(new GenericResponse>(result.Values.All(static value => value), result)); + } - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (Bot.Bots == null) { - throw new InvalidOperationException(nameof(Bot.Bots)); - } - - if (string.IsNullOrEmpty(request.NewName) || !ASF.IsValidBotName(request.NewName) || Bot.Bots.ContainsKey(request.NewName)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(request.NewName)))); - } - - if (!Bot.Bots.TryGetValue(botName, out Bot? bot)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botName))); - } - - bool result = await bot.Rename(request.NewName).ConfigureAwait(false); - - return Ok(new GenericResponse(result)); + /// + /// Removes BGR output files of given bots. + /// + [HttpDelete("{botNames:required}/GamesToRedeemInBackground")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> GamesToRedeemInBackgroundDelete(string botNames) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); } - /// - /// Resumes given bots. - /// - [HttpPost("{botNames:required}/Resume")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> ResumePost(string botNames) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } + HashSet? bots = Bot.GetBots(botNames); - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); - } - - IList<(bool Success, string Message)> results = await Utilities.InParallel(bots.Select(static bot => Task.Run(bot.Actions.Resume))).ConfigureAwait(false); - - return Ok(new GenericResponse(results.All(static result => result.Success), string.Join(Environment.NewLine, results.Select(static result => result.Message)))); + if ((bots == null) || (bots.Count == 0)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); } - /// - /// Starts given bots. - /// - [HttpPost("{botNames:required}/Start")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> StartPost(string botNames) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } + IList results = await Utilities.InParallel(bots.Select(static bot => Task.Run(bot.DeleteRedeemedKeysFiles))).ConfigureAwait(false); - HashSet? bots = Bot.GetBots(botNames); + return Ok(results.All(static result => result) ? new GenericResponse(true) : new GenericResponse(false, Strings.WarningFailed)); + } - if ((bots == null) || (bots.Count == 0)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); - } - - IList<(bool Success, string Message)> results = await Utilities.InParallel(bots.Select(static bot => Task.Run(bot.Actions.Start))).ConfigureAwait(false); - - return Ok(new GenericResponse(results.All(static result => result.Success), string.Join(Environment.NewLine, results.Select(static result => result.Message)))); + /// + /// Fetches BGR output files of given bots. + /// + [HttpGet("{botNames:required}/GamesToRedeemInBackground")] + [ProducesResponseType(typeof(GenericResponse>), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> GamesToRedeemInBackgroundGet(string botNames) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); } - /// - /// Stops given bots. - /// - [HttpPost("{botNames:required}/Stop")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> StopPost(string botNames) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } + HashSet? bots = Bot.GetBots(botNames); - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); - } - - IList<(bool Success, string Message)> results = await Utilities.InParallel(bots.Select(static bot => Task.Run(bot.Actions.Stop))).ConfigureAwait(false); - - return Ok(new GenericResponse(results.All(static result => result.Success), string.Join(Environment.NewLine, results.Select(static result => result.Message)))); + if ((bots == null) || (bots.Count == 0)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); } + + IList<(Dictionary? UnusedKeys, Dictionary? UsedKeys)> results = await Utilities.InParallel(bots.Select(static bot => bot.GetUsedAndUnusedKeys())).ConfigureAwait(false); + + Dictionary result = new(bots.Count, Bot.BotsComparer); + + foreach (Bot bot in bots) { + (Dictionary? unusedKeys, Dictionary? usedKeys) = results[result.Count]; + result[bot.BotName] = new GamesToRedeemInBackgroundResponse(unusedKeys, usedKeys); + } + + return Ok(new GenericResponse>(result)); + } + + /// + /// Adds keys to redeem using BGR to given bot. + /// + [Consumes("application/json")] + [HttpPost("{botNames:required}/GamesToRedeemInBackground")] + [ProducesResponseType(typeof(GenericResponse>), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> GamesToRedeemInBackgroundPost(string botNames, [FromBody] BotGamesToRedeemInBackgroundRequest request) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (request.GamesToRedeemInBackground.Count == 0) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(request.GamesToRedeemInBackground)))); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); + } + + IOrderedDictionary validGamesToRedeemInBackground = Bot.ValidateGamesToRedeemInBackground(request.GamesToRedeemInBackground); + + if (validGamesToRedeemInBackground.Count == 0) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(validGamesToRedeemInBackground)))); + } + + await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.AddGamesToRedeemInBackground(validGamesToRedeemInBackground)))).ConfigureAwait(false); + + Dictionary result = new(bots.Count, Bot.BotsComparer); + + foreach (Bot bot in bots) { + result[bot.BotName] = validGamesToRedeemInBackground; + } + + return Ok(new GenericResponse>(result)); + } + + /// + /// Provides input value to given bot for next usage. + /// + [Consumes("application/json")] + [HttpPost("{botNames:required}/Input")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> InputPost(string botNames, [FromBody] BotInputRequest request) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if ((request.Type == ASF.EUserInputType.None) || !Enum.IsDefined(typeof(ASF.EUserInputType), request.Type) || string.IsNullOrEmpty(request.Value)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, $"{nameof(request.Type)} || {nameof(request.Value)}"))); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.SetUserInput(request.Type, request.Value)))).ConfigureAwait(false); + + return Ok(results.All(static result => result) ? new GenericResponse(true) : new GenericResponse(false, Strings.WarningFailed)); + } + + /// + /// Pauses given bots. + /// + [Consumes("application/json")] + [HttpPost("{botNames:required}/Pause")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> PausePost(string botNames, [FromBody] BotPauseRequest request) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); + } + + IList<(bool Success, string Message)> results = await Utilities.InParallel(bots.Select(bot => bot.Actions.Pause(request.Permanent, request.ResumeInSeconds))).ConfigureAwait(false); + + return Ok(new GenericResponse(results.All(static result => result.Success), string.Join(Environment.NewLine, results.Select(static result => result.Message)))); + } + + /// + /// Redeems cd-keys on given bot. + /// + /// + /// Response contains a map that maps each provided cd-key to its redeem result. + /// Redeem result can be a null value, this means that ASF didn't even attempt to send a request (e.g. because of bot not being connected to Steam network). + /// + [Consumes("application/json")] + [HttpPost("{botNames:required}/Redeem")] + [ProducesResponseType(typeof(GenericResponse>>), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> RedeemPost(string botNames, [FromBody] BotRedeemRequest request) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (request.KeysToRedeem.Count == 0) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(request.KeysToRedeem)))); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); + } + + IList results = await Utilities.InParallel(bots.Select(bot => request.KeysToRedeem.Select(key => bot.Actions.RedeemKey(key))).SelectMany(static task => task)).ConfigureAwait(false); + + Dictionary> result = new(bots.Count, Bot.BotsComparer); + + int count = 0; + + foreach (Bot bot in bots) { + Dictionary responses = new(request.KeysToRedeem.Count, StringComparer.Ordinal); + result[bot.BotName] = responses; + + foreach (string key in request.KeysToRedeem) { + responses[key] = results[count++]; + } + } + + return Ok(new GenericResponse>>(result.Values.SelectMany(static responses => responses.Values).All(static value => value != null), result)); + } + + /// + /// Renames given bot along with all its related files. + /// + [Consumes("application/json")] + [HttpPost("{botName:required}/Rename")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> RenamePost(string botName, [FromBody] BotRenameRequest request) { + if (string.IsNullOrEmpty(botName)) { + throw new ArgumentNullException(nameof(botName)); + } + + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (Bot.Bots == null) { + throw new InvalidOperationException(nameof(Bot.Bots)); + } + + if (string.IsNullOrEmpty(request.NewName) || !ASF.IsValidBotName(request.NewName) || Bot.Bots.ContainsKey(request.NewName)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(request.NewName)))); + } + + if (!Bot.Bots.TryGetValue(botName, out Bot? bot)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botName))); + } + + bool result = await bot.Rename(request.NewName).ConfigureAwait(false); + + return Ok(new GenericResponse(result)); + } + + /// + /// Resumes given bots. + /// + [HttpPost("{botNames:required}/Resume")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> ResumePost(string botNames) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); + } + + IList<(bool Success, string Message)> results = await Utilities.InParallel(bots.Select(static bot => Task.Run(bot.Actions.Resume))).ConfigureAwait(false); + + return Ok(new GenericResponse(results.All(static result => result.Success), string.Join(Environment.NewLine, results.Select(static result => result.Message)))); + } + + /// + /// Starts given bots. + /// + [HttpPost("{botNames:required}/Start")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> StartPost(string botNames) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); + } + + IList<(bool Success, string Message)> results = await Utilities.InParallel(bots.Select(static bot => Task.Run(bot.Actions.Start))).ConfigureAwait(false); + + return Ok(new GenericResponse(results.All(static result => result.Success), string.Join(Environment.NewLine, results.Select(static result => result.Message)))); + } + + /// + /// Stops given bots. + /// + [HttpPost("{botNames:required}/Stop")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> StopPost(string botNames) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); + } + + IList<(bool Success, string Message)> results = await Utilities.InParallel(bots.Select(static bot => Task.Run(bot.Actions.Stop))).ConfigureAwait(false); + + return Ok(new GenericResponse(results.All(static result => result.Success), string.Join(Environment.NewLine, results.Select(static result => result.Message)))); } } diff --git a/ArchiSteamFarm/IPC/Controllers/Api/CommandController.cs b/ArchiSteamFarm/IPC/Controllers/Api/CommandController.cs index e426c7f94..8da15275d 100644 --- a/ArchiSteamFarm/IPC/Controllers/Api/CommandController.cs +++ b/ArchiSteamFarm/IPC/Controllers/Api/CommandController.cs @@ -32,58 +32,58 @@ using ArchiSteamFarm.Steam; using ArchiSteamFarm.Storage; using Microsoft.AspNetCore.Mvc; -namespace ArchiSteamFarm.IPC.Controllers.Api { - [Route("Api/Command")] - public sealed class CommandController : ArchiController { - /// - /// Executes a command. - /// - /// - /// This API endpoint is supposed to be entirely replaced by ASF actions available under /Api/ASF/{action} and /Api/Bot/{bot}/{action}. - /// You should use "given bot" commands when executing this endpoint, omitting targets of the command will cause the command to be executed on first defined bot - /// - [Consumes("application/json")] - [HttpPost] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> CommandPost([FromBody] CommandRequest request) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } +namespace ArchiSteamFarm.IPC.Controllers.Api; - if (string.IsNullOrEmpty(request.Command)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(request.Command)))); - } - - ulong steamOwnerID = ASF.GlobalConfig?.SteamOwnerID ?? GlobalConfig.DefaultSteamOwnerID; - - if (steamOwnerID == 0) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(ASF.GlobalConfig.SteamOwnerID)))); - } - - Bot? targetBot = Bot.Bots?.OrderBy(static bot => bot.Key, Bot.BotsComparer).Select(static bot => bot.Value).FirstOrDefault(); - - if (targetBot == null) { - return BadRequest(new GenericResponse(false, Strings.ErrorNoBotsDefined)); - } - - string command = request.Command; - string? commandPrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.CommandPrefix : GlobalConfig.DefaultCommandPrefix; - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (!string.IsNullOrEmpty(commandPrefix) && command.StartsWith(commandPrefix!, StringComparison.Ordinal)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (command.Length == commandPrefix!.Length) { - // If the message starts with command prefix and is of the same length as command prefix, then it's just empty command trigger, useless - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(command)))); - } - - command = command[commandPrefix.Length..]; - } - - string? response = await targetBot.Commands.Response(steamOwnerID, command).ConfigureAwait(false); - - return Ok(new GenericResponse(response)); +[Route("Api/Command")] +public sealed class CommandController : ArchiController { + /// + /// Executes a command. + /// + /// + /// This API endpoint is supposed to be entirely replaced by ASF actions available under /Api/ASF/{action} and /Api/Bot/{bot}/{action}. + /// You should use "given bot" commands when executing this endpoint, omitting targets of the command will cause the command to be executed on first defined bot + /// + [Consumes("application/json")] + [HttpPost] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> CommandPost([FromBody] CommandRequest request) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); } + + if (string.IsNullOrEmpty(request.Command)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(request.Command)))); + } + + ulong steamOwnerID = ASF.GlobalConfig?.SteamOwnerID ?? GlobalConfig.DefaultSteamOwnerID; + + if (steamOwnerID == 0) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(ASF.GlobalConfig.SteamOwnerID)))); + } + + Bot? targetBot = Bot.Bots?.OrderBy(static bot => bot.Key, Bot.BotsComparer).Select(static bot => bot.Value).FirstOrDefault(); + + if (targetBot == null) { + return BadRequest(new GenericResponse(false, Strings.ErrorNoBotsDefined)); + } + + string command = request.Command; + string? commandPrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.CommandPrefix : GlobalConfig.DefaultCommandPrefix; + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (!string.IsNullOrEmpty(commandPrefix) && command.StartsWith(commandPrefix!, StringComparison.Ordinal)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (command.Length == commandPrefix!.Length) { + // If the message starts with command prefix and is of the same length as command prefix, then it's just empty command trigger, useless + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(command)))); + } + + command = command[commandPrefix.Length..]; + } + + string? response = await targetBot.Commands.Response(steamOwnerID, command).ConfigureAwait(false); + + return Ok(new GenericResponse(response)); } } diff --git a/ArchiSteamFarm/IPC/Controllers/Api/GitHubController.cs b/ArchiSteamFarm/IPC/Controllers/Api/GitHubController.cs index a1f88f6f3..a50473f58 100644 --- a/ArchiSteamFarm/IPC/Controllers/Api/GitHubController.cs +++ b/ArchiSteamFarm/IPC/Controllers/Api/GitHubController.cs @@ -30,102 +30,102 @@ using ArchiSteamFarm.Localization; using ArchiSteamFarm.Web; using Microsoft.AspNetCore.Mvc; -namespace ArchiSteamFarm.IPC.Controllers.Api { - [Route("Api/WWW/GitHub")] - public sealed class GitHubController : ArchiController { - /// - /// Fetches the most recent GitHub release of ASF project. - /// - /// - /// This is internal API being utilizied by our ASF-ui IPC frontend. You should not depend on existence of any /Api/WWW endpoints as they can disappear and change anytime. - /// - [HttpGet("Release")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)] - public async Task> GitHubReleaseGet() { - GitHub.ReleaseResponse? releaseResponse = await GitHub.GetLatestRelease(false).ConfigureAwait(false); +namespace ArchiSteamFarm.IPC.Controllers.Api; - return releaseResponse != null ? Ok(new GenericResponse(new GitHubReleaseResponse(releaseResponse))) : StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries))); +[Route("Api/WWW/GitHub")] +public sealed class GitHubController : ArchiController { + /// + /// Fetches the most recent GitHub release of ASF project. + /// + /// + /// This is internal API being utilizied by our ASF-ui IPC frontend. You should not depend on existence of any /Api/WWW endpoints as they can disappear and change anytime. + /// + [HttpGet("Release")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)] + public async Task> GitHubReleaseGet() { + GitHub.ReleaseResponse? releaseResponse = await GitHub.GetLatestRelease(false).ConfigureAwait(false); + + return releaseResponse != null ? Ok(new GenericResponse(new GitHubReleaseResponse(releaseResponse))) : StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries))); + } + + /// + /// Fetches specific GitHub release of ASF project. Use "latest" for latest stable release. + /// + /// + /// This is internal API being utilizied by our ASF-ui IPC frontend. You should not depend on existence of any /Api/WWW endpoints as they can disappear and change anytime. + /// + [HttpGet("Release/{version:required}")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)] + public async Task> GitHubReleaseGet(string version) { + if (string.IsNullOrEmpty(version)) { + throw new ArgumentNullException(nameof(version)); } - /// - /// Fetches specific GitHub release of ASF project. Use "latest" for latest stable release. - /// - /// - /// This is internal API being utilizied by our ASF-ui IPC frontend. You should not depend on existence of any /Api/WWW endpoints as they can disappear and change anytime. - /// - [HttpGet("Release/{version:required}")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)] - public async Task> GitHubReleaseGet(string version) { - if (string.IsNullOrEmpty(version)) { - throw new ArgumentNullException(nameof(version)); - } + GitHub.ReleaseResponse? releaseResponse; - GitHub.ReleaseResponse? releaseResponse; + switch (version.ToUpperInvariant()) { + case "LATEST": + releaseResponse = await GitHub.GetLatestRelease().ConfigureAwait(false); - switch (version.ToUpperInvariant()) { - case "LATEST": - releaseResponse = await GitHub.GetLatestRelease().ConfigureAwait(false); + break; + default: + if (!Version.TryParse(version, out Version? parsedVersion)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(version)))); + } - break; - default: - if (!Version.TryParse(version, out Version? parsedVersion)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(version)))); - } + releaseResponse = await GitHub.GetRelease(parsedVersion.ToString(4)).ConfigureAwait(false); - releaseResponse = await GitHub.GetRelease(parsedVersion.ToString(4)).ConfigureAwait(false); - - break; - } - - return releaseResponse != null ? Ok(new GenericResponse(new GitHubReleaseResponse(releaseResponse))) : StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries))); + break; } - /// - /// Fetches history of specific GitHub page from ASF project. - /// - /// - /// This is internal API being utilizied by our ASF-ui IPC frontend. You should not depend on existence of any /Api/WWW endpoints as they can disappear and change anytime. - /// - [HttpGet("Wiki/History/{page:required}")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)] - public async Task> GitHubWikiHistoryGet(string page) { - if (string.IsNullOrEmpty(page)) { - throw new ArgumentNullException(nameof(page)); - } + return releaseResponse != null ? Ok(new GenericResponse(new GitHubReleaseResponse(releaseResponse))) : StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries))); + } - Dictionary? revisions = await GitHub.GetWikiHistory(page).ConfigureAwait(false); - - return revisions != null ? revisions.Count > 0 ? Ok(new GenericResponse>(revisions.ToImmutableDictionary())) : BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(page)))) : StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries))); + /// + /// Fetches history of specific GitHub page from ASF project. + /// + /// + /// This is internal API being utilizied by our ASF-ui IPC frontend. You should not depend on existence of any /Api/WWW endpoints as they can disappear and change anytime. + /// + [HttpGet("Wiki/History/{page:required}")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)] + public async Task> GitHubWikiHistoryGet(string page) { + if (string.IsNullOrEmpty(page)) { + throw new ArgumentNullException(nameof(page)); } - /// - /// Fetches specific GitHub page of ASF project. - /// - /// - /// This is internal API being utilizied by our ASF-ui IPC frontend. You should not depend on existence of any /Api/WWW endpoints as they can disappear and change anytime. - /// Specifying revision is optional - when not specified, will fetch latest available. If specified revision is invalid, GitHub will automatically fetch the latest revision as well. - /// - [HttpGet("Wiki/Page/{page:required}")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)] - public async Task> GitHubWikiPageGet(string page, [FromQuery] string? revision = null) { - if (string.IsNullOrEmpty(page)) { - throw new ArgumentNullException(nameof(page)); - } + Dictionary? revisions = await GitHub.GetWikiHistory(page).ConfigureAwait(false); - string? html = await GitHub.GetWikiPage(page, revision).ConfigureAwait(false); + return revisions != null ? revisions.Count > 0 ? Ok(new GenericResponse>(revisions.ToImmutableDictionary())) : BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(page)))) : StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries))); + } - return html switch { - null => StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries))), - "" => BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(page)))), - _ => Ok(new GenericResponse(html)) - }; + /// + /// Fetches specific GitHub page of ASF project. + /// + /// + /// This is internal API being utilizied by our ASF-ui IPC frontend. You should not depend on existence of any /Api/WWW endpoints as they can disappear and change anytime. + /// Specifying revision is optional - when not specified, will fetch latest available. If specified revision is invalid, GitHub will automatically fetch the latest revision as well. + /// + [HttpGet("Wiki/Page/{page:required}")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)] + public async Task> GitHubWikiPageGet(string page, [FromQuery] string? revision = null) { + if (string.IsNullOrEmpty(page)) { + throw new ArgumentNullException(nameof(page)); } + + string? html = await GitHub.GetWikiPage(page, revision).ConfigureAwait(false); + + return html switch { + null => StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries))), + "" => BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(page)))), + _ => Ok(new GenericResponse(html)) + }; } } diff --git a/ArchiSteamFarm/IPC/Controllers/Api/NLogController.cs b/ArchiSteamFarm/IPC/Controllers/Api/NLogController.cs index a96aaa235..64f92322f 100644 --- a/ArchiSteamFarm/IPC/Controllers/Api/NLogController.cs +++ b/ArchiSteamFarm/IPC/Controllers/Api/NLogController.cs @@ -37,157 +37,157 @@ using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Controllers.Api { - [Route("Api/NLog")] - public sealed class NLogController : ArchiController { - private static readonly ConcurrentDictionary ActiveLogWebSockets = new(); +namespace ArchiSteamFarm.IPC.Controllers.Api; - /// - /// Fetches ASF log in realtime. - /// - /// - /// This API endpoint requires a websocket connection. - /// - [HttpGet] - [ProducesResponseType(typeof(IEnumerable>), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task NLogGet(CancellationToken cancellationToken) { - if (HttpContext == null) { - throw new InvalidOperationException(nameof(HttpContext)); +[Route("Api/NLog")] +public sealed class NLogController : ArchiController { + private static readonly ConcurrentDictionary ActiveLogWebSockets = new(); + + /// + /// Fetches ASF log in realtime. + /// + /// + /// This API endpoint requires a websocket connection. + /// + [HttpGet] + [ProducesResponseType(typeof(IEnumerable>), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task NLogGet(CancellationToken cancellationToken) { + if (HttpContext == null) { + throw new InvalidOperationException(nameof(HttpContext)); + } + + if (!HttpContext.WebSockets.IsWebSocketRequest) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError!, $"{nameof(HttpContext.WebSockets.IsWebSocketRequest)}: {HttpContext.WebSockets.IsWebSocketRequest}"))); + } + + // From now on we can return only EmptyResult as the response stream is already being used by existing websocket connection + + try { + using WebSocket webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); + + SemaphoreSlim sendSemaphore = new(1, 1); + + if (!ActiveLogWebSockets.TryAdd(webSocket, (sendSemaphore, cancellationToken))) { + sendSemaphore.Dispose(); + + return new EmptyResult(); } - if (!HttpContext.WebSockets.IsWebSocketRequest) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError!, $"{nameof(HttpContext.WebSockets.IsWebSocketRequest)}: {HttpContext.WebSockets.IsWebSocketRequest}"))); - } - - // From now on we can return only EmptyResult as the response stream is already being used by existing websocket connection - try { - using WebSocket webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); - - SemaphoreSlim sendSemaphore = new(1, 1); - - if (!ActiveLogWebSockets.TryAdd(webSocket, (sendSemaphore, cancellationToken))) { - sendSemaphore.Dispose(); - - return new EmptyResult(); + // Push initial history if available + if (ArchiKestrel.HistoryTarget != null) { + // ReSharper disable once AccessToDisposedClosure - we're waiting for completion with Task.WhenAll(), we're not going to exit using block + await Task.WhenAll(ArchiKestrel.HistoryTarget.ArchivedMessages.Select(archivedMessage => PostLoggedMessageUpdate(webSocket, archivedMessage, sendSemaphore, cancellationToken))).ConfigureAwait(false); } - try { - // Push initial history if available - if (ArchiKestrel.HistoryTarget != null) { - // ReSharper disable once AccessToDisposedClosure - we're waiting for completion with Task.WhenAll(), we're not going to exit using block - await Task.WhenAll(ArchiKestrel.HistoryTarget.ArchivedMessages.Select(archivedMessage => PostLoggedMessageUpdate(webSocket, archivedMessage, sendSemaphore, cancellationToken))).ConfigureAwait(false); - } + while (webSocket.State == WebSocketState.Open) { + WebSocketReceiveResult result = await webSocket.ReceiveAsync(Array.Empty(), cancellationToken).ConfigureAwait(false); - while (webSocket.State == WebSocketState.Open) { - WebSocketReceiveResult result = await webSocket.ReceiveAsync(Array.Empty(), cancellationToken).ConfigureAwait(false); - - if (result.MessageType != WebSocketMessageType.Close) { - await webSocket.CloseAsync(WebSocketCloseStatus.InvalidMessageType, "You're not supposed to be sending any message but Close!", cancellationToken).ConfigureAwait(false); - - break; - } - - await webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", cancellationToken).ConfigureAwait(false); + if (result.MessageType != WebSocketMessageType.Close) { + await webSocket.CloseAsync(WebSocketCloseStatus.InvalidMessageType, "You're not supposed to be sending any message but Close!", cancellationToken).ConfigureAwait(false); break; } - } finally { - if (ActiveLogWebSockets.TryRemove(webSocket, out (SemaphoreSlim Semaphore, CancellationToken CancellationToken) entry)) { - await entry.Semaphore.WaitAsync(CancellationToken.None).ConfigureAwait(false); // Ensure that our semaphore is truly closed by now - entry.Semaphore.Dispose(); - } + + await webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", cancellationToken).ConfigureAwait(false); + + break; } - } catch (ConnectionAbortedException e) { - ASF.ArchiLogger.LogGenericDebuggingException(e); - } catch (OperationCanceledException e) { - ASF.ArchiLogger.LogGenericDebuggingException(e); - } catch (WebSocketException e) { - ASF.ArchiLogger.LogGenericDebuggingException(e); - } - - return new EmptyResult(); - } - - internal static async void OnNewHistoryEntry(object? sender, HistoryTarget.NewHistoryEntryArgs newHistoryEntryArgs) { - if (newHistoryEntryArgs == null) { - throw new ArgumentNullException(nameof(newHistoryEntryArgs)); - } - - if (ActiveLogWebSockets.IsEmpty) { - return; - } - - string json = JsonConvert.SerializeObject(new GenericResponse(newHistoryEntryArgs.Message)); - - await Task.WhenAll(ActiveLogWebSockets.Where(static kv => kv.Key.State == WebSocketState.Open).Select(kv => PostLoggedJsonUpdate(kv.Key, json, kv.Value.Semaphore, kv.Value.CancellationToken))).ConfigureAwait(false); - } - - private static async Task PostLoggedJsonUpdate(WebSocket webSocket, string json, SemaphoreSlim sendSemaphore, CancellationToken cancellationToken) { - if (webSocket == null) { - throw new ArgumentNullException(nameof(webSocket)); - } - - if (string.IsNullOrEmpty(json)) { - throw new ArgumentNullException(nameof(json)); - } - - if (sendSemaphore == null) { - throw new ArgumentNullException(nameof(sendSemaphore)); - } - - if (cancellationToken.IsCancellationRequested || (webSocket.State != WebSocketState.Open)) { - return; - } - - try { - await sendSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - } catch (OperationCanceledException e) { - ASF.ArchiLogger.LogGenericDebuggingException(e); - - return; - } - - try { -#pragma warning disable CA1508 // False positive, webSocket state could change between our previous check and this one due to semaphore wait - if (cancellationToken.IsCancellationRequested || (webSocket.State != WebSocketState.Open)) { -#pragma warning restore CA1508 // False positive, webSocket state could change between our previous check and this one due to semaphore wait - return; - } - - await webSocket.SendAsync(Encoding.UTF8.GetBytes(json), WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); - } catch (ConnectionAbortedException e) { - ASF.ArchiLogger.LogGenericDebuggingException(e); - } catch (OperationCanceledException e) { - ASF.ArchiLogger.LogGenericDebuggingException(e); - } catch (WebSocketException e) { - ASF.ArchiLogger.LogGenericDebuggingException(e); } finally { - sendSemaphore.Release(); + if (ActiveLogWebSockets.TryRemove(webSocket, out (SemaphoreSlim Semaphore, CancellationToken CancellationToken) entry)) { + await entry.Semaphore.WaitAsync(CancellationToken.None).ConfigureAwait(false); // Ensure that our semaphore is truly closed by now + entry.Semaphore.Dispose(); + } } + } catch (ConnectionAbortedException e) { + ASF.ArchiLogger.LogGenericDebuggingException(e); + } catch (OperationCanceledException e) { + ASF.ArchiLogger.LogGenericDebuggingException(e); + } catch (WebSocketException e) { + ASF.ArchiLogger.LogGenericDebuggingException(e); } - private static async Task PostLoggedMessageUpdate(WebSocket webSocket, string loggedMessage, SemaphoreSlim sendSemaphore, CancellationToken cancellationToken) { - if (webSocket == null) { - throw new ArgumentNullException(nameof(webSocket)); - } + return new EmptyResult(); + } - if (string.IsNullOrEmpty(loggedMessage)) { - throw new ArgumentNullException(nameof(loggedMessage)); - } + internal static async void OnNewHistoryEntry(object? sender, HistoryTarget.NewHistoryEntryArgs newHistoryEntryArgs) { + if (newHistoryEntryArgs == null) { + throw new ArgumentNullException(nameof(newHistoryEntryArgs)); + } - if (sendSemaphore == null) { - throw new ArgumentNullException(nameof(sendSemaphore)); - } + if (ActiveLogWebSockets.IsEmpty) { + return; + } + string json = JsonConvert.SerializeObject(new GenericResponse(newHistoryEntryArgs.Message)); + + await Task.WhenAll(ActiveLogWebSockets.Where(static kv => kv.Key.State == WebSocketState.Open).Select(kv => PostLoggedJsonUpdate(kv.Key, json, kv.Value.Semaphore, kv.Value.CancellationToken))).ConfigureAwait(false); + } + + private static async Task PostLoggedJsonUpdate(WebSocket webSocket, string json, SemaphoreSlim sendSemaphore, CancellationToken cancellationToken) { + if (webSocket == null) { + throw new ArgumentNullException(nameof(webSocket)); + } + + if (string.IsNullOrEmpty(json)) { + throw new ArgumentNullException(nameof(json)); + } + + if (sendSemaphore == null) { + throw new ArgumentNullException(nameof(sendSemaphore)); + } + + if (cancellationToken.IsCancellationRequested || (webSocket.State != WebSocketState.Open)) { + return; + } + + try { + await sendSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + } catch (OperationCanceledException e) { + ASF.ArchiLogger.LogGenericDebuggingException(e); + + return; + } + + try { +#pragma warning disable CA1508 // False positive, webSocket state could change between our previous check and this one due to semaphore wait if (cancellationToken.IsCancellationRequested || (webSocket.State != WebSocketState.Open)) { +#pragma warning restore CA1508 // False positive, webSocket state could change between our previous check and this one due to semaphore wait return; } - string response = JsonConvert.SerializeObject(new GenericResponse(loggedMessage)); - - await PostLoggedJsonUpdate(webSocket, response, sendSemaphore, cancellationToken).ConfigureAwait(false); + await webSocket.SendAsync(Encoding.UTF8.GetBytes(json), WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); + } catch (ConnectionAbortedException e) { + ASF.ArchiLogger.LogGenericDebuggingException(e); + } catch (OperationCanceledException e) { + ASF.ArchiLogger.LogGenericDebuggingException(e); + } catch (WebSocketException e) { + ASF.ArchiLogger.LogGenericDebuggingException(e); + } finally { + sendSemaphore.Release(); } } + + private static async Task PostLoggedMessageUpdate(WebSocket webSocket, string loggedMessage, SemaphoreSlim sendSemaphore, CancellationToken cancellationToken) { + if (webSocket == null) { + throw new ArgumentNullException(nameof(webSocket)); + } + + if (string.IsNullOrEmpty(loggedMessage)) { + throw new ArgumentNullException(nameof(loggedMessage)); + } + + if (sendSemaphore == null) { + throw new ArgumentNullException(nameof(sendSemaphore)); + } + + if (cancellationToken.IsCancellationRequested || (webSocket.State != WebSocketState.Open)) { + return; + } + + string response = JsonConvert.SerializeObject(new GenericResponse(loggedMessage)); + + await PostLoggedJsonUpdate(webSocket, response, sendSemaphore, cancellationToken).ConfigureAwait(false); + } } diff --git a/ArchiSteamFarm/IPC/Controllers/Api/PluginsController.cs b/ArchiSteamFarm/IPC/Controllers/Api/PluginsController.cs index 74c82a5b3..b5bc4a40f 100644 --- a/ArchiSteamFarm/IPC/Controllers/Api/PluginsController.cs +++ b/ArchiSteamFarm/IPC/Controllers/Api/PluginsController.cs @@ -27,15 +27,15 @@ using ArchiSteamFarm.Plugins; using ArchiSteamFarm.Plugins.Interfaces; using Microsoft.AspNetCore.Mvc; -namespace ArchiSteamFarm.IPC.Controllers.Api { - [Route("Api/Plugins")] - public sealed class PluginsController : ArchiController { - [HttpGet] - [ProducesResponseType(typeof(GenericResponse>), (int) HttpStatusCode.OK)] - public ActionResult>> PluginsGet() { - IReadOnlyCollection activePlugins = PluginsCore.ActivePlugins ?? (IReadOnlyCollection) Array.Empty(); +namespace ArchiSteamFarm.IPC.Controllers.Api; - return Ok(new GenericResponse>(activePlugins)); - } +[Route("Api/Plugins")] +public sealed class PluginsController : ArchiController { + [HttpGet] + [ProducesResponseType(typeof(GenericResponse>), (int) HttpStatusCode.OK)] + public ActionResult>> PluginsGet() { + IReadOnlyCollection activePlugins = PluginsCore.ActivePlugins ?? (IReadOnlyCollection) Array.Empty(); + + return Ok(new GenericResponse>(activePlugins)); } } diff --git a/ArchiSteamFarm/IPC/Controllers/Api/StorageController.cs b/ArchiSteamFarm/IPC/Controllers/Api/StorageController.cs index 64802732c..129856851 100644 --- a/ArchiSteamFarm/IPC/Controllers/Api/StorageController.cs +++ b/ArchiSteamFarm/IPC/Controllers/Api/StorageController.cs @@ -26,73 +26,73 @@ using ArchiSteamFarm.IPC.Responses; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; -namespace ArchiSteamFarm.IPC.Controllers.Api { - [Route("Api/Storage/{key:required}")] - public sealed class StorageController : ArchiController { - /// - /// Deletes entry under specified key from ASF's persistent KeyValue JSON storage. - /// - [HttpDelete] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - public ActionResult StorageDelete(string key) { - if (string.IsNullOrEmpty(key)) { - throw new ArgumentNullException(nameof(key)); - } +namespace ArchiSteamFarm.IPC.Controllers.Api; - if (ASF.GlobalDatabase == null) { - throw new InvalidOperationException(nameof(ASF.GlobalDatabase)); - } +[Route("Api/Storage/{key:required}")] +public sealed class StorageController : ArchiController { + /// + /// Deletes entry under specified key from ASF's persistent KeyValue JSON storage. + /// + [HttpDelete] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + public ActionResult StorageDelete(string key) { + if (string.IsNullOrEmpty(key)) { + throw new ArgumentNullException(nameof(key)); + } + if (ASF.GlobalDatabase == null) { + throw new InvalidOperationException(nameof(ASF.GlobalDatabase)); + } + + ASF.GlobalDatabase.DeleteFromJsonStorage(key); + + return Ok(new GenericResponse(true)); + } + + /// + /// Loads entry under specified key from ASF's persistent KeyValue JSON storage. + /// + [HttpGet] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + public ActionResult StorageGet(string key) { + if (string.IsNullOrEmpty(key)) { + throw new ArgumentNullException(nameof(key)); + } + + if (ASF.GlobalDatabase == null) { + throw new InvalidOperationException(nameof(ASF.GlobalDatabase)); + } + + JToken? value = ASF.GlobalDatabase.LoadFromJsonStorage(key); + + return Ok(new GenericResponse(true, value)); + } + + /// + /// Saves entry under specified key in ASF's persistent KeyValue JSON storage. + /// + [Consumes("application/json")] + [HttpPost] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + public ActionResult StoragePost(string key, [FromBody] JToken value) { + if (string.IsNullOrEmpty(key)) { + throw new ArgumentNullException(nameof(key)); + } + + if (value == null) { + throw new ArgumentNullException(nameof(value)); + } + + if (ASF.GlobalDatabase == null) { + throw new InvalidOperationException(nameof(ASF.GlobalDatabase)); + } + + if (value.Type == JTokenType.Null) { ASF.GlobalDatabase.DeleteFromJsonStorage(key); - - return Ok(new GenericResponse(true)); + } else { + ASF.GlobalDatabase.SaveToJsonStorage(key, value); } - /// - /// Loads entry under specified key from ASF's persistent KeyValue JSON storage. - /// - [HttpGet] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - public ActionResult StorageGet(string key) { - if (string.IsNullOrEmpty(key)) { - throw new ArgumentNullException(nameof(key)); - } - - if (ASF.GlobalDatabase == null) { - throw new InvalidOperationException(nameof(ASF.GlobalDatabase)); - } - - JToken? value = ASF.GlobalDatabase.LoadFromJsonStorage(key); - - return Ok(new GenericResponse(true, value)); - } - - /// - /// Saves entry under specified key in ASF's persistent KeyValue JSON storage. - /// - [Consumes("application/json")] - [HttpPost] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - public ActionResult StoragePost(string key, [FromBody] JToken value) { - if (string.IsNullOrEmpty(key)) { - throw new ArgumentNullException(nameof(key)); - } - - if (value == null) { - throw new ArgumentNullException(nameof(value)); - } - - if (ASF.GlobalDatabase == null) { - throw new InvalidOperationException(nameof(ASF.GlobalDatabase)); - } - - if (value.Type == JTokenType.Null) { - ASF.GlobalDatabase.DeleteFromJsonStorage(key); - } else { - ASF.GlobalDatabase.SaveToJsonStorage(key, value); - } - - return Ok(new GenericResponse(true)); - } + return Ok(new GenericResponse(true)); } } diff --git a/ArchiSteamFarm/IPC/Controllers/Api/StructureController.cs b/ArchiSteamFarm/IPC/Controllers/Api/StructureController.cs index ab1c95129..784bf69f5 100644 --- a/ArchiSteamFarm/IPC/Controllers/Api/StructureController.cs +++ b/ArchiSteamFarm/IPC/Controllers/Api/StructureController.cs @@ -26,38 +26,38 @@ using ArchiSteamFarm.IPC.Responses; using ArchiSteamFarm.Localization; using Microsoft.AspNetCore.Mvc; -namespace ArchiSteamFarm.IPC.Controllers.Api { - [Route("Api/Structure")] - public sealed class StructureController : ArchiController { - /// - /// Fetches structure of given type. - /// - /// - /// Structure is defined as a representation of given object in its default state. - /// - [HttpGet("{structure:required}")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public ActionResult StructureGet(string structure) { - if (string.IsNullOrEmpty(structure)) { - throw new ArgumentNullException(nameof(structure)); - } +namespace ArchiSteamFarm.IPC.Controllers.Api; - Type? targetType = WebUtilities.ParseType(structure); - - if (targetType == null) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, structure))); - } - - object? obj; - - try { - obj = Activator.CreateInstance(targetType, true); - } catch (Exception e) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(targetType)) + Environment.NewLine + e)); - } - - return Ok(new GenericResponse(obj)); +[Route("Api/Structure")] +public sealed class StructureController : ArchiController { + /// + /// Fetches structure of given type. + /// + /// + /// Structure is defined as a representation of given object in its default state. + /// + [HttpGet("{structure:required}")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public ActionResult StructureGet(string structure) { + if (string.IsNullOrEmpty(structure)) { + throw new ArgumentNullException(nameof(structure)); } + + Type? targetType = WebUtilities.ParseType(structure); + + if (targetType == null) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, structure))); + } + + object? obj; + + try { + obj = Activator.CreateInstance(targetType, true); + } catch (Exception e) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(targetType)) + Environment.NewLine + e)); + } + + return Ok(new GenericResponse(obj)); } } diff --git a/ArchiSteamFarm/IPC/Controllers/Api/TwoFactorAuthenticationController.cs b/ArchiSteamFarm/IPC/Controllers/Api/TwoFactorAuthenticationController.cs index 2fc7b2f2d..bd83d5f4a 100644 --- a/ArchiSteamFarm/IPC/Controllers/Api/TwoFactorAuthenticationController.cs +++ b/ArchiSteamFarm/IPC/Controllers/Api/TwoFactorAuthenticationController.cs @@ -33,137 +33,137 @@ using ArchiSteamFarm.Steam; using ArchiSteamFarm.Steam.Security; using Microsoft.AspNetCore.Mvc; -namespace ArchiSteamFarm.IPC.Controllers.Api { - [Route("Api/Bot/{botNames:required}/TwoFactorAuthentication")] - public sealed class TwoFactorAuthenticationController : ArchiController { - /// - /// Handles 2FA confirmations of given bots, requires ASF 2FA module to be active on them. - /// - [Consumes("application/json")] - [HttpPost("Confirmations")] - [ProducesResponseType(typeof(GenericResponse>>>), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> ConfirmationsPost(string botNames, [FromBody] TwoFactorAuthenticationConfirmationsRequest request) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } +namespace ArchiSteamFarm.IPC.Controllers.Api; - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (request.AcceptedType.HasValue && ((request.AcceptedType.Value == Confirmation.EType.Unknown) || !Enum.IsDefined(typeof(Confirmation.EType), request.AcceptedType.Value))) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(request.AcceptedType)))); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return BadRequest(new GenericResponse>>>(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); - } - - IList<(bool Success, IReadOnlyCollection? HandledConfirmations, string Message)> results = await Utilities.InParallel(bots.Select(bot => bot.Actions.HandleTwoFactorAuthenticationConfirmations(request.Accept, request.AcceptedType, request.AcceptedCreatorIDs.Count > 0 ? request.AcceptedCreatorIDs : null, request.WaitIfNeeded))).ConfigureAwait(false); - - Dictionary>> result = new(bots.Count, Bot.BotsComparer); - - foreach (Bot bot in bots) { - (bool success, IReadOnlyCollection? handledConfirmations, string message) = results[result.Count]; - result[bot.BotName] = new GenericResponse>(success, message, handledConfirmations); - } - - return Ok(new GenericResponse>>>(result)); +[Route("Api/Bot/{botNames:required}/TwoFactorAuthentication")] +public sealed class TwoFactorAuthenticationController : ArchiController { + /// + /// Handles 2FA confirmations of given bots, requires ASF 2FA module to be active on them. + /// + [Consumes("application/json")] + [HttpPost("Confirmations")] + [ProducesResponseType(typeof(GenericResponse>>>), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> ConfirmationsPost(string botNames, [FromBody] TwoFactorAuthenticationConfirmationsRequest request) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); } - /// - /// Deletes the MobileAuthenticator of given bots if an ASF 2FA module is active on them. - /// - [HttpDelete] - [ProducesResponseType(typeof(GenericResponse>>), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> Delete(string botNames) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); - } - - IList<(bool Success, string? Message)> results = await Utilities.InParallel(bots.Select(static bot => Task.Run(bot.RemoveAuthenticator))).ConfigureAwait(false); - - Dictionary> result = new(bots.Count, Bot.BotsComparer); - - foreach (Bot bot in bots) { - (bool success, string? message) = results[result.Count]; - result[bot.BotName] = new GenericResponse(success, message); - } - - return Ok(new GenericResponse>>(result)); + if (request == null) { + throw new ArgumentNullException(nameof(request)); } - /// - /// Imports a MobileAuthenticator into the ASF 2FA module of a given bot. - /// - [Consumes("application/json")] - [HttpPost] - [ProducesResponseType(typeof(GenericResponse>), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> Post(string botNames, [FromBody] MobileAuthenticator authenticator) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (authenticator == null) { - throw new ArgumentNullException(nameof(authenticator)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return BadRequest(new GenericResponse>>(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.TryImportAuthenticator(authenticator)))).ConfigureAwait(false); - - Dictionary result = new(bots.Count, Bot.BotsComparer); - - foreach (Bot bot in bots) { - bool success = results[result.Count]; - result[bot.BotName] = new GenericResponse(success); - } - - return Ok(new GenericResponse>(result)); + if (request.AcceptedType.HasValue && ((request.AcceptedType.Value == Confirmation.EType.Unknown) || !Enum.IsDefined(typeof(Confirmation.EType), request.AcceptedType.Value))) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(request.AcceptedType)))); } - /// - /// Fetches 2FA tokens of given bots, requires ASF 2FA module to be active on them. - /// - [HttpGet("Token")] - [ProducesResponseType(typeof(GenericResponse>>), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public async Task> TokenGet(string botNames) { - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } + HashSet? bots = Bot.GetBots(botNames); - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return BadRequest(new GenericResponse>>(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); - } - - IList<(bool Success, string? Token, string Message)> results = await Utilities.InParallel(bots.Select(static bot => bot.Actions.GenerateTwoFactorAuthenticationToken())).ConfigureAwait(false); - - Dictionary> result = new(bots.Count, Bot.BotsComparer); - - foreach (Bot bot in bots) { - (bool success, string? token, string message) = results[result.Count]; - result[bot.BotName] = new GenericResponse(success, message, token); - } - - return Ok(new GenericResponse>>(result)); + if ((bots == null) || (bots.Count == 0)) { + return BadRequest(new GenericResponse>>>(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); } + + IList<(bool Success, IReadOnlyCollection? HandledConfirmations, string Message)> results = await Utilities.InParallel(bots.Select(bot => bot.Actions.HandleTwoFactorAuthenticationConfirmations(request.Accept, request.AcceptedType, request.AcceptedCreatorIDs.Count > 0 ? request.AcceptedCreatorIDs : null, request.WaitIfNeeded))).ConfigureAwait(false); + + Dictionary>> result = new(bots.Count, Bot.BotsComparer); + + foreach (Bot bot in bots) { + (bool success, IReadOnlyCollection? handledConfirmations, string message) = results[result.Count]; + result[bot.BotName] = new GenericResponse>(success, message, handledConfirmations); + } + + return Ok(new GenericResponse>>>(result)); + } + + /// + /// Deletes the MobileAuthenticator of given bots if an ASF 2FA module is active on them. + /// + [HttpDelete] + [ProducesResponseType(typeof(GenericResponse>>), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> Delete(string botNames) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); + } + + IList<(bool Success, string? Message)> results = await Utilities.InParallel(bots.Select(static bot => Task.Run(bot.RemoveAuthenticator))).ConfigureAwait(false); + + Dictionary> result = new(bots.Count, Bot.BotsComparer); + + foreach (Bot bot in bots) { + (bool success, string? message) = results[result.Count]; + result[bot.BotName] = new GenericResponse(success, message); + } + + return Ok(new GenericResponse>>(result)); + } + + /// + /// Imports a MobileAuthenticator into the ASF 2FA module of a given bot. + /// + [Consumes("application/json")] + [HttpPost] + [ProducesResponseType(typeof(GenericResponse>), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> Post(string botNames, [FromBody] MobileAuthenticator authenticator) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (authenticator == null) { + throw new ArgumentNullException(nameof(authenticator)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return BadRequest(new GenericResponse>>(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.TryImportAuthenticator(authenticator)))).ConfigureAwait(false); + + Dictionary result = new(bots.Count, Bot.BotsComparer); + + foreach (Bot bot in bots) { + bool success = results[result.Count]; + result[bot.BotName] = new GenericResponse(success); + } + + return Ok(new GenericResponse>(result)); + } + + /// + /// Fetches 2FA tokens of given bots, requires ASF 2FA module to be active on them. + /// + [HttpGet("Token")] + [ProducesResponseType(typeof(GenericResponse>>), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public async Task> TokenGet(string botNames) { + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return BadRequest(new GenericResponse>>(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames))); + } + + IList<(bool Success, string? Token, string Message)> results = await Utilities.InParallel(bots.Select(static bot => bot.Actions.GenerateTwoFactorAuthenticationToken())).ConfigureAwait(false); + + Dictionary> result = new(bots.Count, Bot.BotsComparer); + + foreach (Bot bot in bots) { + (bool success, string? token, string message) = results[result.Count]; + result[bot.BotName] = new GenericResponse(success, message, token); + } + + return Ok(new GenericResponse>>(result)); } } diff --git a/ArchiSteamFarm/IPC/Controllers/Api/TypeController.cs b/ArchiSteamFarm/IPC/Controllers/Api/TypeController.cs index dd4775685..9e5b94be9 100644 --- a/ArchiSteamFarm/IPC/Controllers/Api/TypeController.cs +++ b/ArchiSteamFarm/IPC/Controllers/Api/TypeController.cs @@ -31,92 +31,92 @@ using ArchiSteamFarm.Localization; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Controllers.Api { - [Route("Api/Type")] - public sealed class TypeController : ArchiController { - /// - /// Fetches type info of given type. - /// - /// - /// Type info is defined as a representation of given object with its fields and properties being assigned to a string value that defines their type. - /// - [HttpGet("{type:required}")] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] - [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] - public ActionResult TypeGet(string type) { - if (string.IsNullOrEmpty(type)) { - throw new ArgumentNullException(nameof(type)); - } +namespace ArchiSteamFarm.IPC.Controllers.Api; - Type? targetType = WebUtilities.ParseType(type); - - if (targetType == null) { - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, type))); - } - - string? baseType = targetType.BaseType?.GetUnifiedName(); - HashSet customAttributes = targetType.CustomAttributes.Select(static attribute => attribute.AttributeType.GetUnifiedName()).Where(static customAttribute => !string.IsNullOrEmpty(customAttribute)).ToHashSet(StringComparer.Ordinal)!; - string? underlyingType = null; - - Dictionary body = new(StringComparer.Ordinal); - - if (targetType.IsClass) { - foreach (FieldInfo field in targetType.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Where(static field => !field.IsPrivate)) { - JsonPropertyAttribute? jsonProperty = field.GetCustomAttribute(); - - if (jsonProperty != null) { - string? unifiedName = field.FieldType.GetUnifiedName(); - - if (!string.IsNullOrEmpty(unifiedName)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - body[jsonProperty.PropertyName ?? field.Name] = unifiedName!; - } - } - } - - foreach (PropertyInfo property in targetType.GetProperties(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Where(static property => property.CanRead && (property.GetMethod?.IsPrivate == false))) { - JsonPropertyAttribute? jsonProperty = property.GetCustomAttribute(); - - if (jsonProperty != null) { - string? unifiedName = property.PropertyType.GetUnifiedName(); - - if (!string.IsNullOrEmpty(unifiedName)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - body[jsonProperty.PropertyName ?? property.Name] = unifiedName!; - } - } - } - } else if (targetType.IsEnum) { - Type enumType = Enum.GetUnderlyingType(targetType); - underlyingType = enumType.GetUnifiedName(); - - foreach (object? value in Enum.GetValues(targetType)) { - string? valueText = value?.ToString(); - - if (string.IsNullOrEmpty(valueText)) { - ASF.ArchiLogger.LogNullError(nameof(valueText)); - - return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(valueText)))); - } - - string? valueObjText = Convert.ChangeType(value, enumType, CultureInfo.InvariantCulture)?.ToString(); - - if (string.IsNullOrEmpty(valueObjText)) { - continue; - } - - // ReSharper disable RedundantSuppressNullableWarningExpression - required for .NET Framework - body[valueText!] = valueObjText!; - - // ReSharper restore RedundantSuppressNullableWarningExpression - required for .NET Framework - } - } - - TypeProperties properties = new(baseType, customAttributes.Count > 0 ? customAttributes : null, underlyingType); - - TypeResponse response = new(body, properties); - - return Ok(new GenericResponse(response)); +[Route("Api/Type")] +public sealed class TypeController : ArchiController { + /// + /// Fetches type info of given type. + /// + /// + /// Type info is defined as a representation of given object with its fields and properties being assigned to a string value that defines their type. + /// + [HttpGet("{type:required}")] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)] + [ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)] + public ActionResult TypeGet(string type) { + if (string.IsNullOrEmpty(type)) { + throw new ArgumentNullException(nameof(type)); } + + Type? targetType = WebUtilities.ParseType(type); + + if (targetType == null) { + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, type))); + } + + string? baseType = targetType.BaseType?.GetUnifiedName(); + HashSet customAttributes = targetType.CustomAttributes.Select(static attribute => attribute.AttributeType.GetUnifiedName()).Where(static customAttribute => !string.IsNullOrEmpty(customAttribute)).ToHashSet(StringComparer.Ordinal)!; + string? underlyingType = null; + + Dictionary body = new(StringComparer.Ordinal); + + if (targetType.IsClass) { + foreach (FieldInfo field in targetType.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Where(static field => !field.IsPrivate)) { + JsonPropertyAttribute? jsonProperty = field.GetCustomAttribute(); + + if (jsonProperty != null) { + string? unifiedName = field.FieldType.GetUnifiedName(); + + if (!string.IsNullOrEmpty(unifiedName)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + body[jsonProperty.PropertyName ?? field.Name] = unifiedName!; + } + } + } + + foreach (PropertyInfo property in targetType.GetProperties(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Where(static property => property.CanRead && (property.GetMethod?.IsPrivate == false))) { + JsonPropertyAttribute? jsonProperty = property.GetCustomAttribute(); + + if (jsonProperty != null) { + string? unifiedName = property.PropertyType.GetUnifiedName(); + + if (!string.IsNullOrEmpty(unifiedName)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + body[jsonProperty.PropertyName ?? property.Name] = unifiedName!; + } + } + } + } else if (targetType.IsEnum) { + Type enumType = Enum.GetUnderlyingType(targetType); + underlyingType = enumType.GetUnifiedName(); + + foreach (object? value in Enum.GetValues(targetType)) { + string? valueText = value?.ToString(); + + if (string.IsNullOrEmpty(valueText)) { + ASF.ArchiLogger.LogNullError(nameof(valueText)); + + return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(valueText)))); + } + + string? valueObjText = Convert.ChangeType(value, enumType, CultureInfo.InvariantCulture)?.ToString(); + + if (string.IsNullOrEmpty(valueObjText)) { + continue; + } + + // ReSharper disable RedundantSuppressNullableWarningExpression - required for .NET Framework + body[valueText!] = valueObjText!; + + // ReSharper restore RedundantSuppressNullableWarningExpression - required for .NET Framework + } + } + + TypeProperties properties = new(baseType, customAttributes.Count > 0 ? customAttributes : null, underlyingType); + + TypeResponse response = new(body, properties); + + return Ok(new GenericResponse(response)); } } diff --git a/ArchiSteamFarm/IPC/Integration/ApiAuthenticationMiddleware.cs b/ArchiSteamFarm/IPC/Integration/ApiAuthenticationMiddleware.cs index 73ad6f917..94fb4f590 100644 --- a/ArchiSteamFarm/IPC/Integration/ApiAuthenticationMiddleware.cs +++ b/ArchiSteamFarm/IPC/Integration/ApiAuthenticationMiddleware.cs @@ -40,146 +40,146 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; -namespace ArchiSteamFarm.IPC.Integration { - [SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] - internal sealed class ApiAuthenticationMiddleware { - internal const string HeadersField = "Authentication"; +namespace ArchiSteamFarm.IPC.Integration; - private const byte FailedAuthorizationsCooldownInHours = 1; - private const byte MaxFailedAuthorizationAttempts = 5; +[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] +internal sealed class ApiAuthenticationMiddleware { + internal const string HeadersField = "Authentication"; - private static readonly ConcurrentDictionary AuthorizationTasks = new(); - private static readonly Timer ClearFailedAuthorizationsTimer = new(ClearFailedAuthorizations); - private static readonly ConcurrentDictionary FailedAuthorizations = new(); + private const byte FailedAuthorizationsCooldownInHours = 1; + private const byte MaxFailedAuthorizationAttempts = 5; - private readonly ForwardedHeadersOptions ForwardedHeadersOptions; - private readonly RequestDelegate Next; + private static readonly ConcurrentDictionary AuthorizationTasks = new(); + private static readonly Timer ClearFailedAuthorizationsTimer = new(ClearFailedAuthorizations); + private static readonly ConcurrentDictionary FailedAuthorizations = new(); - public ApiAuthenticationMiddleware(RequestDelegate next, IOptions forwardedHeadersOptions) { - Next = next ?? throw new ArgumentNullException(nameof(next)); + private readonly ForwardedHeadersOptions ForwardedHeadersOptions; + private readonly RequestDelegate Next; - if (forwardedHeadersOptions == null) { - throw new ArgumentNullException(nameof(forwardedHeadersOptions)); - } + public ApiAuthenticationMiddleware(RequestDelegate next, IOptions forwardedHeadersOptions) { + Next = next ?? throw new ArgumentNullException(nameof(next)); - ForwardedHeadersOptions = forwardedHeadersOptions.Value ?? throw new InvalidOperationException(nameof(forwardedHeadersOptions)); - - lock (FailedAuthorizations) { - ClearFailedAuthorizationsTimer.Change(TimeSpan.FromHours(FailedAuthorizationsCooldownInHours), TimeSpan.FromHours(FailedAuthorizationsCooldownInHours)); - } + if (forwardedHeadersOptions == null) { + throw new ArgumentNullException(nameof(forwardedHeadersOptions)); } - [UsedImplicitly] - public async Task InvokeAsync(HttpContext context, IOptions jsonOptions) { - if (context == null) { - throw new ArgumentNullException(nameof(context)); - } + ForwardedHeadersOptions = forwardedHeadersOptions.Value ?? throw new InvalidOperationException(nameof(forwardedHeadersOptions)); - if (jsonOptions == null) { - throw new ArgumentNullException(nameof(jsonOptions)); - } + lock (FailedAuthorizations) { + ClearFailedAuthorizationsTimer.Change(TimeSpan.FromHours(FailedAuthorizationsCooldownInHours), TimeSpan.FromHours(FailedAuthorizationsCooldownInHours)); + } + } - (HttpStatusCode statusCode, bool permanent) = await GetAuthenticationStatus(context).ConfigureAwait(false); - - if (statusCode == HttpStatusCode.OK) { - await Next(context).ConfigureAwait(false); - - return; - } - - context.Response.StatusCode = (int) statusCode; - - StatusCodeResponse statusCodeResponse = new(statusCode, permanent); - - await context.Response.WriteJsonAsync(new GenericResponse(false, statusCodeResponse), jsonOptions.Value.SerializerSettings).ConfigureAwait(false); + [UsedImplicitly] + public async Task InvokeAsync(HttpContext context, IOptions jsonOptions) { + if (context == null) { + throw new ArgumentNullException(nameof(context)); } - private static void ClearFailedAuthorizations(object? state = null) => FailedAuthorizations.Clear(); + if (jsonOptions == null) { + throw new ArgumentNullException(nameof(jsonOptions)); + } - private async Task<(HttpStatusCode StatusCode, bool Permanent)> GetAuthenticationStatus(HttpContext context) { - if (context == null) { - throw new ArgumentNullException(nameof(context)); + (HttpStatusCode statusCode, bool permanent) = await GetAuthenticationStatus(context).ConfigureAwait(false); + + if (statusCode == HttpStatusCode.OK) { + await Next(context).ConfigureAwait(false); + + return; + } + + context.Response.StatusCode = (int) statusCode; + + StatusCodeResponse statusCodeResponse = new(statusCode, permanent); + + await context.Response.WriteJsonAsync(new GenericResponse(false, statusCodeResponse), jsonOptions.Value.SerializerSettings).ConfigureAwait(false); + } + + private static void ClearFailedAuthorizations(object? state = null) => FailedAuthorizations.Clear(); + + private async Task<(HttpStatusCode StatusCode, bool Permanent)> GetAuthenticationStatus(HttpContext context) { + if (context == null) { + throw new ArgumentNullException(nameof(context)); + } + + IPAddress? clientIP = context.Connection.RemoteIpAddress; + + if (clientIP == null) { + throw new InvalidOperationException(nameof(clientIP)); + } + + if (FailedAuthorizations.TryGetValue(clientIP, out byte attempts) && (attempts >= MaxFailedAuthorizationAttempts)) { + return (HttpStatusCode.Forbidden, false); + } + + string? ipcPassword = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPassword : GlobalConfig.DefaultIPCPassword; + + if (string.IsNullOrEmpty(ipcPassword)) { + if (IPAddress.IsLoopback(clientIP)) { + return (HttpStatusCode.OK, true); } - IPAddress? clientIP = context.Connection.RemoteIpAddress; - - if (clientIP == null) { - throw new InvalidOperationException(nameof(clientIP)); + if (ForwardedHeadersOptions.KnownNetworks.Count == 0) { + return (HttpStatusCode.Forbidden, true); } - if (FailedAuthorizations.TryGetValue(clientIP, out byte attempts) && (attempts >= MaxFailedAuthorizationAttempts)) { - return (HttpStatusCode.Forbidden, false); - } + if (clientIP.IsIPv4MappedToIPv6) { + IPAddress mappedClientIP = clientIP.MapToIPv4(); - string? ipcPassword = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPassword : GlobalConfig.DefaultIPCPassword; - - if (string.IsNullOrEmpty(ipcPassword)) { - if (IPAddress.IsLoopback(clientIP)) { + if (ForwardedHeadersOptions.KnownNetworks.Any(network => network.Contains(mappedClientIP))) { return (HttpStatusCode.OK, true); } - - if (ForwardedHeadersOptions.KnownNetworks.Count == 0) { - return (HttpStatusCode.Forbidden, true); - } - - if (clientIP.IsIPv4MappedToIPv6) { - IPAddress mappedClientIP = clientIP.MapToIPv4(); - - if (ForwardedHeadersOptions.KnownNetworks.Any(network => network.Contains(mappedClientIP))) { - return (HttpStatusCode.OK, true); - } - } - - return (ForwardedHeadersOptions.KnownNetworks.Any(network => network.Contains(clientIP)) ? HttpStatusCode.OK : HttpStatusCode.Forbidden, true); } - if (!context.Request.Headers.TryGetValue(HeadersField, out StringValues passwords) && !context.Request.Query.TryGetValue("password", out passwords)) { - return (HttpStatusCode.Unauthorized, true); + return (ForwardedHeadersOptions.KnownNetworks.Any(network => network.Contains(clientIP)) ? HttpStatusCode.OK : HttpStatusCode.Forbidden, true); + } + + if (!context.Request.Headers.TryGetValue(HeadersField, out StringValues passwords) && !context.Request.Query.TryGetValue("password", out passwords)) { + return (HttpStatusCode.Unauthorized, true); + } + + string? inputPassword = passwords.FirstOrDefault(static password => !string.IsNullOrEmpty(password)); + + if (string.IsNullOrEmpty(inputPassword)) { + return (HttpStatusCode.Unauthorized, true); + } + + ArchiCryptoHelper.EHashingMethod ipcPasswordFormat = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPasswordFormat : GlobalConfig.DefaultIPCPasswordFormat; + + string inputHash = ArchiCryptoHelper.Hash(ipcPasswordFormat, inputPassword); + + bool authorized = ipcPassword == inputHash; + + while (true) { + if (AuthorizationTasks.TryGetValue(clientIP, out Task? task)) { + await task.ConfigureAwait(false); + + continue; } - string? inputPassword = passwords.FirstOrDefault(static password => !string.IsNullOrEmpty(password)); + TaskCompletionSource taskCompletionSource = new(); - if (string.IsNullOrEmpty(inputPassword)) { - return (HttpStatusCode.Unauthorized, true); + if (!AuthorizationTasks.TryAdd(clientIP, taskCompletionSource.Task)) { + continue; } - ArchiCryptoHelper.EHashingMethod ipcPasswordFormat = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPasswordFormat : GlobalConfig.DefaultIPCPasswordFormat; + try { + bool hasFailedAuthorizations = FailedAuthorizations.TryGetValue(clientIP, out attempts); - string inputHash = ArchiCryptoHelper.Hash(ipcPasswordFormat, inputPassword); - - bool authorized = ipcPassword == inputHash; - - while (true) { - if (AuthorizationTasks.TryGetValue(clientIP, out Task? task)) { - await task.ConfigureAwait(false); - - continue; + if (hasFailedAuthorizations && (attempts >= MaxFailedAuthorizationAttempts)) { + return (HttpStatusCode.Forbidden, false); } - TaskCompletionSource taskCompletionSource = new(); - - if (!AuthorizationTasks.TryAdd(clientIP, taskCompletionSource.Task)) { - continue; + if (!authorized) { + FailedAuthorizations[clientIP] = hasFailedAuthorizations ? ++attempts : (byte) 1; } + } finally { + AuthorizationTasks.TryRemove(clientIP, out _); - try { - bool hasFailedAuthorizations = FailedAuthorizations.TryGetValue(clientIP, out attempts); - - if (hasFailedAuthorizations && (attempts >= MaxFailedAuthorizationAttempts)) { - return (HttpStatusCode.Forbidden, false); - } - - if (!authorized) { - FailedAuthorizations[clientIP] = hasFailedAuthorizations ? ++attempts : (byte) 1; - } - } finally { - AuthorizationTasks.TryRemove(clientIP, out _); - - taskCompletionSource.SetResult(); - } - - return (authorized ? HttpStatusCode.OK : HttpStatusCode.Unauthorized, true); + taskCompletionSource.SetResult(); } + + return (authorized ? HttpStatusCode.OK : HttpStatusCode.Unauthorized, true); } } } diff --git a/ArchiSteamFarm/IPC/Integration/CustomAttributesSchemaFilter.cs b/ArchiSteamFarm/IPC/Integration/CustomAttributesSchemaFilter.cs index bec89c600..443d9f194 100644 --- a/ArchiSteamFarm/IPC/Integration/CustomAttributesSchemaFilter.cs +++ b/ArchiSteamFarm/IPC/Integration/CustomAttributesSchemaFilter.cs @@ -25,31 +25,31 @@ using JetBrains.Annotations; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; -namespace ArchiSteamFarm.IPC.Integration { - [UsedImplicitly] - internal sealed class CustomAttributesSchemaFilter : ISchemaFilter { - public void Apply(OpenApiSchema schema, SchemaFilterContext context) { - if (schema == null) { - throw new ArgumentNullException(nameof(schema)); - } +namespace ArchiSteamFarm.IPC.Integration; - if (context == null) { - throw new ArgumentNullException(nameof(context)); - } +[UsedImplicitly] +internal sealed class CustomAttributesSchemaFilter : ISchemaFilter { + public void Apply(OpenApiSchema schema, SchemaFilterContext context) { + if (schema == null) { + throw new ArgumentNullException(nameof(schema)); + } - ICustomAttributeProvider attributesProvider; + if (context == null) { + throw new ArgumentNullException(nameof(context)); + } - if (context.MemberInfo != null) { - attributesProvider = context.MemberInfo; - } else if (context.ParameterInfo != null) { - attributesProvider = context.ParameterInfo; - } else { - return; - } + ICustomAttributeProvider attributesProvider; - foreach (CustomSwaggerAttribute customSwaggerAttribute in attributesProvider.GetCustomAttributes(typeof(CustomSwaggerAttribute), true)) { - customSwaggerAttribute.Apply(schema); - } + if (context.MemberInfo != null) { + attributesProvider = context.MemberInfo; + } else if (context.ParameterInfo != null) { + attributesProvider = context.ParameterInfo; + } else { + return; + } + + foreach (CustomSwaggerAttribute customSwaggerAttribute in attributesProvider.GetCustomAttributes(typeof(CustomSwaggerAttribute), true)) { + customSwaggerAttribute.Apply(schema); } } } diff --git a/ArchiSteamFarm/IPC/Integration/CustomSwaggerAttribute.cs b/ArchiSteamFarm/IPC/Integration/CustomSwaggerAttribute.cs index 3a7d1a982..d1505e789 100644 --- a/ArchiSteamFarm/IPC/Integration/CustomSwaggerAttribute.cs +++ b/ArchiSteamFarm/IPC/Integration/CustomSwaggerAttribute.cs @@ -23,10 +23,10 @@ using System; using JetBrains.Annotations; using Microsoft.OpenApi.Models; -namespace ArchiSteamFarm.IPC.Integration { - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property)] - [PublicAPI] - public abstract class CustomSwaggerAttribute : Attribute { - public abstract void Apply(OpenApiSchema schema); - } +namespace ArchiSteamFarm.IPC.Integration; + +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property)] +[PublicAPI] +public abstract class CustomSwaggerAttribute : Attribute { + public abstract void Apply(OpenApiSchema schema); } diff --git a/ArchiSteamFarm/IPC/Integration/EnumSchemaFilter.cs b/ArchiSteamFarm/IPC/Integration/EnumSchemaFilter.cs index 2b5f948ad..03f1784f9 100644 --- a/ArchiSteamFarm/IPC/Integration/EnumSchemaFilter.cs +++ b/ArchiSteamFarm/IPC/Integration/EnumSchemaFilter.cs @@ -27,86 +27,86 @@ using Microsoft.OpenApi.Extensions; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; -namespace ArchiSteamFarm.IPC.Integration { - [UsedImplicitly] - internal sealed class EnumSchemaFilter : ISchemaFilter { - public void Apply(OpenApiSchema schema, SchemaFilterContext context) { - if (schema == null) { - throw new ArgumentNullException(nameof(schema)); - } +namespace ArchiSteamFarm.IPC.Integration; - if (context == null) { - throw new ArgumentNullException(nameof(context)); - } - - if (context.Type is not { IsEnum: true }) { - return; - } - - if (context.Type.IsDefined(typeof(FlagsAttribute), false)) { - schema.Format = "flags"; - } - - OpenApiObject definition = new(); - - foreach (object? enumValue in context.Type.GetEnumValues()) { - if (enumValue == null) { - throw new InvalidOperationException(nameof(enumValue)); - } - - string? enumName = Enum.GetName(context.Type, enumValue); - - if (string.IsNullOrEmpty(enumName)) { - // Fallback - enumName = enumValue.ToString(); - - if (string.IsNullOrEmpty(enumName)) { - throw new InvalidOperationException(nameof(enumName)); - } - } - - if (definition.ContainsKey(enumName)) { - // This is possible if we have multiple names for the same enum value, we'll ignore additional ones - continue; - } - - IOpenApiPrimitive enumObject; - - if (TryCast(enumValue, out int intValue)) { - enumObject = new OpenApiInteger(intValue); - } else if (TryCast(enumValue, out long longValue)) { - enumObject = new OpenApiLong(longValue); - } else if (TryCast(enumValue, out ulong ulongValue)) { - // OpenApi spec doesn't support ulongs as of now - enumObject = new OpenApiString(ulongValue.ToString(CultureInfo.InvariantCulture)); - } else { - throw new InvalidOperationException(nameof(enumValue)); - } - - definition.Add(enumName, enumObject); - } - - schema.AddExtension("x-definition", definition); +[UsedImplicitly] +internal sealed class EnumSchemaFilter : ISchemaFilter { + public void Apply(OpenApiSchema schema, SchemaFilterContext context) { + if (schema == null) { + throw new ArgumentNullException(nameof(schema)); } - private static bool TryCast(object value, out T typedValue) where T : struct { - if (value == null) { - throw new ArgumentNullException(nameof(value)); + if (context == null) { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Type is not { IsEnum: true }) { + return; + } + + if (context.Type.IsDefined(typeof(FlagsAttribute), false)) { + schema.Format = "flags"; + } + + OpenApiObject definition = new(); + + foreach (object? enumValue in context.Type.GetEnumValues()) { + if (enumValue == null) { + throw new InvalidOperationException(nameof(enumValue)); } - try { - typedValue = (T) Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture); + string? enumName = Enum.GetName(context.Type, enumValue); - return true; - } catch (InvalidCastException) { - typedValue = default(T); + if (string.IsNullOrEmpty(enumName)) { + // Fallback + enumName = enumValue.ToString(); - return false; - } catch (OverflowException) { - typedValue = default(T); - - return false; + if (string.IsNullOrEmpty(enumName)) { + throw new InvalidOperationException(nameof(enumName)); + } } + + if (definition.ContainsKey(enumName)) { + // This is possible if we have multiple names for the same enum value, we'll ignore additional ones + continue; + } + + IOpenApiPrimitive enumObject; + + if (TryCast(enumValue, out int intValue)) { + enumObject = new OpenApiInteger(intValue); + } else if (TryCast(enumValue, out long longValue)) { + enumObject = new OpenApiLong(longValue); + } else if (TryCast(enumValue, out ulong ulongValue)) { + // OpenApi spec doesn't support ulongs as of now + enumObject = new OpenApiString(ulongValue.ToString(CultureInfo.InvariantCulture)); + } else { + throw new InvalidOperationException(nameof(enumValue)); + } + + definition.Add(enumName, enumObject); + } + + schema.AddExtension("x-definition", definition); + } + + private static bool TryCast(object value, out T typedValue) where T : struct { + if (value == null) { + throw new ArgumentNullException(nameof(value)); + } + + try { + typedValue = (T) Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture); + + return true; + } catch (InvalidCastException) { + typedValue = default(T); + + return false; + } catch (OverflowException) { + typedValue = default(T); + + return false; } } } diff --git a/ArchiSteamFarm/IPC/Integration/LocalizationMiddleware.cs b/ArchiSteamFarm/IPC/Integration/LocalizationMiddleware.cs index d163a165b..48da4d927 100644 --- a/ArchiSteamFarm/IPC/Integration/LocalizationMiddleware.cs +++ b/ArchiSteamFarm/IPC/Integration/LocalizationMiddleware.cs @@ -30,57 +30,57 @@ using Microsoft.AspNetCore.Http.Headers; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; -namespace ArchiSteamFarm.IPC.Integration { - [SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] - internal sealed class LocalizationMiddleware { - private static readonly ImmutableDictionary CultureConversions = new Dictionary(2, StringComparer.OrdinalIgnoreCase) { - { "lol-US", SharedInfo.LolcatCultureName }, - { "sr-CS", "sr-Latn" } - }.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase); +namespace ArchiSteamFarm.IPC.Integration; - private readonly RequestDelegate Next; +[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] +internal sealed class LocalizationMiddleware { + private static readonly ImmutableDictionary CultureConversions = new Dictionary(2, StringComparer.OrdinalIgnoreCase) { + { "lol-US", SharedInfo.LolcatCultureName }, + { "sr-CS", "sr-Latn" } + }.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase); - public LocalizationMiddleware(RequestDelegate next) => Next = next ?? throw new ArgumentNullException(nameof(next)); + private readonly RequestDelegate Next; - [UsedImplicitly] - public async Task InvokeAsync(HttpContext context) { - if (context == null) { - throw new ArgumentNullException(nameof(context)); - } + public LocalizationMiddleware(RequestDelegate next) => Next = next ?? throw new ArgumentNullException(nameof(next)); - RequestHeaders headers = context.Request.GetTypedHeaders(); - - IList? acceptLanguageHeader = headers.AcceptLanguage; - - if ((acceptLanguageHeader == null) || (acceptLanguageHeader.Count == 0)) { - await Next(context).ConfigureAwait(false); - - return; - } - - bool valuesChanged = false; - - for (int i = 0; i < acceptLanguageHeader.Count; i++) { - StringSegment language = acceptLanguageHeader[i].Value; - - if (!language.HasValue || string.IsNullOrEmpty(language.Value)) { - continue; - } - - if (!CultureConversions.TryGetValue(language.Value, out string? replacement) || string.IsNullOrEmpty(replacement)) { - continue; - } - - acceptLanguageHeader[i] = StringWithQualityHeaderValue.Parse(replacement); - valuesChanged = true; - } - - if (valuesChanged) { - // The getter returns a temporary collection; To make sure our changes are persisted, we need to assign it back - headers.AcceptLanguage = acceptLanguageHeader; - } - - await Next(context).ConfigureAwait(false); + [UsedImplicitly] + public async Task InvokeAsync(HttpContext context) { + if (context == null) { + throw new ArgumentNullException(nameof(context)); } + + RequestHeaders headers = context.Request.GetTypedHeaders(); + + IList? acceptLanguageHeader = headers.AcceptLanguage; + + if ((acceptLanguageHeader == null) || (acceptLanguageHeader.Count == 0)) { + await Next(context).ConfigureAwait(false); + + return; + } + + bool valuesChanged = false; + + for (int i = 0; i < acceptLanguageHeader.Count; i++) { + StringSegment language = acceptLanguageHeader[i].Value; + + if (!language.HasValue || string.IsNullOrEmpty(language.Value)) { + continue; + } + + if (!CultureConversions.TryGetValue(language.Value, out string? replacement) || string.IsNullOrEmpty(replacement)) { + continue; + } + + acceptLanguageHeader[i] = StringWithQualityHeaderValue.Parse(replacement); + valuesChanged = true; + } + + if (valuesChanged) { + // The getter returns a temporary collection; To make sure our changes are persisted, we need to assign it back + headers.AcceptLanguage = acceptLanguageHeader; + } + + await Next(context).ConfigureAwait(false); } } diff --git a/ArchiSteamFarm/IPC/Integration/SwaggerItemsMinMaxAttribute.cs b/ArchiSteamFarm/IPC/Integration/SwaggerItemsMinMaxAttribute.cs index 569450ab3..d1804bee4 100644 --- a/ArchiSteamFarm/IPC/Integration/SwaggerItemsMinMaxAttribute.cs +++ b/ArchiSteamFarm/IPC/Integration/SwaggerItemsMinMaxAttribute.cs @@ -23,38 +23,38 @@ using System; using JetBrains.Annotations; using Microsoft.OpenApi.Models; -namespace ArchiSteamFarm.IPC.Integration { - [PublicAPI] - public sealed class SwaggerItemsMinMaxAttribute : CustomSwaggerAttribute { - public uint MaximumUint { - get => BackingMaximum.HasValue ? decimal.ToUInt32(BackingMaximum.Value) : default(uint); - set => BackingMaximum = value; +namespace ArchiSteamFarm.IPC.Integration; + +[PublicAPI] +public sealed class SwaggerItemsMinMaxAttribute : CustomSwaggerAttribute { + public uint MaximumUint { + get => BackingMaximum.HasValue ? decimal.ToUInt32(BackingMaximum.Value) : default(uint); + set => BackingMaximum = value; + } + + public uint MinimumUint { + get => BackingMinimum.HasValue ? decimal.ToUInt32(BackingMinimum.Value) : default(uint); + set => BackingMinimum = value; + } + + private decimal? BackingMaximum; + private decimal? BackingMinimum; + + public override void Apply(OpenApiSchema schema) { + if (schema == null) { + throw new ArgumentNullException(nameof(schema)); } - public uint MinimumUint { - get => BackingMinimum.HasValue ? decimal.ToUInt32(BackingMinimum.Value) : default(uint); - set => BackingMinimum = value; + if (schema.Items == null) { + throw new InvalidOperationException(nameof(schema.Items)); } - private decimal? BackingMaximum; - private decimal? BackingMinimum; + if (BackingMinimum.HasValue) { + schema.Items.Minimum = BackingMinimum.Value; + } - public override void Apply(OpenApiSchema schema) { - if (schema == null) { - throw new ArgumentNullException(nameof(schema)); - } - - if (schema.Items == null) { - throw new InvalidOperationException(nameof(schema.Items)); - } - - if (BackingMinimum.HasValue) { - schema.Items.Minimum = BackingMinimum.Value; - } - - if (BackingMaximum.HasValue) { - schema.Items.Maximum = BackingMaximum.Value; - } + if (BackingMaximum.HasValue) { + schema.Items.Maximum = BackingMaximum.Value; } } } diff --git a/ArchiSteamFarm/IPC/Integration/SwaggerSteamIdentifierAttribute.cs b/ArchiSteamFarm/IPC/Integration/SwaggerSteamIdentifierAttribute.cs index f9d939dcb..c2e8212f0 100644 --- a/ArchiSteamFarm/IPC/Integration/SwaggerSteamIdentifierAttribute.cs +++ b/ArchiSteamFarm/IPC/Integration/SwaggerSteamIdentifierAttribute.cs @@ -24,21 +24,21 @@ using JetBrains.Annotations; using Microsoft.OpenApi.Models; using SteamKit2; -namespace ArchiSteamFarm.IPC.Integration { - [PublicAPI] - public sealed class SwaggerSteamIdentifierAttribute : CustomSwaggerAttribute { - public EAccountType AccountType { get; set; } = EAccountType.Individual; - public uint MaximumAccountID { get; set; } = uint.MaxValue; - public uint MinimumAccountID { get; set; } = 1; - public EUniverse Universe { get; set; } = EUniverse.Public; +namespace ArchiSteamFarm.IPC.Integration; - public override void Apply(OpenApiSchema schema) { - if (schema == null) { - throw new ArgumentNullException(nameof(schema)); - } +[PublicAPI] +public sealed class SwaggerSteamIdentifierAttribute : CustomSwaggerAttribute { + public EAccountType AccountType { get; set; } = EAccountType.Individual; + public uint MaximumAccountID { get; set; } = uint.MaxValue; + public uint MinimumAccountID { get; set; } = 1; + public EUniverse Universe { get; set; } = EUniverse.Public; - schema.Minimum = new SteamID(MinimumAccountID, Universe, AccountType); - schema.Maximum = new SteamID(MaximumAccountID, Universe, AccountType); + public override void Apply(OpenApiSchema schema) { + if (schema == null) { + throw new ArgumentNullException(nameof(schema)); } + + schema.Minimum = new SteamID(MinimumAccountID, Universe, AccountType); + schema.Maximum = new SteamID(MaximumAccountID, Universe, AccountType); } } diff --git a/ArchiSteamFarm/IPC/Integration/SwaggerValidValuesAttribute.cs b/ArchiSteamFarm/IPC/Integration/SwaggerValidValuesAttribute.cs index 1ee0689b6..9cbd4fe4b 100644 --- a/ArchiSteamFarm/IPC/Integration/SwaggerValidValuesAttribute.cs +++ b/ArchiSteamFarm/IPC/Integration/SwaggerValidValuesAttribute.cs @@ -26,32 +26,32 @@ using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Extensions; using Microsoft.OpenApi.Models; -namespace ArchiSteamFarm.IPC.Integration { - [PublicAPI] - public sealed class SwaggerValidValuesAttribute : CustomSwaggerAttribute { - public int[]? ValidIntValues { get; set; } - public string[]? ValidStringValues { get; set; } +namespace ArchiSteamFarm.IPC.Integration; - public override void Apply(OpenApiSchema schema) { - if (schema == null) { - throw new ArgumentNullException(nameof(schema)); - } +[PublicAPI] +public sealed class SwaggerValidValuesAttribute : CustomSwaggerAttribute { + public int[]? ValidIntValues { get; set; } + public string[]? ValidStringValues { get; set; } - OpenApiArray validValues = new(); + public override void Apply(OpenApiSchema schema) { + if (schema == null) { + throw new ArgumentNullException(nameof(schema)); + } - if (ValidIntValues != null) { - validValues.AddRange(ValidIntValues.Select(static type => new OpenApiInteger(type))); - } + OpenApiArray validValues = new(); - if (ValidStringValues != null) { - validValues.AddRange(ValidStringValues.Select(static type => new OpenApiString(type))); - } + if (ValidIntValues != null) { + validValues.AddRange(ValidIntValues.Select(static type => new OpenApiInteger(type))); + } - if (schema.Items is { Reference: null }) { - schema.Items.AddExtension("x-valid-values", validValues); - } else { - schema.AddExtension("x-valid-values", validValues); - } + if (ValidStringValues != null) { + validValues.AddRange(ValidStringValues.Select(static type => new OpenApiString(type))); + } + + if (schema.Items is { Reference: null }) { + schema.Items.AddExtension("x-valid-values", validValues); + } else { + schema.AddExtension("x-valid-values", validValues); } } } diff --git a/ArchiSteamFarm/IPC/Requests/ASFEncryptRequest.cs b/ArchiSteamFarm/IPC/Requests/ASFEncryptRequest.cs index 890a43e74..346a67d15 100644 --- a/ArchiSteamFarm/IPC/Requests/ASFEncryptRequest.cs +++ b/ArchiSteamFarm/IPC/Requests/ASFEncryptRequest.cs @@ -24,24 +24,24 @@ using System.Diagnostics.CodeAnalysis; using ArchiSteamFarm.Helpers; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Requests { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public sealed class ASFEncryptRequest { - /// - /// Encryption method used for encrypting this string. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public ArchiCryptoHelper.ECryptoMethod CryptoMethod { get; private set; } +namespace ArchiSteamFarm.IPC.Requests; - /// - /// String to encrypt with provided . - /// - [JsonProperty(Required = Required.Always)] - [Required] - public string StringToEncrypt { get; private set; } = ""; +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class ASFEncryptRequest { + /// + /// Encryption method used for encrypting this string. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public ArchiCryptoHelper.ECryptoMethod CryptoMethod { get; private set; } - [JsonConstructor] - private ASFEncryptRequest() { } - } + /// + /// String to encrypt with provided . + /// + [JsonProperty(Required = Required.Always)] + [Required] + public string StringToEncrypt { get; private set; } = ""; + + [JsonConstructor] + private ASFEncryptRequest() { } } diff --git a/ArchiSteamFarm/IPC/Requests/ASFHashRequest.cs b/ArchiSteamFarm/IPC/Requests/ASFHashRequest.cs index c512fa241..30e5cbae4 100644 --- a/ArchiSteamFarm/IPC/Requests/ASFHashRequest.cs +++ b/ArchiSteamFarm/IPC/Requests/ASFHashRequest.cs @@ -24,24 +24,24 @@ using System.Diagnostics.CodeAnalysis; using ArchiSteamFarm.Helpers; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Requests { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public sealed class ASFHashRequest { - /// - /// Hashing method used for hashing this string. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public ArchiCryptoHelper.EHashingMethod HashingMethod { get; private set; } +namespace ArchiSteamFarm.IPC.Requests; - /// - /// String to hash with provided . - /// - [JsonProperty(Required = Required.Always)] - [Required] - public string StringToHash { get; private set; } = ""; +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class ASFHashRequest { + /// + /// Hashing method used for hashing this string. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public ArchiCryptoHelper.EHashingMethod HashingMethod { get; private set; } - [JsonConstructor] - private ASFHashRequest() { } - } + /// + /// String to hash with provided . + /// + [JsonProperty(Required = Required.Always)] + [Required] + public string StringToHash { get; private set; } = ""; + + [JsonConstructor] + private ASFHashRequest() { } } diff --git a/ArchiSteamFarm/IPC/Requests/ASFRequest.cs b/ArchiSteamFarm/IPC/Requests/ASFRequest.cs index ea1fdbd4f..9fe0ab4dc 100644 --- a/ArchiSteamFarm/IPC/Requests/ASFRequest.cs +++ b/ArchiSteamFarm/IPC/Requests/ASFRequest.cs @@ -24,17 +24,17 @@ using System.Diagnostics.CodeAnalysis; using ArchiSteamFarm.Storage; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Requests { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public sealed class ASFRequest { - /// - /// ASF's global config structure. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public GlobalConfig GlobalConfig { get; private set; } = new(); +namespace ArchiSteamFarm.IPC.Requests; - [JsonConstructor] - private ASFRequest() { } - } +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class ASFRequest { + /// + /// ASF's global config structure. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public GlobalConfig GlobalConfig { get; private set; } = new(); + + [JsonConstructor] + private ASFRequest() { } } diff --git a/ArchiSteamFarm/IPC/Requests/BotGamesToRedeemInBackgroundRequest.cs b/ArchiSteamFarm/IPC/Requests/BotGamesToRedeemInBackgroundRequest.cs index 61f0189d5..142ee1bd8 100644 --- a/ArchiSteamFarm/IPC/Requests/BotGamesToRedeemInBackgroundRequest.cs +++ b/ArchiSteamFarm/IPC/Requests/BotGamesToRedeemInBackgroundRequest.cs @@ -24,21 +24,21 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Requests { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public sealed class BotGamesToRedeemInBackgroundRequest { - /// - /// A string-string map that maps cd-key to redeem (key) to its name (value). - /// - /// - /// Key in the map must be a valid and unique Steam cd-key. - /// Value in the map must be a non-null and non-empty name of the key (e.g. game's name, but can be anything). - /// - [JsonProperty(Required = Required.Always)] - [Required] - public OrderedDictionary GamesToRedeemInBackground { get; private set; } = new(); +namespace ArchiSteamFarm.IPC.Requests; - [JsonConstructor] - private BotGamesToRedeemInBackgroundRequest() { } - } +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class BotGamesToRedeemInBackgroundRequest { + /// + /// A string-string map that maps cd-key to redeem (key) to its name (value). + /// + /// + /// Key in the map must be a valid and unique Steam cd-key. + /// Value in the map must be a non-null and non-empty name of the key (e.g. game's name, but can be anything). + /// + [JsonProperty(Required = Required.Always)] + [Required] + public OrderedDictionary GamesToRedeemInBackground { get; private set; } = new(); + + [JsonConstructor] + private BotGamesToRedeemInBackgroundRequest() { } } diff --git a/ArchiSteamFarm/IPC/Requests/BotInputRequest.cs b/ArchiSteamFarm/IPC/Requests/BotInputRequest.cs index d1093ed81..f5c0a5d13 100644 --- a/ArchiSteamFarm/IPC/Requests/BotInputRequest.cs +++ b/ArchiSteamFarm/IPC/Requests/BotInputRequest.cs @@ -23,22 +23,22 @@ using System.Diagnostics.CodeAnalysis; using ArchiSteamFarm.Core; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Requests { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public sealed class BotInputRequest { - /// - /// Specifies the type of the input. - /// - [JsonProperty(Required = Required.Always)] - public ASF.EUserInputType Type { get; private set; } +namespace ArchiSteamFarm.IPC.Requests; - /// - /// Specifies the value for given input type (declared in ) - /// - [JsonProperty(Required = Required.Always)] - public string Value { get; private set; } = ""; +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class BotInputRequest { + /// + /// Specifies the type of the input. + /// + [JsonProperty(Required = Required.Always)] + public ASF.EUserInputType Type { get; private set; } - [JsonConstructor] - private BotInputRequest() { } - } + /// + /// Specifies the value for given input type (declared in ) + /// + [JsonProperty(Required = Required.Always)] + public string Value { get; private set; } = ""; + + [JsonConstructor] + private BotInputRequest() { } } diff --git a/ArchiSteamFarm/IPC/Requests/BotPauseRequest.cs b/ArchiSteamFarm/IPC/Requests/BotPauseRequest.cs index 60f87f81b..b94ac79d2 100644 --- a/ArchiSteamFarm/IPC/Requests/BotPauseRequest.cs +++ b/ArchiSteamFarm/IPC/Requests/BotPauseRequest.cs @@ -22,22 +22,22 @@ using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Requests { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public sealed class BotPauseRequest { - /// - /// Specifies if pause is permanent or temporary (default). - /// - [JsonProperty(Required = Required.DisallowNull)] - public bool Permanent { get; private set; } +namespace ArchiSteamFarm.IPC.Requests; - /// - /// Specifies automatic resume action in given seconds. Default value of 0 disables automatic resume. - /// - [JsonProperty(Required = Required.DisallowNull)] - public ushort ResumeInSeconds { get; private set; } +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class BotPauseRequest { + /// + /// Specifies if pause is permanent or temporary (default). + /// + [JsonProperty(Required = Required.DisallowNull)] + public bool Permanent { get; private set; } - [JsonConstructor] - private BotPauseRequest() { } - } + /// + /// Specifies automatic resume action in given seconds. Default value of 0 disables automatic resume. + /// + [JsonProperty(Required = Required.DisallowNull)] + public ushort ResumeInSeconds { get; private set; } + + [JsonConstructor] + private BotPauseRequest() { } } diff --git a/ArchiSteamFarm/IPC/Requests/BotRedeemRequest.cs b/ArchiSteamFarm/IPC/Requests/BotRedeemRequest.cs index ad6a62657..83bfe0403 100644 --- a/ArchiSteamFarm/IPC/Requests/BotRedeemRequest.cs +++ b/ArchiSteamFarm/IPC/Requests/BotRedeemRequest.cs @@ -24,17 +24,17 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Requests { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public sealed class BotRedeemRequest { - /// - /// A collection (set) of keys to redeem. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public ImmutableHashSet KeysToRedeem { get; private set; } = ImmutableHashSet.Empty; +namespace ArchiSteamFarm.IPC.Requests; - [JsonConstructor] - private BotRedeemRequest() { } - } +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class BotRedeemRequest { + /// + /// A collection (set) of keys to redeem. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public ImmutableHashSet KeysToRedeem { get; private set; } = ImmutableHashSet.Empty; + + [JsonConstructor] + private BotRedeemRequest() { } } diff --git a/ArchiSteamFarm/IPC/Requests/BotRenameRequest.cs b/ArchiSteamFarm/IPC/Requests/BotRenameRequest.cs index 3e7d8d0f4..0a75f2a0a 100644 --- a/ArchiSteamFarm/IPC/Requests/BotRenameRequest.cs +++ b/ArchiSteamFarm/IPC/Requests/BotRenameRequest.cs @@ -23,17 +23,17 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Requests { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public sealed class BotRenameRequest { - /// - /// Specifies the new name for the bot. The new name can't be "ASF", neither the one used by any existing bot. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public string NewName { get; private set; } = ""; +namespace ArchiSteamFarm.IPC.Requests; - [JsonConstructor] - private BotRenameRequest() { } - } +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class BotRenameRequest { + /// + /// Specifies the new name for the bot. The new name can't be "ASF", neither the one used by any existing bot. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public string NewName { get; private set; } = ""; + + [JsonConstructor] + private BotRenameRequest() { } } diff --git a/ArchiSteamFarm/IPC/Requests/BotRequest.cs b/ArchiSteamFarm/IPC/Requests/BotRequest.cs index 4f95a5f71..1f7dff1d8 100644 --- a/ArchiSteamFarm/IPC/Requests/BotRequest.cs +++ b/ArchiSteamFarm/IPC/Requests/BotRequest.cs @@ -24,17 +24,17 @@ using System.Diagnostics.CodeAnalysis; using ArchiSteamFarm.Steam.Storage; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Requests { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public sealed class BotRequest { - /// - /// ASF's bot config structure. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public BotConfig BotConfig { get; private set; } = new(); +namespace ArchiSteamFarm.IPC.Requests; - [JsonConstructor] - private BotRequest() { } - } +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class BotRequest { + /// + /// ASF's bot config structure. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public BotConfig BotConfig { get; private set; } = new(); + + [JsonConstructor] + private BotRequest() { } } diff --git a/ArchiSteamFarm/IPC/Requests/CommandRequest.cs b/ArchiSteamFarm/IPC/Requests/CommandRequest.cs index 650a8910e..fbd0ba644 100644 --- a/ArchiSteamFarm/IPC/Requests/CommandRequest.cs +++ b/ArchiSteamFarm/IPC/Requests/CommandRequest.cs @@ -24,25 +24,25 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Requests { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public sealed class CommandRequest { - /// - /// Specifies the command that will be executed by ASF. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public string Command { get; private set; } = ""; +namespace ArchiSteamFarm.IPC.Requests; - internal CommandRequest(string command) { - if (string.IsNullOrEmpty(command)) { - throw new ArgumentNullException(nameof(command)); - } +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class CommandRequest { + /// + /// Specifies the command that will be executed by ASF. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public string Command { get; private set; } = ""; - Command = command; + internal CommandRequest(string command) { + if (string.IsNullOrEmpty(command)) { + throw new ArgumentNullException(nameof(command)); } - [JsonConstructor] - private CommandRequest() { } + Command = command; } + + [JsonConstructor] + private CommandRequest() { } } diff --git a/ArchiSteamFarm/IPC/Requests/TwoFactorAuthenticationConfirmationsRequest.cs b/ArchiSteamFarm/IPC/Requests/TwoFactorAuthenticationConfirmationsRequest.cs index d8988e5d4..956c2150e 100644 --- a/ArchiSteamFarm/IPC/Requests/TwoFactorAuthenticationConfirmationsRequest.cs +++ b/ArchiSteamFarm/IPC/Requests/TwoFactorAuthenticationConfirmationsRequest.cs @@ -30,61 +30,61 @@ using ArchiSteamFarm.Localization; using ArchiSteamFarm.Steam.Security; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Requests { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public sealed class TwoFactorAuthenticationConfirmationsRequest { - /// - /// Specifies the target action, whether we should accept the confirmations (true), or decline them (false). - /// - [JsonProperty(Required = Required.Always)] - public bool Accept { get; private set; } +namespace ArchiSteamFarm.IPC.Requests; - /// - /// Specifies IDs of the confirmations that we're supposed to handle. CreatorID of the confirmation is equal to ID of the object that triggered it - e.g. ID of the trade offer, or ID of the market listing. If not provided, or empty array, all confirmation IDs are considered for an action. - /// - [JsonProperty(Required = Required.DisallowNull)] - public ImmutableHashSet AcceptedCreatorIDs { get; private set; } = ImmutableHashSet.Empty; +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class TwoFactorAuthenticationConfirmationsRequest { + /// + /// Specifies the target action, whether we should accept the confirmations (true), or decline them (false). + /// + [JsonProperty(Required = Required.Always)] + public bool Accept { get; private set; } - /// - /// Specifies the type of confirmations to handle. If not provided, all confirmation types are considered for an action. - /// - [JsonProperty] - public Confirmation.EType? AcceptedType { get; private set; } + /// + /// Specifies IDs of the confirmations that we're supposed to handle. CreatorID of the confirmation is equal to ID of the object that triggered it - e.g. ID of the trade offer, or ID of the market listing. If not provided, or empty array, all confirmation IDs are considered for an action. + /// + [JsonProperty(Required = Required.DisallowNull)] + public ImmutableHashSet AcceptedCreatorIDs { get; private set; } = ImmutableHashSet.Empty; - /// - /// A helper property which works the same as but with values written as strings - for javascript compatibility purposes. Use either this one, or , not both. - /// - [JsonProperty(PropertyName = SharedInfo.UlongCompatibilityStringPrefix + nameof(AcceptedCreatorIDs), Required = Required.DisallowNull)] - public ImmutableHashSet SAcceptedCreatorIDs { - get => AcceptedCreatorIDs.Select(static creatorID => creatorID.ToString(CultureInfo.InvariantCulture)).ToImmutableHashSet(); - set { - if (value == null) { - throw new ArgumentNullException(nameof(value)); - } + /// + /// Specifies the type of confirmations to handle. If not provided, all confirmation types are considered for an action. + /// + [JsonProperty] + public Confirmation.EType? AcceptedType { get; private set; } - HashSet acceptedCreatorIDs = new(); - - foreach (string creatorIDText in value) { - if (!ulong.TryParse(creatorIDText, out ulong creatorID) || (creatorID == 0)) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(SAcceptedCreatorIDs))); - - return; - } - - acceptedCreatorIDs.Add(creatorID); - } - - AcceptedCreatorIDs = acceptedCreatorIDs.ToImmutableHashSet(); + /// + /// A helper property which works the same as but with values written as strings - for javascript compatibility purposes. Use either this one, or , not both. + /// + [JsonProperty(PropertyName = SharedInfo.UlongCompatibilityStringPrefix + nameof(AcceptedCreatorIDs), Required = Required.DisallowNull)] + public ImmutableHashSet SAcceptedCreatorIDs { + get => AcceptedCreatorIDs.Select(static creatorID => creatorID.ToString(CultureInfo.InvariantCulture)).ToImmutableHashSet(); + set { + if (value == null) { + throw new ArgumentNullException(nameof(value)); } + + HashSet acceptedCreatorIDs = new(); + + foreach (string creatorIDText in value) { + if (!ulong.TryParse(creatorIDText, out ulong creatorID) || (creatorID == 0)) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(SAcceptedCreatorIDs))); + + return; + } + + acceptedCreatorIDs.Add(creatorID); + } + + AcceptedCreatorIDs = acceptedCreatorIDs.ToImmutableHashSet(); } - - /// - /// Specifies whether we should wait for the confirmations to arrive, in case they're not available immediately. This option makes sense only if is specified as well, and in this case ASF will add a few more tries if needed to ensure that all specified IDs are handled. Useful if confirmations are generated with a delay on Steam network side, which happens fairly often. - /// - [JsonProperty(Required = Required.DisallowNull)] - public bool WaitIfNeeded { get; private set; } - - [JsonConstructor] - private TwoFactorAuthenticationConfirmationsRequest() { } } + + /// + /// Specifies whether we should wait for the confirmations to arrive, in case they're not available immediately. This option makes sense only if is specified as well, and in this case ASF will add a few more tries if needed to ensure that all specified IDs are handled. Useful if confirmations are generated with a delay on Steam network side, which happens fairly often. + /// + [JsonProperty(Required = Required.DisallowNull)] + public bool WaitIfNeeded { get; private set; } + + [JsonConstructor] + private TwoFactorAuthenticationConfirmationsRequest() { } } diff --git a/ArchiSteamFarm/IPC/Responses/ASFResponse.cs b/ArchiSteamFarm/IPC/Responses/ASFResponse.cs index 59cf7f7c7..315c27c51 100644 --- a/ArchiSteamFarm/IPC/Responses/ASFResponse.cs +++ b/ArchiSteamFarm/IPC/Responses/ASFResponse.cs @@ -24,57 +24,57 @@ using System.ComponentModel.DataAnnotations; using ArchiSteamFarm.Storage; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Responses { - public sealed class ASFResponse { - /// - /// ASF's build variant. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public string BuildVariant { get; private set; } +namespace ArchiSteamFarm.IPC.Responses; - /// - /// A value specifying whether this variant of ASF is capable of auto-update. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public bool CanUpdate { get; private set; } +public sealed class ASFResponse { + /// + /// ASF's build variant. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public string BuildVariant { get; private set; } - /// - /// Currently loaded ASF's global config. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public GlobalConfig GlobalConfig { get; private set; } + /// + /// A value specifying whether this variant of ASF is capable of auto-update. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public bool CanUpdate { get; private set; } - /// - /// Current amount of managed memory being used by the process, in kilobytes. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public uint MemoryUsage { get; private set; } + /// + /// Currently loaded ASF's global config. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public GlobalConfig GlobalConfig { get; private set; } - /// - /// Start date of the process. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public DateTime ProcessStartTime { get; private set; } + /// + /// Current amount of managed memory being used by the process, in kilobytes. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public uint MemoryUsage { get; private set; } - /// - /// ASF version of currently running binary. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public Version Version { get; private set; } + /// + /// Start date of the process. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public DateTime ProcessStartTime { get; private set; } - internal ASFResponse(string buildVariant, bool canUpdate, GlobalConfig globalConfig, uint memoryUsage, DateTime processStartTime, Version version) { - BuildVariant = !string.IsNullOrEmpty(buildVariant) ? buildVariant : throw new ArgumentNullException(nameof(buildVariant)); - CanUpdate = canUpdate; - GlobalConfig = globalConfig ?? throw new ArgumentNullException(nameof(globalConfig)); - MemoryUsage = memoryUsage > 0 ? memoryUsage : throw new ArgumentOutOfRangeException(nameof(memoryUsage)); - ProcessStartTime = processStartTime > DateTime.MinValue ? processStartTime : throw new ArgumentOutOfRangeException(nameof(processStartTime)); - Version = version ?? throw new ArgumentNullException(nameof(version)); - } + /// + /// ASF version of currently running binary. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public Version Version { get; private set; } + + internal ASFResponse(string buildVariant, bool canUpdate, GlobalConfig globalConfig, uint memoryUsage, DateTime processStartTime, Version version) { + BuildVariant = !string.IsNullOrEmpty(buildVariant) ? buildVariant : throw new ArgumentNullException(nameof(buildVariant)); + CanUpdate = canUpdate; + GlobalConfig = globalConfig ?? throw new ArgumentNullException(nameof(globalConfig)); + MemoryUsage = memoryUsage > 0 ? memoryUsage : throw new ArgumentOutOfRangeException(nameof(memoryUsage)); + ProcessStartTime = processStartTime > DateTime.MinValue ? processStartTime : throw new ArgumentOutOfRangeException(nameof(processStartTime)); + Version = version ?? throw new ArgumentNullException(nameof(version)); } } diff --git a/ArchiSteamFarm/IPC/Responses/GamesToRedeemInBackgroundResponse.cs b/ArchiSteamFarm/IPC/Responses/GamesToRedeemInBackgroundResponse.cs index 92078e4a3..6b6a79829 100644 --- a/ArchiSteamFarm/IPC/Responses/GamesToRedeemInBackgroundResponse.cs +++ b/ArchiSteamFarm/IPC/Responses/GamesToRedeemInBackgroundResponse.cs @@ -22,23 +22,23 @@ using System.Collections.Generic; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Responses { - public sealed class GamesToRedeemInBackgroundResponse { - /// - /// Keys that were redeemed and not used during the process, if available. - /// - [JsonProperty] - public Dictionary? UnusedKeys { get; private set; } +namespace ArchiSteamFarm.IPC.Responses; - /// - /// Keys that were redeemed and used during the process, if available. - /// - [JsonProperty] - public Dictionary? UsedKeys { get; private set; } +public sealed class GamesToRedeemInBackgroundResponse { + /// + /// Keys that were redeemed and not used during the process, if available. + /// + [JsonProperty] + public Dictionary? UnusedKeys { get; private set; } - internal GamesToRedeemInBackgroundResponse(Dictionary? unusedKeys = null, Dictionary? usedKeys = null) { - UnusedKeys = unusedKeys; - UsedKeys = usedKeys; - } + /// + /// Keys that were redeemed and used during the process, if available. + /// + [JsonProperty] + public Dictionary? UsedKeys { get; private set; } + + internal GamesToRedeemInBackgroundResponse(Dictionary? unusedKeys = null, Dictionary? usedKeys = null) { + UnusedKeys = unusedKeys; + UsedKeys = usedKeys; } } diff --git a/ArchiSteamFarm/IPC/Responses/GenericResponse.cs b/ArchiSteamFarm/IPC/Responses/GenericResponse.cs index 00a9a7aa4..d724e08d2 100644 --- a/ArchiSteamFarm/IPC/Responses/GenericResponse.cs +++ b/ArchiSteamFarm/IPC/Responses/GenericResponse.cs @@ -23,45 +23,45 @@ using System.ComponentModel.DataAnnotations; using ArchiSteamFarm.Localization; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Responses { - public sealed class GenericResponse : GenericResponse where T : class { - /// - /// The actual result of the request, if available. - /// - /// - /// The type of the result depends on the API endpoint that you've called. - /// - [JsonProperty] - public T? Result { get; private set; } +namespace ArchiSteamFarm.IPC.Responses; - public GenericResponse(T? result) : base(result != null) => Result = result; - public GenericResponse(bool success, string? message) : base(success, message) { } - public GenericResponse(bool success, T? result) : base(success) => Result = result; - public GenericResponse(bool success, string? message, T? result) : base(success, message) => Result = result; - } +public sealed class GenericResponse : GenericResponse where T : class { + /// + /// The actual result of the request, if available. + /// + /// + /// The type of the result depends on the API endpoint that you've called. + /// + [JsonProperty] + public T? Result { get; private set; } - public class GenericResponse { - /// - /// A message that describes what happened with the request, if available. - /// - /// - /// This property will provide exact reason for majority of expected failures. - /// - [JsonProperty] - public string Message { get; private set; } + public GenericResponse(T? result) : base(result != null) => Result = result; + public GenericResponse(bool success, string? message) : base(success, message) { } + public GenericResponse(bool success, T? result) : base(success) => Result = result; + public GenericResponse(bool success, string? message, T? result) : base(success, message) => Result = result; +} - /// - /// Boolean type that specifies if the request has succeeded. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public bool Success { get; private set; } +public class GenericResponse { + /// + /// A message that describes what happened with the request, if available. + /// + /// + /// This property will provide exact reason for majority of expected failures. + /// + [JsonProperty] + public string Message { get; private set; } - public GenericResponse(bool success, string? message = null) { - Success = success; + /// + /// Boolean type that specifies if the request has succeeded. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public bool Success { get; private set; } - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - Message = !string.IsNullOrEmpty(message) ? message! : success ? "OK" : Strings.WarningFailed; - } + public GenericResponse(bool success, string? message = null) { + Success = success; + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + Message = !string.IsNullOrEmpty(message) ? message! : success ? "OK" : Strings.WarningFailed; } } diff --git a/ArchiSteamFarm/IPC/Responses/GitHubReleaseResponse.cs b/ArchiSteamFarm/IPC/Responses/GitHubReleaseResponse.cs index 8826b4823..0a757a1b9 100644 --- a/ArchiSteamFarm/IPC/Responses/GitHubReleaseResponse.cs +++ b/ArchiSteamFarm/IPC/Responses/GitHubReleaseResponse.cs @@ -24,45 +24,45 @@ using System.ComponentModel.DataAnnotations; using ArchiSteamFarm.Web; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Responses { - public sealed class GitHubReleaseResponse { - /// - /// Changelog of the release rendered in HTML. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public string ChangelogHTML { get; private set; } +namespace ArchiSteamFarm.IPC.Responses; - /// - /// Date of the release. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public DateTime ReleasedAt { get; private set; } +public sealed class GitHubReleaseResponse { + /// + /// Changelog of the release rendered in HTML. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public string ChangelogHTML { get; private set; } - /// - /// Boolean value that specifies whether the build is stable or not (pre-release). - /// - [JsonProperty(Required = Required.Always)] - [Required] - public bool Stable { get; private set; } + /// + /// Date of the release. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public DateTime ReleasedAt { get; private set; } - /// - /// Version of the release. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public string Version { get; private set; } + /// + /// Boolean value that specifies whether the build is stable or not (pre-release). + /// + [JsonProperty(Required = Required.Always)] + [Required] + public bool Stable { get; private set; } - internal GitHubReleaseResponse(GitHub.ReleaseResponse releaseResponse) { - if (releaseResponse == null) { - throw new ArgumentNullException(nameof(releaseResponse)); - } + /// + /// Version of the release. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public string Version { get; private set; } - ChangelogHTML = releaseResponse.ChangelogHTML ?? ""; - ReleasedAt = releaseResponse.PublishedAt; - Stable = !releaseResponse.IsPreRelease; - Version = releaseResponse.Tag; + internal GitHubReleaseResponse(GitHub.ReleaseResponse releaseResponse) { + if (releaseResponse == null) { + throw new ArgumentNullException(nameof(releaseResponse)); } + + ChangelogHTML = releaseResponse.ChangelogHTML ?? ""; + ReleasedAt = releaseResponse.PublishedAt; + Stable = !releaseResponse.IsPreRelease; + Version = releaseResponse.Tag; } } diff --git a/ArchiSteamFarm/IPC/Responses/StatusCodeResponse.cs b/ArchiSteamFarm/IPC/Responses/StatusCodeResponse.cs index 2738b713c..9b5d60d79 100644 --- a/ArchiSteamFarm/IPC/Responses/StatusCodeResponse.cs +++ b/ArchiSteamFarm/IPC/Responses/StatusCodeResponse.cs @@ -23,25 +23,25 @@ using System.ComponentModel.DataAnnotations; using System.Net; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Responses { - public sealed class StatusCodeResponse { - /// - /// Value indicating whether the status is permanent. If yes, retrying the request with exactly the same payload doesn't make sense due to a permanent problem (e.g. ASF misconfiguration). - /// - [JsonProperty(Required = Required.Always)] - [Required] - public bool Permanent { get; private set; } +namespace ArchiSteamFarm.IPC.Responses; - /// - /// Status code transmitted in addition to the one in HTTP spec. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public HttpStatusCode StatusCode { get; private set; } +public sealed class StatusCodeResponse { + /// + /// Value indicating whether the status is permanent. If yes, retrying the request with exactly the same payload doesn't make sense due to a permanent problem (e.g. ASF misconfiguration). + /// + [JsonProperty(Required = Required.Always)] + [Required] + public bool Permanent { get; private set; } - internal StatusCodeResponse(HttpStatusCode statusCode, bool permanent) { - StatusCode = statusCode; - Permanent = permanent; - } + /// + /// Status code transmitted in addition to the one in HTTP spec. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public HttpStatusCode StatusCode { get; private set; } + + internal StatusCodeResponse(HttpStatusCode statusCode, bool permanent) { + StatusCode = statusCode; + Permanent = permanent; } } diff --git a/ArchiSteamFarm/IPC/Responses/TypeProperties.cs b/ArchiSteamFarm/IPC/Responses/TypeProperties.cs index 72a1b9f0d..ebdc8c763 100644 --- a/ArchiSteamFarm/IPC/Responses/TypeProperties.cs +++ b/ArchiSteamFarm/IPC/Responses/TypeProperties.cs @@ -23,39 +23,39 @@ using System; using System.Collections.Generic; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Responses { - public sealed class TypeProperties { - /// - /// Base type of given type, if available. - /// - /// - /// This can be used for determining how the body of the response should be interpreted. - /// - [JsonProperty] - public string? BaseType { get; private set; } +namespace ArchiSteamFarm.IPC.Responses; - /// - /// Custom attributes of given type, if available. - /// - /// - /// This can be used for determining main enum type if is . - /// - [JsonProperty] - public HashSet? CustomAttributes { get; private set; } +public sealed class TypeProperties { + /// + /// Base type of given type, if available. + /// + /// + /// This can be used for determining how the body of the response should be interpreted. + /// + [JsonProperty] + public string? BaseType { get; private set; } - /// - /// Underlying type of given type, if available. - /// - /// - /// This can be used for determining underlying enum type if is . - /// - [JsonProperty] - public string? UnderlyingType { get; private set; } + /// + /// Custom attributes of given type, if available. + /// + /// + /// This can be used for determining main enum type if is . + /// + [JsonProperty] + public HashSet? CustomAttributes { get; private set; } - internal TypeProperties(string? baseType = null, HashSet? customAttributes = null, string? underlyingType = null) { - BaseType = baseType; - CustomAttributes = customAttributes; - UnderlyingType = underlyingType; - } + /// + /// Underlying type of given type, if available. + /// + /// + /// This can be used for determining underlying enum type if is . + /// + [JsonProperty] + public string? UnderlyingType { get; private set; } + + internal TypeProperties(string? baseType = null, HashSet? customAttributes = null, string? underlyingType = null) { + BaseType = baseType; + CustomAttributes = customAttributes; + UnderlyingType = underlyingType; } } diff --git a/ArchiSteamFarm/IPC/Responses/TypeResponse.cs b/ArchiSteamFarm/IPC/Responses/TypeResponse.cs index ceeb68736..360d5392d 100644 --- a/ArchiSteamFarm/IPC/Responses/TypeResponse.cs +++ b/ArchiSteamFarm/IPC/Responses/TypeResponse.cs @@ -24,30 +24,30 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC.Responses { - public sealed class TypeResponse { - /// - /// A string-string map representing a decomposition of given type. - /// - /// - /// The actual structure of this field depends on the type that was requested. You can determine that type based on metadata. - /// For enums, keys are friendly names while values are underlying values of those names. - /// For objects, keys are non-private fields and properties, while values are underlying types of those. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public Dictionary Body { get; private set; } +namespace ArchiSteamFarm.IPC.Responses; - /// - /// Metadata of given type. - /// - [JsonProperty(Required = Required.Always)] - [Required] - public TypeProperties Properties { get; private set; } +public sealed class TypeResponse { + /// + /// A string-string map representing a decomposition of given type. + /// + /// + /// The actual structure of this field depends on the type that was requested. You can determine that type based on metadata. + /// For enums, keys are friendly names while values are underlying values of those names. + /// For objects, keys are non-private fields and properties, while values are underlying types of those. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public Dictionary Body { get; private set; } - internal TypeResponse(Dictionary body, TypeProperties properties) { - Body = body ?? throw new ArgumentNullException(nameof(body)); - Properties = properties ?? throw new ArgumentNullException(nameof(properties)); - } + /// + /// Metadata of given type. + /// + [JsonProperty(Required = Required.Always)] + [Required] + public TypeProperties Properties { get; private set; } + + internal TypeResponse(Dictionary body, TypeProperties properties) { + Body = body ?? throw new ArgumentNullException(nameof(body)); + Properties = properties ?? throw new ArgumentNullException(nameof(properties)); } } diff --git a/ArchiSteamFarm/IPC/Startup.cs b/ArchiSteamFarm/IPC/Startup.cs index fe2b220b5..8eb387dc8 100644 --- a/ArchiSteamFarm/IPC/Startup.cs +++ b/ArchiSteamFarm/IPC/Startup.cs @@ -51,297 +51,298 @@ using Microsoft.OpenApi.Models; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; -namespace ArchiSteamFarm.IPC { - internal sealed class Startup { - private readonly IConfiguration Configuration; +namespace ArchiSteamFarm.IPC; - public Startup(IConfiguration configuration) => Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); +internal sealed class Startup { + private readonly IConfiguration Configuration; - [UsedImplicitly] - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { - if (app == null) { - throw new ArgumentNullException(nameof(app)); - } + public Startup(IConfiguration configuration) => Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - if (env == null) { - throw new ArgumentNullException(nameof(env)); - } + [UsedImplicitly] + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { + if (app == null) { + throw new ArgumentNullException(nameof(app)); + } - // The order of dependency injection is super important, doing things in wrong order will break everything - // https://docs.microsoft.com/aspnet/core/fundamentals/middleware + if (env == null) { + throw new ArgumentNullException(nameof(env)); + } - // This one is easy, it's always in the beginning - if (Debugging.IsUserDebugging) { - app.UseDeveloperExceptionPage(); - } + // The order of dependency injection is super important, doing things in wrong order will break everything + // https://docs.microsoft.com/aspnet/core/fundamentals/middleware - // Add support for proxies, this one comes usually after developer exception page, but could be before - app.UseForwardedHeaders(); + // This one is easy, it's always in the beginning + if (Debugging.IsUserDebugging) { + app.UseDeveloperExceptionPage(); + } - if (ASF.GlobalConfig?.OptimizationMode != GlobalConfig.EOptimizationMode.MinMemoryUsage) { - // Add support for response caching - must be called before static files as we want to cache those as well - app.UseResponseCaching(); - } + // Add support for proxies, this one comes usually after developer exception page, but could be before + app.UseForwardedHeaders(); - // Add support for response compression - must be called before static files as we want to compress those as well - app.UseResponseCompression(); + if (ASF.GlobalConfig?.OptimizationMode != GlobalConfig.EOptimizationMode.MinMemoryUsage) { + // Add support for response caching - must be called before static files as we want to cache those as well + app.UseResponseCaching(); + } - // It's not apparent when UsePathBase() should be called, but definitely before we get down to static files - // TODO: Maybe eventually we can get rid of this, https://github.com/aspnet/AspNetCore/issues/5898 - PathString pathBase = Configuration.GetSection("Kestrel").GetValue("PathBase"); + // Add support for response compression - must be called before static files as we want to compress those as well + app.UseResponseCompression(); - if (!string.IsNullOrEmpty(pathBase) && (pathBase != "/")) { - app.UsePathBase(pathBase); - } + // It's not apparent when UsePathBase() should be called, but definitely before we get down to static files + // TODO: Maybe eventually we can get rid of this, https://github.com/aspnet/AspNetCore/issues/5898 + PathString pathBase = Configuration.GetSection("Kestrel").GetValue("PathBase"); - // The default HTML file (usually index.html) is responsible for IPC GUI routing, so re-execute all non-API calls on / - // This must be called before default files, because we don't know the exact file name that will be used for index page - app.UseWhen(static context => !context.Request.Path.StartsWithSegments("/Api", StringComparison.OrdinalIgnoreCase), static appBuilder => appBuilder.UseStatusCodePagesWithReExecute("/")); + if (!string.IsNullOrEmpty(pathBase) && (pathBase != "/")) { + app.UsePathBase(pathBase); + } - // Add support for default root path redirection (GET / -> GET /index.html), must come before static files - app.UseDefaultFiles(); + // The default HTML file (usually index.html) is responsible for IPC GUI routing, so re-execute all non-API calls on / + // This must be called before default files, because we don't know the exact file name that will be used for index page + app.UseWhen(static context => !context.Request.Path.StartsWithSegments("/Api", StringComparison.OrdinalIgnoreCase), static appBuilder => appBuilder.UseStatusCodePagesWithReExecute("/")); - // Add support for static files (e.g. HTML, CSS and JS from IPC GUI) - app.UseStaticFiles( - new StaticFileOptions { - OnPrepareResponse = static context => { - if (context.File.Exists && !context.File.IsDirectory && !string.IsNullOrEmpty(context.File.Name)) { - string extension = Path.GetExtension(context.File.Name); + // Add support for default root path redirection (GET / -> GET /index.html), must come before static files + app.UseDefaultFiles(); - CacheControlHeaderValue cacheControl = new(); + // Add support for static files (e.g. HTML, CSS and JS from IPC GUI) + app.UseStaticFiles( + new StaticFileOptions { + OnPrepareResponse = static context => { + if (context.File.Exists && !context.File.IsDirectory && !string.IsNullOrEmpty(context.File.Name)) { + string extension = Path.GetExtension(context.File.Name); - switch (extension.ToUpperInvariant()) { - case ".CSS": - case ".JS": - // Add support for SRI-protected static files - // SRI requires from us to notify the caller (especially proxy) to avoid modifying the data - cacheControl.NoTransform = true; + CacheControlHeaderValue cacheControl = new(); - goto default; - default: - // Instruct the caller to always ask us first about every file it requests - // Contrary to the name, this doesn't prevent client from caching, but rather informs it that it must verify with us first that his cache is still up-to-date - // This is used to handle ASF and user updates to WWW root, we don't want from the client to ever use outdated scripts - cacheControl.NoCache = true; + switch (extension.ToUpperInvariant()) { + case ".CSS": + case ".JS": + // Add support for SRI-protected static files + // SRI requires from us to notify the caller (especially proxy) to avoid modifying the data + cacheControl.NoTransform = true; - // All static files are public by definition, we don't have any authorization here - cacheControl.Public = true; + goto default; + default: + // Instruct the caller to always ask us first about every file it requests + // Contrary to the name, this doesn't prevent client from caching, but rather informs it that it must verify with us first that his cache is still up-to-date + // This is used to handle ASF and user updates to WWW root, we don't want from the client to ever use outdated scripts + cacheControl.NoCache = true; - break; - } + // All static files are public by definition, we don't have any authorization here + cacheControl.Public = true; - ResponseHeaders headers = context.Context.Response.GetTypedHeaders(); - - headers.CacheControl = cacheControl; + break; } + + ResponseHeaders headers = context.Context.Response.GetTypedHeaders(); + + headers.CacheControl = cacheControl; } } - ); + } + ); - // Add support for additional localization mappings - app.UseMiddleware(); + // Add support for additional localization mappings + app.UseMiddleware(); - // Add support for localization - app.UseRequestLocalization(); + // Add support for localization + app.UseRequestLocalization(); - // Use routing for our API controllers, this should be called once we're done with all the static files mess + // Use routing for our API controllers, this should be called once we're done with all the static files mess #if !NETFRAMEWORK - app.UseRouting(); + app.UseRouting(); #endif - // We want to protect our API with IPCPassword and additional security, this should be called after routing, so the middleware won't have to deal with API endpoints that do not exist - app.UseWhen(static context => context.Request.Path.StartsWithSegments("/Api", StringComparison.OrdinalIgnoreCase), static appBuilder => appBuilder.UseMiddleware()); + // We want to protect our API with IPCPassword and additional security, this should be called after routing, so the middleware won't have to deal with API endpoints that do not exist + app.UseWhen(static context => context.Request.Path.StartsWithSegments("/Api", StringComparison.OrdinalIgnoreCase), static appBuilder => appBuilder.UseMiddleware()); - string? ipcPassword = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPassword : GlobalConfig.DefaultIPCPassword; + string? ipcPassword = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPassword : GlobalConfig.DefaultIPCPassword; - if (!string.IsNullOrEmpty(ipcPassword)) { - // We want to apply CORS policy in order to allow userscripts and other third-party integrations to communicate with ASF API, this should be called before response compression, but can't be due to how our flow works - // We apply CORS policy only with IPCPassword set as an extra authentication measure - app.UseCors(); - } + if (!string.IsNullOrEmpty(ipcPassword)) { + // We want to apply CORS policy in order to allow userscripts and other third-party integrations to communicate with ASF API, this should be called before response compression, but can't be due to how our flow works + // We apply CORS policy only with IPCPassword set as an extra authentication measure + app.UseCors(); + } - // Add support for websockets that we use e.g. in /Api/NLog - app.UseWebSockets(); + // Add support for websockets that we use e.g. in /Api/NLog + app.UseWebSockets(); - // Finally register proper API endpoints once we're done with routing + // Finally register proper API endpoints once we're done with routing #if NETFRAMEWORK app.UseMvcWithDefaultRoute(); #else - app.UseEndpoints(static endpoints => endpoints.MapControllers()); + app.UseEndpoints(static endpoints => endpoints.MapControllers()); #endif - // Add support for swagger, responsible for automatic API documentation generation, this should be on the end, once we're done with API - app.UseSwagger(); + // Add support for swagger, responsible for automatic API documentation generation, this should be on the end, once we're done with API + app.UseSwagger(); - // Add support for swagger UI, this should be after swagger, obviously - app.UseSwaggerUI( - static options => { - options.DisplayRequestDuration(); - options.EnableDeepLinking(); - options.ShowExtensions(); - options.SwaggerEndpoint($"{SharedInfo.ASF}/swagger.json", $"{SharedInfo.ASF} API"); - } - ); + // Add support for swagger UI, this should be after swagger, obviously + app.UseSwaggerUI( + static options => { + options.DisplayRequestDuration(); + options.EnableDeepLinking(); + options.ShowExtensions(); + options.SwaggerEndpoint($"{SharedInfo.ASF}/swagger.json", $"{SharedInfo.ASF} API"); + } + ); + } + + public void ConfigureServices(IServiceCollection services) { + if (services == null) { + throw new ArgumentNullException(nameof(services)); } - public void ConfigureServices(IServiceCollection services) { - if (services == null) { - throw new ArgumentNullException(nameof(services)); - } + // The order of dependency injection is super important, doing things in wrong order will break everything + // Order in Configure() method is a good start - // The order of dependency injection is super important, doing things in wrong order will break everything - // Order in Configure() method is a good start + // Prepare knownNetworks that we'll use in a second + HashSet? knownNetworksTexts = Configuration.GetSection("Kestrel:KnownNetworks").Get>(); - // Prepare knownNetworks that we'll use in a second - HashSet? knownNetworksTexts = Configuration.GetSection("Kestrel:KnownNetworks").Get>(); + HashSet? knownNetworks = null; - HashSet? knownNetworks = null; + if (knownNetworksTexts?.Count > 0) { + // Use specified known networks + knownNetworks = new HashSet(); - if (knownNetworksTexts?.Count > 0) { - // Use specified known networks - knownNetworks = new HashSet(); + foreach (string knownNetworkText in knownNetworksTexts) { + string[] addressParts = knownNetworkText.Split('/', StringSplitOptions.RemoveEmptyEntries); - foreach (string knownNetworkText in knownNetworksTexts) { - string[] addressParts = knownNetworkText.Split('/', StringSplitOptions.RemoveEmptyEntries); + if ((addressParts.Length != 2) || !IPAddress.TryParse(addressParts[0], out IPAddress? ipAddress) || !byte.TryParse(addressParts[1], out byte prefixLength)) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(knownNetworkText))); + ASF.ArchiLogger.LogGenericDebug($"{nameof(knownNetworkText)}: {knownNetworkText}"); - if ((addressParts.Length != 2) || !IPAddress.TryParse(addressParts[0], out IPAddress? ipAddress) || !byte.TryParse(addressParts[1], out byte prefixLength)) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(knownNetworkText))); - ASF.ArchiLogger.LogGenericDebug($"{nameof(knownNetworkText)}: {knownNetworkText}"); - - continue; - } - - knownNetworks.Add(new IPNetwork(ipAddress, prefixLength)); + continue; } + + knownNetworks.Add(new IPNetwork(ipAddress, prefixLength)); } + } - // Add support for proxies - services.Configure( - options => { - options.ForwardedHeaders = ForwardedHeaders.All; + // Add support for proxies + services.Configure( + options => { + options.ForwardedHeaders = ForwardedHeaders.All; - if (knownNetworks != null) { - foreach (IPNetwork knownNetwork in knownNetworks) { - options.KnownNetworks.Add(knownNetwork); - } + if (knownNetworks != null) { + foreach (IPNetwork knownNetwork in knownNetworks) { + options.KnownNetworks.Add(knownNetwork); } } - ); - - if (ASF.GlobalConfig?.OptimizationMode != GlobalConfig.EOptimizationMode.MinMemoryUsage) { - // Add support for response caching - services.AddResponseCaching(); } + ); - // Add support for response compression - services.AddResponseCompression(); + if (ASF.GlobalConfig?.OptimizationMode != GlobalConfig.EOptimizationMode.MinMemoryUsage) { + // Add support for response caching + services.AddResponseCaching(); + } - // Add support for localization - services.AddLocalization(); + // Add support for response compression + services.AddResponseCompression(); - services.AddRequestLocalization( - static options => { - // We do not set the DefaultRequestCulture here, because it will default to Thread.CurrentThread.CurrentCulture in this case, which is set when loading GlobalConfig + // Add support for localization + services.AddLocalization(); - try { - CultureInfo lolcatCulture = CultureInfo.CreateSpecificCulture(SharedInfo.LolcatCultureName); + services.AddRequestLocalization( + static options => { + // We do not set the DefaultRequestCulture here, because it will default to Thread.CurrentThread.CurrentCulture in this case, which is set when loading GlobalConfig - options.SupportedCultures = options.SupportedUICultures = CultureInfo.GetCultures(CultureTypes.AllCultures).Append(lolcatCulture).ToList(); - } catch (Exception e) { - // Fallback for platforms that do not support qps-Ploc culture - ASF.ArchiLogger.LogGenericDebuggingException(e); + try { + CultureInfo lolcatCulture = CultureInfo.CreateSpecificCulture(SharedInfo.LolcatCultureName); - options.SupportedCultures = options.SupportedUICultures = CultureInfo.GetCultures(CultureTypes.AllCultures); - } + options.SupportedCultures = options.SupportedUICultures = CultureInfo.GetCultures(CultureTypes.AllCultures).Append(lolcatCulture).ToList(); + } catch (Exception e) { + // Fallback for platforms that do not support qps-Ploc culture + ASF.ArchiLogger.LogGenericDebuggingException(e); - // The default checks the URI and cookies and only then for headers; ASFs IPC does not use either of the higher priority mechanisms anywhere else and we don't want to start here. - options.RequestCultureProviders = new List(1) { new AcceptLanguageHeaderRequestCultureProvider() }; + options.SupportedCultures = options.SupportedUICultures = CultureInfo.GetCultures(CultureTypes.AllCultures); } - ); - string? ipcPassword = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPassword : GlobalConfig.DefaultIPCPassword; - - if (!string.IsNullOrEmpty(ipcPassword)) { - // We want to apply CORS policy in order to allow userscripts and other third-party integrations to communicate with ASF API - // We apply CORS policy only with IPCPassword set as an extra authentication measure - services.AddCors(static options => options.AddDefaultPolicy(static policyBuilder => policyBuilder.AllowAnyOrigin())); + // The default checks the URI and cookies and only then for headers; ASFs IPC does not use either of the higher priority mechanisms anywhere else and we don't want to start here. + options.RequestCultureProviders = new List(1) { new AcceptLanguageHeaderRequestCultureProvider() }; } + ); - // Add support for swagger, responsible for automatic API documentation generation - services.AddSwaggerGen( - static options => { - options.AddSecurityDefinition( - nameof(GlobalConfig.IPCPassword), new OpenApiSecurityScheme { - Description = $"{nameof(GlobalConfig.IPCPassword)} authentication using request headers. Check {SharedInfo.ProjectURL}/wiki/IPC#authentication for more info.", - In = ParameterLocation.Header, - Name = ApiAuthenticationMiddleware.HeadersField, - Type = SecuritySchemeType.ApiKey - } - ); + string? ipcPassword = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPassword : GlobalConfig.DefaultIPCPassword; - options.AddSecurityRequirement( - new OpenApiSecurityRequirement { - { - new OpenApiSecurityScheme { - Reference = new OpenApiReference { - Id = nameof(GlobalConfig.IPCPassword), - Type = ReferenceType.SecurityScheme - } - }, + if (!string.IsNullOrEmpty(ipcPassword)) { + // We want to apply CORS policy in order to allow userscripts and other third-party integrations to communicate with ASF API + // We apply CORS policy only with IPCPassword set as an extra authentication measure + services.AddCors(static options => options.AddDefaultPolicy(static policyBuilder => policyBuilder.AllowAnyOrigin())); + } - Array.Empty() - } - } - ); + // Add support for swagger, responsible for automatic API documentation generation + services.AddSwaggerGen( + static options => { + options.AddSecurityDefinition( + nameof(GlobalConfig.IPCPassword), new OpenApiSecurityScheme { + Description = $"{nameof(GlobalConfig.IPCPassword)} authentication using request headers. Check {SharedInfo.ProjectURL}/wiki/IPC#authentication for more info.", + In = ParameterLocation.Header, + Name = ApiAuthenticationMiddleware.HeadersField, + Type = SecuritySchemeType.ApiKey + } + ); - options.CustomSchemaIds(static type => type.GetUnifiedName()); - options.EnableAnnotations(true, true); - - options.SchemaFilter(); - options.SchemaFilter(); - - options.SwaggerDoc( - SharedInfo.ASF, new OpenApiInfo { - Contact = new OpenApiContact { - Name = SharedInfo.GithubRepo, - Url = new Uri(SharedInfo.ProjectURL) + options.AddSecurityRequirement( + new OpenApiSecurityRequirement { + { + new OpenApiSecurityScheme { + Reference = new OpenApiReference { + Id = nameof(GlobalConfig.IPCPassword), + Type = ReferenceType.SecurityScheme + } }, - License = new OpenApiLicense { - Name = SharedInfo.LicenseName, - Url = new Uri(SharedInfo.LicenseURL) - }, - - Title = $"{SharedInfo.ASF} API" + Array.Empty() } - ); - - string xmlDocumentationFile = Path.Combine(AppContext.BaseDirectory, SharedInfo.AssemblyDocumentation); - - if (File.Exists(xmlDocumentationFile)) { - options.IncludeXmlComments(xmlDocumentationFile); } - } - ); + ); - // Add support for Newtonsoft.Json in swagger, this one must be executed after AddSwaggerGen() - services.AddSwaggerGenNewtonsoftSupport(); + options.CustomSchemaIds(static type => type.GetUnifiedName()); + options.EnableAnnotations(true, true); - // We need MVC for /Api, but we're going to use only a small subset of all available features - IMvcBuilder mvc = services.AddControllers(); + options.SchemaFilter(); + options.SchemaFilter(); - // Add support for controllers declared in custom plugins - if (PluginsCore.ActivePlugins?.Count > 0) { - HashSet? assemblies = PluginsCore.LoadAssemblies(); + options.SwaggerDoc( + SharedInfo.ASF, new OpenApiInfo { + Contact = new OpenApiContact { + Name = SharedInfo.GithubRepo, + Url = new Uri(SharedInfo.ProjectURL) + }, - if (assemblies != null) { - foreach (Assembly assembly in assemblies) { - mvc.AddApplicationPart(assembly); + License = new OpenApiLicense { + Name = SharedInfo.LicenseName, + Url = new Uri(SharedInfo.LicenseURL) + }, + + Title = $"{SharedInfo.ASF} API" } + ); + + string xmlDocumentationFile = Path.Combine(AppContext.BaseDirectory, SharedInfo.AssemblyDocumentation); + + if (File.Exists(xmlDocumentationFile)) { + options.IncludeXmlComments(xmlDocumentationFile); } } + ); - mvc.AddControllersAsServices(); + // Add support for Newtonsoft.Json in swagger, this one must be executed after AddSwaggerGen() + services.AddSwaggerGenNewtonsoftSupport(); + + // We need MVC for /Api, but we're going to use only a small subset of all available features + IMvcBuilder mvc = services.AddControllers(); + + // Add support for controllers declared in custom plugins + if (PluginsCore.ActivePlugins?.Count > 0) { + HashSet? assemblies = PluginsCore.LoadAssemblies(); + + if (assemblies != null) { + foreach (Assembly assembly in assemblies) { + mvc.AddApplicationPart(assembly); + } + } + } + + mvc.AddControllersAsServices(); #if NETFRAMEWORK // Use latest compatibility version for MVC @@ -354,21 +355,20 @@ namespace ArchiSteamFarm.IPC { mvc.AddApiExplorer(); #endif - mvc.AddNewtonsoftJson( - static options => { - // Fix default contract resolver to use original names and not a camel case - options.SerializerSettings.ContractResolver = new DefaultContractResolver(); + mvc.AddNewtonsoftJson( + static options => { + // Fix default contract resolver to use original names and not a camel case + options.SerializerSettings.ContractResolver = new DefaultContractResolver(); - if (Debugging.IsUserDebugging) { - options.SerializerSettings.Formatting = Formatting.Indented; - } + if (Debugging.IsUserDebugging) { + options.SerializerSettings.Formatting = Formatting.Indented; + } #if NETFRAMEWORK // .NET Framework serializes Version as object by default, serialize it as string just like .NET Core options.SerializerSettings.Converters.Add(new VersionConverter()); #endif - } - ); - } + } + ); } } diff --git a/ArchiSteamFarm/IPC/WebUtilities.cs b/ArchiSteamFarm/IPC/WebUtilities.cs index 7d2b35b1d..99574d9c8 100644 --- a/ArchiSteamFarm/IPC/WebUtilities.cs +++ b/ArchiSteamFarm/IPC/WebUtilities.cs @@ -32,8 +32,9 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Newtonsoft.Json; -namespace ArchiSteamFarm.IPC { - internal static class WebUtilities { +namespace ArchiSteamFarm.IPC; + +internal static class WebUtilities { #if NETFRAMEWORK internal static IMvcCoreBuilder AddControllers(this IServiceCollection services) { if (services == null) { @@ -69,53 +70,52 @@ namespace ArchiSteamFarm.IPC { } #endif - internal static string? GetUnifiedName(this Type type) { - if (type == null) { - throw new ArgumentNullException(nameof(type)); - } - - return type.GenericTypeArguments.Length == 0 ? type.FullName : $"{type.Namespace}.{type.Name}{string.Join("", type.GenericTypeArguments.Select(static innerType => $"[{innerType.GetUnifiedName()}]"))}"; + internal static string? GetUnifiedName(this Type type) { + if (type == null) { + throw new ArgumentNullException(nameof(type)); } - internal static Type? ParseType(string typeText) { - if (string.IsNullOrEmpty(typeText)) { - throw new ArgumentNullException(nameof(typeText)); - } + return type.GenericTypeArguments.Length == 0 ? type.FullName : $"{type.Namespace}.{type.Name}{string.Join("", type.GenericTypeArguments.Select(static innerType => $"[{innerType.GetUnifiedName()}]"))}"; + } - Type? targetType = Type.GetType(typeText); - - if (targetType != null) { - return targetType; - } - - // We can try one more time by trying to smartly guess the assembly name from the namespace, this will work for custom libraries like SteamKit2 - int index = typeText.IndexOf('.', StringComparison.Ordinal); - - if ((index <= 0) || (index >= typeText.Length - 1)) { - return null; - } - - return Type.GetType($"{typeText},{typeText[..index]}"); + internal static Type? ParseType(string typeText) { + if (string.IsNullOrEmpty(typeText)) { + throw new ArgumentNullException(nameof(typeText)); } - internal static async Task WriteJsonAsync(this HttpResponse response, TValue? value, JsonSerializerSettings? jsonSerializerSettings = null) { - if (response == null) { - throw new ArgumentNullException(nameof(response)); - } + Type? targetType = Type.GetType(typeText); - JsonSerializer serializer = JsonSerializer.CreateDefault(jsonSerializerSettings); + if (targetType != null) { + return targetType; + } - response.ContentType = "application/json; charset=utf-8"; + // We can try one more time by trying to smartly guess the assembly name from the namespace, this will work for custom libraries like SteamKit2 + int index = typeText.IndexOf('.', StringComparison.Ordinal); - StreamWriter streamWriter = new(response.Body, Encoding.UTF8); + if ((index <= 0) || (index >= typeText.Length - 1)) { + return null; + } - await using (streamWriter.ConfigureAwait(false)) { - using JsonTextWriter jsonWriter = new(streamWriter) { - CloseOutput = false - }; + return Type.GetType($"{typeText},{typeText[..index]}"); + } - serializer.Serialize(jsonWriter, value); - } + internal static async Task WriteJsonAsync(this HttpResponse response, TValue? value, JsonSerializerSettings? jsonSerializerSettings = null) { + if (response == null) { + throw new ArgumentNullException(nameof(response)); + } + + JsonSerializer serializer = JsonSerializer.CreateDefault(jsonSerializerSettings); + + response.ContentType = "application/json; charset=utf-8"; + + StreamWriter streamWriter = new(response.Body, Encoding.UTF8); + + await using (streamWriter.ConfigureAwait(false)) { + using JsonTextWriter jsonWriter = new(streamWriter) { + CloseOutput = false + }; + + serializer.Serialize(jsonWriter, value); } } } diff --git a/ArchiSteamFarm/NLog/ArchiLogger.cs b/ArchiSteamFarm/NLog/ArchiLogger.cs index 4d77519b3..d7b500739 100644 --- a/ArchiSteamFarm/NLog/ArchiLogger.cs +++ b/ArchiSteamFarm/NLog/ArchiLogger.cs @@ -31,154 +31,170 @@ using JetBrains.Annotations; using NLog; using SteamKit2; -namespace ArchiSteamFarm.NLog { - public sealed class ArchiLogger { - private readonly Logger Logger; +namespace ArchiSteamFarm.NLog; - public ArchiLogger(string name) { - if (string.IsNullOrEmpty(name)) { - throw new ArgumentNullException(nameof(name)); - } +public sealed class ArchiLogger { + private readonly Logger Logger; - Logger = LogManager.GetLogger(name); + public ArchiLogger(string name) { + if (string.IsNullOrEmpty(name)) { + throw new ArgumentNullException(nameof(name)); } - [PublicAPI] - public void LogGenericDebug(string message, [CallerMemberName] string? previousMethodName = null) { - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } + Logger = LogManager.GetLogger(name); + } - Logger.Debug($"{previousMethodName}() {message}"); + [PublicAPI] + public void LogGenericDebug(string message, [CallerMemberName] string? previousMethodName = null) { + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); } - [PublicAPI] - public void LogGenericDebuggingException(Exception exception, [CallerMemberName] string? previousMethodName = null) { - if (exception == null) { - throw new ArgumentNullException(nameof(exception)); - } + Logger.Debug($"{previousMethodName}() {message}"); + } - if (!Debugging.IsUserDebugging) { - return; - } - - Logger.Debug(exception, $"{previousMethodName}()"); + [PublicAPI] + public void LogGenericDebuggingException(Exception exception, [CallerMemberName] string? previousMethodName = null) { + if (exception == null) { + throw new ArgumentNullException(nameof(exception)); } - [PublicAPI] - public void LogGenericError(string message, [CallerMemberName] string? previousMethodName = null) { - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } - - Logger.Error($"{previousMethodName}() {message}"); + if (!Debugging.IsUserDebugging) { + return; } - [PublicAPI] - public void LogGenericException(Exception exception, [CallerMemberName] string? previousMethodName = null) { - if (exception == null) { - throw new ArgumentNullException(nameof(exception)); - } + Logger.Debug(exception, $"{previousMethodName}()"); + } - Logger.Error(exception, $"{previousMethodName}()"); + [PublicAPI] + public void LogGenericError(string message, [CallerMemberName] string? previousMethodName = null) { + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); } - [PublicAPI] - public void LogGenericInfo(string message, [CallerMemberName] string? previousMethodName = null) { - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } + Logger.Error($"{previousMethodName}() {message}"); + } - Logger.Info($"{previousMethodName}() {message}"); + [PublicAPI] + public void LogGenericException(Exception exception, [CallerMemberName] string? previousMethodName = null) { + if (exception == null) { + throw new ArgumentNullException(nameof(exception)); } - [PublicAPI] - public void LogGenericTrace(string message, [CallerMemberName] string? previousMethodName = null) { - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } + Logger.Error(exception, $"{previousMethodName}()"); + } - Logger.Trace($"{previousMethodName}() {message}"); + [PublicAPI] + public void LogGenericInfo(string message, [CallerMemberName] string? previousMethodName = null) { + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); } - [PublicAPI] - public void LogGenericWarning(string message, [CallerMemberName] string? previousMethodName = null) { - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } + Logger.Info($"{previousMethodName}() {message}"); + } - Logger.Warn($"{previousMethodName}() {message}"); + [PublicAPI] + public void LogGenericTrace(string message, [CallerMemberName] string? previousMethodName = null) { + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); } - [PublicAPI] - public void LogGenericWarningException(Exception exception, [CallerMemberName] string? previousMethodName = null) { - if (exception == null) { - throw new ArgumentNullException(nameof(exception)); - } + Logger.Trace($"{previousMethodName}() {message}"); + } - Logger.Warn(exception, $"{previousMethodName}()"); + [PublicAPI] + public void LogGenericWarning(string message, [CallerMemberName] string? previousMethodName = null) { + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); } - [PublicAPI] - public void LogNullError(string nullObjectName, [CallerMemberName] string? previousMethodName = null) { - if (string.IsNullOrEmpty(nullObjectName)) { - throw new ArgumentNullException(nameof(nullObjectName)); - } + Logger.Warn($"{previousMethodName}() {message}"); + } - LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nullObjectName), previousMethodName); + [PublicAPI] + public void LogGenericWarningException(Exception exception, [CallerMemberName] string? previousMethodName = null) { + if (exception == null) { + throw new ArgumentNullException(nameof(exception)); } - internal void LogChatMessage(bool echo, string message, ulong chatGroupID = 0, ulong chatID = 0, ulong steamID = 0, [CallerMemberName] string? previousMethodName = null) { - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } + Logger.Warn(exception, $"{previousMethodName}()"); + } - if (((chatGroupID == 0) || (chatID == 0)) && (steamID == 0)) { - throw new InvalidOperationException($"(({nameof(chatGroupID)} || {nameof(chatID)}) && {nameof(steamID)})"); - } - - StringBuilder loggedMessage = new($"{previousMethodName}() {message} {(echo ? "->" : "<-")} "); - - if ((chatGroupID != 0) && (chatID != 0)) { - loggedMessage.Append(CultureInfo.InvariantCulture, $"{chatGroupID}-{chatID}"); - - if (steamID != 0) { - loggedMessage.Append(CultureInfo.InvariantCulture, $"/{steamID}"); - } - } else if (steamID != 0) { - loggedMessage.Append(steamID); - } - - LogEventInfo logEventInfo = new(LogLevel.Trace, Logger.Name, loggedMessage.ToString()) { - Properties = { - ["Echo"] = echo, - ["Message"] = message, - ["ChatGroupID"] = chatGroupID, - ["ChatID"] = chatID, - ["SteamID"] = steamID - } - }; - - Logger.Log(logEventInfo); + [PublicAPI] + public void LogNullError(string nullObjectName, [CallerMemberName] string? previousMethodName = null) { + if (string.IsNullOrEmpty(nullObjectName)) { + throw new ArgumentNullException(nameof(nullObjectName)); } - internal async Task LogFatalException(Exception exception, [CallerMemberName] string? previousMethodName = null) { - if (exception == null) { - throw new ArgumentNullException(nameof(exception)); + LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nullObjectName), previousMethodName); + } + + internal void LogChatMessage(bool echo, string message, ulong chatGroupID = 0, ulong chatID = 0, ulong steamID = 0, [CallerMemberName] string? previousMethodName = null) { + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + if (((chatGroupID == 0) || (chatID == 0)) && (steamID == 0)) { + throw new InvalidOperationException($"(({nameof(chatGroupID)} || {nameof(chatID)}) && {nameof(steamID)})"); + } + + StringBuilder loggedMessage = new($"{previousMethodName}() {message} {(echo ? "->" : "<-")} "); + + if ((chatGroupID != 0) && (chatID != 0)) { + loggedMessage.Append(CultureInfo.InvariantCulture, $"{chatGroupID}-{chatID}"); + + if (steamID != 0) { + loggedMessage.Append(CultureInfo.InvariantCulture, $"/{steamID}"); } + } else if (steamID != 0) { + loggedMessage.Append(steamID); + } - Logger.Fatal(exception, $"{previousMethodName}()"); - - // If LogManager has been initialized already, don't do anything else - if (LogManager.Configuration != null) { - return; + LogEventInfo logEventInfo = new(LogLevel.Trace, Logger.Name, loggedMessage.ToString()) { + Properties = { + ["Echo"] = echo, + ["Message"] = message, + ["ChatGroupID"] = chatGroupID, + ["ChatID"] = chatID, + ["SteamID"] = steamID } + }; - // Otherwise, we ran into fatal exception before logging module could even get initialized, so activate fallback logging that involves file and console - string message = string.Format(CultureInfo.CurrentCulture, DateTime.Now + " " + Strings.ErrorEarlyFatalExceptionInfo, SharedInfo.Version) + Environment.NewLine; + Logger.Log(logEventInfo); + } + + internal async Task LogFatalException(Exception exception, [CallerMemberName] string? previousMethodName = null) { + if (exception == null) { + throw new ArgumentNullException(nameof(exception)); + } + + Logger.Fatal(exception, $"{previousMethodName}()"); + + // If LogManager has been initialized already, don't do anything else + if (LogManager.Configuration != null) { + return; + } + + // Otherwise, we ran into fatal exception before logging module could even get initialized, so activate fallback logging that involves file and console + string message = string.Format(CultureInfo.CurrentCulture, DateTime.Now + " " + Strings.ErrorEarlyFatalExceptionInfo, SharedInfo.Version) + Environment.NewLine; + + try { + await File.WriteAllTextAsync(SharedInfo.LogFile, message).ConfigureAwait(false); + } catch { + // Ignored, we can't do anything about this + } + + try { + Console.Write(message); + } catch { + // Ignored, we can't do anything about this + } + + while (true) { + message = string.Format(CultureInfo.CurrentCulture, Strings.ErrorEarlyFatalExceptionPrint, previousMethodName, exception.Message, exception.StackTrace) + Environment.NewLine; try { - await File.WriteAllTextAsync(SharedInfo.LogFile, message).ConfigureAwait(false); + await File.AppendAllTextAsync(SharedInfo.LogFile, message).ConfigureAwait(false); } catch { // Ignored, we can't do anything about this } @@ -189,49 +205,33 @@ namespace ArchiSteamFarm.NLog { // Ignored, we can't do anything about this } - while (true) { - message = string.Format(CultureInfo.CurrentCulture, Strings.ErrorEarlyFatalExceptionPrint, previousMethodName, exception.Message, exception.StackTrace) + Environment.NewLine; + if (exception.InnerException != null) { + exception = exception.InnerException; - try { - await File.AppendAllTextAsync(SharedInfo.LogFile, message).ConfigureAwait(false); - } catch { - // Ignored, we can't do anything about this - } - - try { - Console.Write(message); - } catch { - // Ignored, we can't do anything about this - } - - if (exception.InnerException != null) { - exception = exception.InnerException; - - continue; - } - - break; - } - } - - internal void LogInvite(SteamID steamID, bool? handled = null, [CallerMemberName] string? previousMethodName = null) { - if ((steamID == null) || (steamID.AccountType == EAccountType.Invalid)) { - throw new ArgumentNullException(nameof(steamID)); + continue; } - ulong steamID64 = steamID; - - string loggedMessage = $"{previousMethodName}() {steamID.AccountType} {steamID64}{(handled.HasValue ? $" = {handled.Value}" : "")}"; - - LogEventInfo logEventInfo = new(LogLevel.Trace, Logger.Name, loggedMessage) { - Properties = { - ["AccountType"] = steamID.AccountType, - ["Handled"] = handled, - ["SteamID"] = steamID64 - } - }; - - Logger.Log(logEventInfo); + break; } } + + internal void LogInvite(SteamID steamID, bool? handled = null, [CallerMemberName] string? previousMethodName = null) { + if ((steamID == null) || (steamID.AccountType == EAccountType.Invalid)) { + throw new ArgumentNullException(nameof(steamID)); + } + + ulong steamID64 = steamID; + + string loggedMessage = $"{previousMethodName}() {steamID.AccountType} {steamID64}{(handled.HasValue ? $" = {handled.Value}" : "")}"; + + LogEventInfo logEventInfo = new(LogLevel.Trace, Logger.Name, loggedMessage) { + Properties = { + ["AccountType"] = steamID.AccountType, + ["Handled"] = handled, + ["SteamID"] = steamID64 + } + }; + + Logger.Log(logEventInfo); + } } diff --git a/ArchiSteamFarm/NLog/Logging.cs b/ArchiSteamFarm/NLog/Logging.cs index b0a4bf142..9dd3ca195 100644 --- a/ArchiSteamFarm/NLog/Logging.cs +++ b/ArchiSteamFarm/NLog/Logging.cs @@ -38,408 +38,408 @@ using NLog; using NLog.Config; using NLog.Targets; -namespace ArchiSteamFarm.NLog { - internal static class Logging { - internal const string NLogConfigurationFile = "NLog.config"; +namespace ArchiSteamFarm.NLog; - private const byte ConsoleResponsivenessDelay = 250; // In milliseconds - private const string GeneralLayout = @"${date:format=yyyy-MM-dd HH\:mm\:ss}|${processname}-${processid}|${level:uppercase=true}|" + LayoutMessage; - private const string LayoutMessage = @"${logger}|${message}${onexception:inner= ${exception:format=toString,Data}}"; +internal static class Logging { + internal const string NLogConfigurationFile = "NLog.config"; - private static readonly ConcurrentHashSet ConsoleLoggingRules = new(); - private static readonly SemaphoreSlim ConsoleSemaphore = new(1, 1); + private const byte ConsoleResponsivenessDelay = 250; // In milliseconds + private const string GeneralLayout = @"${date:format=yyyy-MM-dd HH\:mm\:ss}|${processname}-${processid}|${level:uppercase=true}|" + LayoutMessage; + private const string LayoutMessage = @"${logger}|${message}${onexception:inner= ${exception:format=toString,Data}}"; - private static string Backspace => "\b \b"; + private static readonly ConcurrentHashSet ConsoleLoggingRules = new(); + private static readonly SemaphoreSlim ConsoleSemaphore = new(1, 1); - private static bool IsUsingCustomConfiguration; - private static bool IsWaitingForUserInput; + private static string Backspace => "\b \b"; - internal static void EnableTraceLogging() { - if (IsUsingCustomConfiguration || (LogManager.Configuration == null)) { - return; - } + private static bool IsUsingCustomConfiguration; + private static bool IsWaitingForUserInput; - bool reload = false; - - foreach (LoggingRule rule in LogManager.Configuration.LoggingRules.Where(static rule => rule.IsLoggingEnabledForLevel(LogLevel.Debug) && !rule.IsLoggingEnabledForLevel(LogLevel.Trace))) { - rule.EnableLoggingForLevel(LogLevel.Trace); - reload = true; - } - - if (reload) { - LogManager.ReconfigExistingLoggers(); - } + internal static void EnableTraceLogging() { + if (IsUsingCustomConfiguration || (LogManager.Configuration == null)) { + return; } - internal static async Task GetUserInput(ASF.EUserInputType userInputType, string botName = SharedInfo.ASF) { - if ((userInputType == ASF.EUserInputType.None) || !Enum.IsDefined(typeof(ASF.EUserInputType), userInputType)) { - throw new InvalidEnumArgumentException(nameof(userInputType), (int) userInputType, typeof(ASF.EUserInputType)); - } + bool reload = false; - if (string.IsNullOrEmpty(botName)) { - throw new ArgumentNullException(nameof(botName)); - } + foreach (LoggingRule rule in LogManager.Configuration.LoggingRules.Where(static rule => rule.IsLoggingEnabledForLevel(LogLevel.Debug) && !rule.IsLoggingEnabledForLevel(LogLevel.Trace))) { + rule.EnableLoggingForLevel(LogLevel.Trace); + reload = true; + } - if (Program.Service || (ASF.GlobalConfig?.Headless ?? GlobalConfig.DefaultHeadless)) { - ASF.ArchiLogger.LogGenericWarning(Strings.ErrorUserInputRunningInHeadlessMode); + if (reload) { + LogManager.ReconfigExistingLoggers(); + } + } + + internal static async Task GetUserInput(ASF.EUserInputType userInputType, string botName = SharedInfo.ASF) { + if ((userInputType == ASF.EUserInputType.None) || !Enum.IsDefined(typeof(ASF.EUserInputType), userInputType)) { + throw new InvalidEnumArgumentException(nameof(userInputType), (int) userInputType, typeof(ASF.EUserInputType)); + } + + if (string.IsNullOrEmpty(botName)) { + throw new ArgumentNullException(nameof(botName)); + } + + if (Program.Service || (ASF.GlobalConfig?.Headless ?? GlobalConfig.DefaultHeadless)) { + ASF.ArchiLogger.LogGenericWarning(Strings.ErrorUserInputRunningInHeadlessMode); + + return null; + } + + await ConsoleSemaphore.WaitAsync().ConfigureAwait(false); + + string? result; + + try { + OnUserInputStart(); + + try { + switch (userInputType) { + case ASF.EUserInputType.Login: + Console.Write(Bot.FormatBotResponse(Strings.UserInputSteamLogin, botName)); + result = ConsoleReadLine(); + + break; + case ASF.EUserInputType.Password: + Console.Write(Bot.FormatBotResponse(Strings.UserInputSteamPassword, botName)); + result = ConsoleReadLineMasked(); + + break; + case ASF.EUserInputType.SteamGuard: + Console.Write(Bot.FormatBotResponse(Strings.UserInputSteamGuard, botName)); + result = ConsoleReadLine(); + + break; + case ASF.EUserInputType.SteamParentalCode: + Console.Write(Bot.FormatBotResponse(Strings.UserInputSteamParentalCode, botName)); + result = ConsoleReadLineMasked(); + + break; + case ASF.EUserInputType.TwoFactorAuthentication: + Console.Write(Bot.FormatBotResponse(Strings.UserInputSteam2FA, botName)); + result = ConsoleReadLine(); + + break; + default: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(userInputType), userInputType)); + + return null; + } + + if (!Console.IsOutputRedirected) { + Console.Clear(); // For security purposes + } + } catch (Exception e) { + OnUserInputEnd(); + ASF.ArchiLogger.LogGenericException(e); return null; - } - - await ConsoleSemaphore.WaitAsync().ConfigureAwait(false); - - string? result; - - try { - OnUserInputStart(); - - try { - switch (userInputType) { - case ASF.EUserInputType.Login: - Console.Write(Bot.FormatBotResponse(Strings.UserInputSteamLogin, botName)); - result = ConsoleReadLine(); - - break; - case ASF.EUserInputType.Password: - Console.Write(Bot.FormatBotResponse(Strings.UserInputSteamPassword, botName)); - result = ConsoleReadLineMasked(); - - break; - case ASF.EUserInputType.SteamGuard: - Console.Write(Bot.FormatBotResponse(Strings.UserInputSteamGuard, botName)); - result = ConsoleReadLine(); - - break; - case ASF.EUserInputType.SteamParentalCode: - Console.Write(Bot.FormatBotResponse(Strings.UserInputSteamParentalCode, botName)); - result = ConsoleReadLineMasked(); - - break; - case ASF.EUserInputType.TwoFactorAuthentication: - Console.Write(Bot.FormatBotResponse(Strings.UserInputSteam2FA, botName)); - result = ConsoleReadLine(); - - break; - default: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(userInputType), userInputType)); - - return null; - } - - if (!Console.IsOutputRedirected) { - Console.Clear(); // For security purposes - } - } catch (Exception e) { - OnUserInputEnd(); - ASF.ArchiLogger.LogGenericException(e); - - return null; - } finally { - OnUserInputEnd(); - } } finally { - ConsoleSemaphore.Release(); + OnUserInputEnd(); } - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - return !string.IsNullOrEmpty(result) ? result!.Trim() : null; + } finally { + ConsoleSemaphore.Release(); } - internal static void InitCoreLoggers(bool uniqueInstance) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + return !string.IsNullOrEmpty(result) ? result!.Trim() : null; + } + + internal static void InitCoreLoggers(bool uniqueInstance) { + try { + if ((Directory.GetCurrentDirectory() != AppContext.BaseDirectory) && File.Exists(NLogConfigurationFile)) { + LogManager.Configuration = new XmlLoggingConfiguration(NLogConfigurationFile); + } + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + + if (LogManager.Configuration != null) { + IsUsingCustomConfiguration = true; + InitConsoleLoggers(); + LogManager.ConfigurationChanged += OnConfigurationChanged; + + return; + } + + ConfigurationItemFactory.Default.ParseMessageTemplates = false; + LoggingConfiguration config = new(); + +#pragma warning disable CA2000 // False positive, we're adding this disposable object to the global scope, so we can't dispose it + ColoredConsoleTarget coloredConsoleTarget = new("ColoredConsole") { Layout = GeneralLayout }; +#pragma warning restore CA2000 // False positive, we're adding this disposable object to the global scope, so we can't dispose it + + config.AddTarget(coloredConsoleTarget); + config.LoggingRules.Add(new LoggingRule("*", LogLevel.Debug, coloredConsoleTarget)); + + if (uniqueInstance) { try { - if ((Directory.GetCurrentDirectory() != AppContext.BaseDirectory) && File.Exists(NLogConfigurationFile)) { - LogManager.Configuration = new XmlLoggingConfiguration(NLogConfigurationFile); + if (!Directory.Exists(SharedInfo.ArchivalLogsDirectory)) { + Directory.CreateDirectory(SharedInfo.ArchivalLogsDirectory); } } catch (Exception e) { ASF.ArchiLogger.LogGenericException(e); } - if (LogManager.Configuration != null) { - IsUsingCustomConfiguration = true; - InitConsoleLoggers(); - LogManager.ConfigurationChanged += OnConfigurationChanged; - - return; - } - - ConfigurationItemFactory.Default.ParseMessageTemplates = false; - LoggingConfiguration config = new(); - #pragma warning disable CA2000 // False positive, we're adding this disposable object to the global scope, so we can't dispose it - ColoredConsoleTarget coloredConsoleTarget = new("ColoredConsole") { Layout = GeneralLayout }; + FileTarget fileTarget = new("File") { + ArchiveFileName = Path.Combine("${currentdir}", SharedInfo.ArchivalLogsDirectory, SharedInfo.ArchivalLogFile), + ArchiveNumbering = ArchiveNumberingMode.Rolling, + ArchiveOldFileOnStartup = true, + CleanupFileName = false, + ConcurrentWrites = false, + DeleteOldFileOnStartup = true, + FileName = Path.Combine("${currentdir}", SharedInfo.LogFile), + Layout = GeneralLayout, + MaxArchiveFiles = 10 + }; #pragma warning restore CA2000 // False positive, we're adding this disposable object to the global scope, so we can't dispose it - config.AddTarget(coloredConsoleTarget); - config.LoggingRules.Add(new LoggingRule("*", LogLevel.Debug, coloredConsoleTarget)); - - if (uniqueInstance) { - try { - if (!Directory.Exists(SharedInfo.ArchivalLogsDirectory)) { - Directory.CreateDirectory(SharedInfo.ArchivalLogsDirectory); - } - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - -#pragma warning disable CA2000 // False positive, we're adding this disposable object to the global scope, so we can't dispose it - FileTarget fileTarget = new("File") { - ArchiveFileName = Path.Combine("${currentdir}", SharedInfo.ArchivalLogsDirectory, SharedInfo.ArchivalLogFile), - ArchiveNumbering = ArchiveNumberingMode.Rolling, - ArchiveOldFileOnStartup = true, - CleanupFileName = false, - ConcurrentWrites = false, - DeleteOldFileOnStartup = true, - FileName = Path.Combine("${currentdir}", SharedInfo.LogFile), - Layout = GeneralLayout, - MaxArchiveFiles = 10 - }; -#pragma warning restore CA2000 // False positive, we're adding this disposable object to the global scope, so we can't dispose it - - config.AddTarget(fileTarget); - config.LoggingRules.Add(new LoggingRule("*", LogLevel.Debug, fileTarget)); - } - - LogManager.Configuration = config; - InitConsoleLoggers(); + config.AddTarget(fileTarget); + config.LoggingRules.Add(new LoggingRule("*", LogLevel.Debug, fileTarget)); } - internal static void InitHistoryLogger() { - if (LogManager.Configuration == null) { + LogManager.Configuration = config; + InitConsoleLoggers(); + } + + internal static void InitHistoryLogger() { + if (LogManager.Configuration == null) { + return; + } + + HistoryTarget? historyTarget = LogManager.Configuration.AllTargets.OfType().FirstOrDefault(); + + if ((historyTarget == null) && !IsUsingCustomConfiguration) { + historyTarget = new HistoryTarget("History") { + Layout = GeneralLayout, + MaxCount = 20 + }; + + LogManager.Configuration.AddTarget(historyTarget); + LogManager.Configuration.LoggingRules.Add(new LoggingRule("*", LogLevel.Debug, historyTarget)); + + LogManager.ReconfigExistingLoggers(); + } + + ArchiKestrel.OnNewHistoryTarget(historyTarget); + } + + internal static void StartInteractiveConsole() { + if ((ASF.GlobalConfig?.SteamOwnerID ?? GlobalConfig.DefaultSteamOwnerID) == 0) { + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.InteractiveConsoleNotAvailable, nameof(ASF.GlobalConfig.SteamOwnerID))); + + return; + } + + Utilities.InBackground(HandleConsoleInteractively, true); + ASF.ArchiLogger.LogGenericInfo(Strings.InteractiveConsoleEnabled); + } + + private static async Task BeepUntilCanceled(CancellationToken cancellationToken, byte secondsDelay = 30) { + if (secondsDelay == 0) { + throw new ArgumentOutOfRangeException(nameof(secondsDelay)); + } + + while (!cancellationToken.IsCancellationRequested) { + try { + await Task.Delay(secondsDelay * 1000, cancellationToken).ConfigureAwait(false); + } catch (TaskCanceledException) { return; } - HistoryTarget? historyTarget = LogManager.Configuration.AllTargets.OfType().FirstOrDefault(); - - if ((historyTarget == null) && !IsUsingCustomConfiguration) { - historyTarget = new HistoryTarget("History") { - Layout = GeneralLayout, - MaxCount = 20 - }; - - LogManager.Configuration.AddTarget(historyTarget); - LogManager.Configuration.LoggingRules.Add(new LoggingRule("*", LogLevel.Debug, historyTarget)); - - LogManager.ReconfigExistingLoggers(); - } - - ArchiKestrel.OnNewHistoryTarget(historyTarget); + Console.Beep(); } + } - internal static void StartInteractiveConsole() { - if ((ASF.GlobalConfig?.SteamOwnerID ?? GlobalConfig.DefaultSteamOwnerID) == 0) { - ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.InteractiveConsoleNotAvailable, nameof(ASF.GlobalConfig.SteamOwnerID))); + private static string? ConsoleReadLine() { + using CancellationTokenSource cts = new(); - return; - } + try { + CancellationToken token = cts.Token; - Utilities.InBackground(HandleConsoleInteractively, true); - ASF.ArchiLogger.LogGenericInfo(Strings.InteractiveConsoleEnabled); + Utilities.InBackground(() => BeepUntilCanceled(token)); + + return Console.ReadLine(); + } finally { + cts.Cancel(); } + } - private static async Task BeepUntilCanceled(CancellationToken cancellationToken, byte secondsDelay = 30) { - if (secondsDelay == 0) { - throw new ArgumentOutOfRangeException(nameof(secondsDelay)); - } + private static string ConsoleReadLineMasked(char mask = '*') { + using CancellationTokenSource cts = new(); - while (!cancellationToken.IsCancellationRequested) { - try { - await Task.Delay(secondsDelay * 1000, cancellationToken).ConfigureAwait(false); - } catch (TaskCanceledException) { - return; - } + try { + CancellationToken token = cts.Token; - Console.Beep(); - } - } + Utilities.InBackground(() => BeepUntilCanceled(token)); - private static string? ConsoleReadLine() { - using CancellationTokenSource cts = new(); + StringBuilder result = new(); - try { - CancellationToken token = cts.Token; + ConsoleKeyInfo keyInfo; - Utilities.InBackground(() => BeepUntilCanceled(token)); + while ((keyInfo = Console.ReadKey(true)).Key != ConsoleKey.Enter) { + if (!char.IsControl(keyInfo.KeyChar)) { + result.Append(keyInfo.KeyChar); + Console.Write(mask); + } else if ((keyInfo.Key == ConsoleKey.Backspace) && (result.Length > 0)) { + result.Length--; - return Console.ReadLine(); - } finally { - cts.Cancel(); - } - } - - private static string ConsoleReadLineMasked(char mask = '*') { - using CancellationTokenSource cts = new(); - - try { - CancellationToken token = cts.Token; - - Utilities.InBackground(() => BeepUntilCanceled(token)); - - StringBuilder result = new(); - - ConsoleKeyInfo keyInfo; - - while ((keyInfo = Console.ReadKey(true)).Key != ConsoleKey.Enter) { - if (!char.IsControl(keyInfo.KeyChar)) { - result.Append(keyInfo.KeyChar); - Console.Write(mask); - } else if ((keyInfo.Key == ConsoleKey.Backspace) && (result.Length > 0)) { - result.Length--; - - if (Console.CursorLeft == 0) { - Console.SetCursorPosition(Console.BufferWidth - 1, Console.CursorTop - 1); - Console.Write(' '); - Console.SetCursorPosition(Console.BufferWidth - 1, Console.CursorTop - 1); - } else { - Console.Write(Backspace); - } + if (Console.CursorLeft == 0) { + Console.SetCursorPosition(Console.BufferWidth - 1, Console.CursorTop - 1); + Console.Write(' '); + Console.SetCursorPosition(Console.BufferWidth - 1, Console.CursorTop - 1); + } else { + Console.Write(Backspace); } } - - Console.WriteLine(); - - return result.ToString(); - } finally { - cts.Cancel(); } - } - private static async Task HandleConsoleInteractively() { - while (!Program.ShutdownSequenceInitialized) { + Console.WriteLine(); + + return result.ToString(); + } finally { + cts.Cancel(); + } + } + + private static async Task HandleConsoleInteractively() { + while (!Program.ShutdownSequenceInitialized) { + try { + if (IsWaitingForUserInput || !Console.KeyAvailable) { + continue; + } + + await ConsoleSemaphore.WaitAsync().ConfigureAwait(false); + try { - if (IsWaitingForUserInput || !Console.KeyAvailable) { + ConsoleKeyInfo keyInfo = Console.ReadKey(true); + + if (keyInfo.Key != ConsoleKey.C) { continue; } - await ConsoleSemaphore.WaitAsync().ConfigureAwait(false); + OnUserInputStart(); try { - ConsoleKeyInfo keyInfo = Console.ReadKey(true); + Console.Write($@">> {Strings.EnterCommand}"); + string? command = ConsoleReadLine(); - if (keyInfo.Key != ConsoleKey.C) { + if (string.IsNullOrEmpty(command)) { continue; } - OnUserInputStart(); + string? commandPrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.CommandPrefix : GlobalConfig.DefaultCommandPrefix; - try { - Console.Write($@">> {Strings.EnterCommand}"); - string? command = ConsoleReadLine(); + // ReSharper disable RedundantSuppressNullableWarningExpression - required for .NET Framework + if (!string.IsNullOrEmpty(commandPrefix) && command!.StartsWith(commandPrefix!, StringComparison.Ordinal)) { + // ReSharper restore RedundantSuppressNullableWarningExpression - required for .NET Framework - if (string.IsNullOrEmpty(command)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (command.Length == commandPrefix!.Length) { + // If the message starts with command prefix and is of the same length as command prefix, then it's just empty command trigger, useless continue; } - string? commandPrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.CommandPrefix : GlobalConfig.DefaultCommandPrefix; - - // ReSharper disable RedundantSuppressNullableWarningExpression - required for .NET Framework - if (!string.IsNullOrEmpty(commandPrefix) && command!.StartsWith(commandPrefix!, StringComparison.Ordinal)) { - // ReSharper restore RedundantSuppressNullableWarningExpression - required for .NET Framework - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (command.Length == commandPrefix!.Length) { - // If the message starts with command prefix and is of the same length as command prefix, then it's just empty command trigger, useless - continue; - } - - command = command[commandPrefix.Length..]; - } - - Bot? targetBot = Bot.Bots?.OrderBy(static bot => bot.Key, Bot.BotsComparer).Select(static bot => bot.Value).FirstOrDefault(); - - if (targetBot == null) { - Console.WriteLine($@"<< {Strings.ErrorNoBotsDefined}"); - - continue; - } - - Console.WriteLine($@"<> {Strings.Executing}"); - - ulong steamOwnerID = ASF.GlobalConfig?.SteamOwnerID ?? GlobalConfig.DefaultSteamOwnerID; - - string? response = await targetBot.Commands.Response(steamOwnerID, command!).ConfigureAwait(false); - - if (string.IsNullOrEmpty(response)) { - ASF.ArchiLogger.LogNullError(nameof(response)); - Console.WriteLine(Strings.ErrorIsEmpty, nameof(response)); - - continue; - } - - Console.WriteLine($@"<< {response}"); - } finally { - OnUserInputEnd(); + command = command[commandPrefix.Length..]; } + + Bot? targetBot = Bot.Bots?.OrderBy(static bot => bot.Key, Bot.BotsComparer).Select(static bot => bot.Value).FirstOrDefault(); + + if (targetBot == null) { + Console.WriteLine($@"<< {Strings.ErrorNoBotsDefined}"); + + continue; + } + + Console.WriteLine($@"<> {Strings.Executing}"); + + ulong steamOwnerID = ASF.GlobalConfig?.SteamOwnerID ?? GlobalConfig.DefaultSteamOwnerID; + + string? response = await targetBot.Commands.Response(steamOwnerID, command!).ConfigureAwait(false); + + if (string.IsNullOrEmpty(response)) { + ASF.ArchiLogger.LogNullError(nameof(response)); + Console.WriteLine(Strings.ErrorIsEmpty, nameof(response)); + + continue; + } + + Console.WriteLine($@"<< {response}"); } finally { - ConsoleSemaphore.Release(); + OnUserInputEnd(); } - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return; } finally { - await Task.Delay(ConsoleResponsivenessDelay).ConfigureAwait(false); + ConsoleSemaphore.Release(); } - } - } + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); - private static void InitConsoleLoggers() { - ConsoleLoggingRules.Clear(); - - foreach (LoggingRule loggingRule in LogManager.Configuration.LoggingRules.Where(static loggingRule => loggingRule.Targets.Any(static target => target is ColoredConsoleTarget or ConsoleTarget))) { - ConsoleLoggingRules.Add(loggingRule); - } - } - - private static void OnConfigurationChanged(object? sender, LoggingConfigurationChangedEventArgs e) { - if (e == null) { - throw new ArgumentNullException(nameof(e)); - } - - InitConsoleLoggers(); - - if (IsWaitingForUserInput) { - OnUserInputStart(); - } - - HistoryTarget? historyTarget = LogManager.Configuration.AllTargets.OfType().FirstOrDefault(); - ArchiKestrel.OnNewHistoryTarget(historyTarget); - } - - private static void OnUserInputEnd() { - IsWaitingForUserInput = false; - - if (ConsoleLoggingRules.Count == 0) { return; - } - - bool reconfigure = false; - - foreach (LoggingRule consoleLoggingRule in ConsoleLoggingRules.Where(static consoleLoggingRule => !LogManager.Configuration.LoggingRules.Contains(consoleLoggingRule))) { - LogManager.Configuration.LoggingRules.Add(consoleLoggingRule); - reconfigure = true; - } - - if (reconfigure) { - LogManager.ReconfigExistingLoggers(); - } - } - - private static void OnUserInputStart() { - IsWaitingForUserInput = true; - - if (ConsoleLoggingRules.Count == 0) { - return; - } - - bool reconfigure = false; - - foreach (LoggingRule _ in ConsoleLoggingRules.Where(static consoleLoggingRule => LogManager.Configuration.LoggingRules.Remove(consoleLoggingRule))) { - reconfigure = true; - } - - if (reconfigure) { - LogManager.ReconfigExistingLoggers(); + } finally { + await Task.Delay(ConsoleResponsivenessDelay).ConfigureAwait(false); } } } + + private static void InitConsoleLoggers() { + ConsoleLoggingRules.Clear(); + + foreach (LoggingRule loggingRule in LogManager.Configuration.LoggingRules.Where(static loggingRule => loggingRule.Targets.Any(static target => target is ColoredConsoleTarget or ConsoleTarget))) { + ConsoleLoggingRules.Add(loggingRule); + } + } + + private static void OnConfigurationChanged(object? sender, LoggingConfigurationChangedEventArgs e) { + if (e == null) { + throw new ArgumentNullException(nameof(e)); + } + + InitConsoleLoggers(); + + if (IsWaitingForUserInput) { + OnUserInputStart(); + } + + HistoryTarget? historyTarget = LogManager.Configuration.AllTargets.OfType().FirstOrDefault(); + ArchiKestrel.OnNewHistoryTarget(historyTarget); + } + + private static void OnUserInputEnd() { + IsWaitingForUserInput = false; + + if (ConsoleLoggingRules.Count == 0) { + return; + } + + bool reconfigure = false; + + foreach (LoggingRule consoleLoggingRule in ConsoleLoggingRules.Where(static consoleLoggingRule => !LogManager.Configuration.LoggingRules.Contains(consoleLoggingRule))) { + LogManager.Configuration.LoggingRules.Add(consoleLoggingRule); + reconfigure = true; + } + + if (reconfigure) { + LogManager.ReconfigExistingLoggers(); + } + } + + private static void OnUserInputStart() { + IsWaitingForUserInput = true; + + if (ConsoleLoggingRules.Count == 0) { + return; + } + + bool reconfigure = false; + + foreach (LoggingRule _ in ConsoleLoggingRules.Where(static consoleLoggingRule => LogManager.Configuration.LoggingRules.Remove(consoleLoggingRule))) { + reconfigure = true; + } + + if (reconfigure) { + LogManager.ReconfigExistingLoggers(); + } + } } diff --git a/ArchiSteamFarm/NLog/Targets/HistoryTarget.cs b/ArchiSteamFarm/NLog/Targets/HistoryTarget.cs index 4248dd746..a160e435a 100644 --- a/ArchiSteamFarm/NLog/Targets/HistoryTarget.cs +++ b/ArchiSteamFarm/NLog/Targets/HistoryTarget.cs @@ -27,59 +27,59 @@ using JetBrains.Annotations; using NLog; using NLog.Targets; -namespace ArchiSteamFarm.NLog.Targets { - [Target(TargetName)] - internal sealed class HistoryTarget : TargetWithLayout { - internal const string TargetName = "History"; +namespace ArchiSteamFarm.NLog.Targets; - private const byte DefaultMaxCount = 20; +[Target(TargetName)] +internal sealed class HistoryTarget : TargetWithLayout { + internal const string TargetName = "History"; - internal IEnumerable ArchivedMessages => HistoryQueue; + private const byte DefaultMaxCount = 20; - private readonly FixedSizeConcurrentQueue HistoryQueue = new(DefaultMaxCount); + internal IEnumerable ArchivedMessages => HistoryQueue; - // This is NLog config property, it must have public get() and set() capabilities - [UsedImplicitly] - public byte MaxCount { - get => HistoryQueue.MaxCount; + private readonly FixedSizeConcurrentQueue HistoryQueue = new(DefaultMaxCount); - set { - if (value == 0) { - ASF.ArchiLogger.LogNullError(nameof(value)); + // This is NLog config property, it must have public get() and set() capabilities + [UsedImplicitly] + public byte MaxCount { + get => HistoryQueue.MaxCount; - return; - } + set { + if (value == 0) { + ASF.ArchiLogger.LogNullError(nameof(value)); - HistoryQueue.MaxCount = value; - } - } - - // This parameter-less constructor is intentionally public, as NLog uses it for creating targets - // It must stay like this as we want to have our targets defined in our NLog.config - [UsedImplicitly] - public HistoryTarget() { } - - internal HistoryTarget(string name) : this() => Name = name; - - protected override void Write(LogEventInfo logEvent) { - if (logEvent == null) { - throw new ArgumentNullException(nameof(logEvent)); + return; } - base.Write(logEvent); - - string message = Layout.Render(logEvent); - - HistoryQueue.Enqueue(message); - NewHistoryEntry?.Invoke(this, new NewHistoryEntryArgs(message)); - } - - internal event EventHandler? NewHistoryEntry; - - internal sealed class NewHistoryEntryArgs : EventArgs { - internal readonly string Message; - - internal NewHistoryEntryArgs(string message) => Message = message ?? throw new ArgumentNullException(nameof(message)); + HistoryQueue.MaxCount = value; } } + + // This parameter-less constructor is intentionally public, as NLog uses it for creating targets + // It must stay like this as we want to have our targets defined in our NLog.config + [UsedImplicitly] + public HistoryTarget() { } + + internal HistoryTarget(string name) : this() => Name = name; + + protected override void Write(LogEventInfo logEvent) { + if (logEvent == null) { + throw new ArgumentNullException(nameof(logEvent)); + } + + base.Write(logEvent); + + string message = Layout.Render(logEvent); + + HistoryQueue.Enqueue(message); + NewHistoryEntry?.Invoke(this, new NewHistoryEntryArgs(message)); + } + + internal event EventHandler? NewHistoryEntry; + + internal sealed class NewHistoryEntryArgs : EventArgs { + internal readonly string Message; + + internal NewHistoryEntryArgs(string message) => Message = message ?? throw new ArgumentNullException(nameof(message)); + } } diff --git a/ArchiSteamFarm/NLog/Targets/SteamTarget.cs b/ArchiSteamFarm/NLog/Targets/SteamTarget.cs index bdb95e018..95b8576a2 100644 --- a/ArchiSteamFarm/NLog/Targets/SteamTarget.cs +++ b/ArchiSteamFarm/NLog/Targets/SteamTarget.cs @@ -33,107 +33,107 @@ using NLog.Config; using NLog.Layouts; using NLog.Targets; -namespace ArchiSteamFarm.NLog.Targets { - [SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] - [Target(TargetName)] - internal sealed class SteamTarget : AsyncTaskTarget { - internal const string TargetName = "Steam"; +namespace ArchiSteamFarm.NLog.Targets; - // This is NLog config property, it must have public get() and set() capabilities - [UsedImplicitly] - public Layout? BotName { get; set; } +[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] +[Target(TargetName)] +internal sealed class SteamTarget : AsyncTaskTarget { + internal const string TargetName = "Steam"; - // This is NLog config property, it must have public get() and set() capabilities - [UsedImplicitly] - public ulong ChatGroupID { get; set; } + // This is NLog config property, it must have public get() and set() capabilities + [UsedImplicitly] + public Layout? BotName { get; set; } - // This is NLog config property, it must have public get() and set() capabilities - [RequiredParameter] - [UsedImplicitly] - public ulong SteamID { get; set; } + // This is NLog config property, it must have public get() and set() capabilities + [UsedImplicitly] + public ulong ChatGroupID { get; set; } - // This parameter-less constructor is intentionally public, as NLog uses it for creating targets - // It must stay like this as we want to have our targets defined in our NLog.config - // Keeping date in default layout also doesn't make much sense (Steam offers that), so we remove it by default - public SteamTarget() => Layout = "${level:uppercase=true}|${logger}|${message}"; + // This is NLog config property, it must have public get() and set() capabilities + [RequiredParameter] + [UsedImplicitly] + public ulong SteamID { get; set; } - protected override async Task WriteAsyncTask(LogEventInfo logEvent, CancellationToken cancellationToken) { - if (logEvent == null) { - throw new ArgumentNullException(nameof(logEvent)); - } + // This parameter-less constructor is intentionally public, as NLog uses it for creating targets + // It must stay like this as we want to have our targets defined in our NLog.config + // Keeping date in default layout also doesn't make much sense (Steam offers that), so we remove it by default + public SteamTarget() => Layout = "${level:uppercase=true}|${logger}|${message}"; - base.Write(logEvent); - - if ((SteamID == 0) || (Bot.Bots == null) || Bot.Bots.IsEmpty) { - return; - } - - string message = Layout.Render(logEvent); - - if (string.IsNullOrEmpty(message)) { - return; - } - - Bot? bot = null; - - string? botName = BotName?.Render(logEvent); - - if (!string.IsNullOrEmpty(botName)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - bot = Bot.GetBot(botName!); - - if (bot?.IsConnectedAndLoggedOn != true) { - return; - } - } - - Task task; - - if (ChatGroupID != 0) { - task = SendGroupMessage(message, bot); - } else if (bot?.SteamID != SteamID) { - task = SendPrivateMessage(message, bot); - } else { - return; - } - - await task.ConfigureAwait(false); + protected override async Task WriteAsyncTask(LogEventInfo logEvent, CancellationToken cancellationToken) { + if (logEvent == null) { + throw new ArgumentNullException(nameof(logEvent)); } - private async Task SendGroupMessage(string message, Bot? bot = null) { - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } + base.Write(logEvent); - if (bot == null) { - bot = Bot.Bots?.Values.FirstOrDefault(static targetBot => targetBot.IsConnectedAndLoggedOn); + if ((SteamID == 0) || (Bot.Bots == null) || Bot.Bots.IsEmpty) { + return; + } - if (bot == null) { - return; - } - } + string message = Layout.Render(logEvent); - if (!await bot.SendMessage(ChatGroupID, SteamID, message).ConfigureAwait(false)) { - bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendMessage))); + if (string.IsNullOrEmpty(message)) { + return; + } + + Bot? bot = null; + + string? botName = BotName?.Render(logEvent); + + if (!string.IsNullOrEmpty(botName)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + bot = Bot.GetBot(botName!); + + if (bot?.IsConnectedAndLoggedOn != true) { + return; } } - private async Task SendPrivateMessage(string message, Bot? bot = null) { - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } + Task task; + + if (ChatGroupID != 0) { + task = SendGroupMessage(message, bot); + } else if (bot?.SteamID != SteamID) { + task = SendPrivateMessage(message, bot); + } else { + return; + } + + await task.ConfigureAwait(false); + } + + private async Task SendGroupMessage(string message, Bot? bot = null) { + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + if (bot == null) { + bot = Bot.Bots?.Values.FirstOrDefault(static targetBot => targetBot.IsConnectedAndLoggedOn); if (bot == null) { - bot = Bot.Bots?.Values.FirstOrDefault(targetBot => targetBot.IsConnectedAndLoggedOn && (targetBot.SteamID != SteamID)); - - if (bot == null) { - return; - } + return; } + } - if (!await bot.SendMessage(SteamID, message).ConfigureAwait(false)) { - bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendMessage))); + if (!await bot.SendMessage(ChatGroupID, SteamID, message).ConfigureAwait(false)) { + bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendMessage))); + } + } + + private async Task SendPrivateMessage(string message, Bot? bot = null) { + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + if (bot == null) { + bot = Bot.Bots?.Values.FirstOrDefault(targetBot => targetBot.IsConnectedAndLoggedOn && (targetBot.SteamID != SteamID)); + + if (bot == null) { + return; } } + + if (!await bot.SendMessage(SteamID, message).ConfigureAwait(false)) { + bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendMessage))); + } } } diff --git a/ArchiSteamFarm/Plugins/Interfaces/IASF.cs b/ArchiSteamFarm/Plugins/Interfaces/IASF.cs index aa12bfd8a..c1197594b 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/IASF.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/IASF.cs @@ -24,13 +24,13 @@ using JetBrains.Annotations; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface IASF : IPlugin { - /// - /// ASF will call this method right after global config initialization. - /// - /// Extra config properties made out of . Can be null if no extra properties are found. - void OnASFInit(IReadOnlyDictionary? additionalConfigProperties = null); - } +namespace ArchiSteamFarm.Plugins.Interfaces; + +[PublicAPI] +public interface IASF : IPlugin { + /// + /// ASF will call this method right after global config initialization. + /// + /// Extra config properties made out of . Can be null if no extra properties are found. + void OnASFInit(IReadOnlyDictionary? additionalConfigProperties = null); } diff --git a/ArchiSteamFarm/Plugins/Interfaces/IBot.cs b/ArchiSteamFarm/Plugins/Interfaces/IBot.cs index 486f19d9a..531a54ba4 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/IBot.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/IBot.cs @@ -22,22 +22,22 @@ using ArchiSteamFarm.Steam; using JetBrains.Annotations; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface IBot : IPlugin { - /// - /// ASF will call this method after removing its own references from it, e.g. after config removal. - /// You should ensure that you'll remove any of your own references to this bot instance in timely manner. - /// Doing so will allow the garbage collector to dispose the bot afterwards, refraining from doing so will create a "memory leak" by keeping the reference alive. - /// - /// Bot object related to this callback. - void OnBotDestroy(Bot bot); +namespace ArchiSteamFarm.Plugins.Interfaces; - /// - /// ASF will call this method after creating the bot object, e.g. after config creation. - /// Bot config is not yet available at this stage. This function will execute only once for every bot object. - /// - /// Bot object related to this callback. - void OnBotInit(Bot bot); - } +[PublicAPI] +public interface IBot : IPlugin { + /// + /// ASF will call this method after removing its own references from it, e.g. after config removal. + /// You should ensure that you'll remove any of your own references to this bot instance in timely manner. + /// Doing so will allow the garbage collector to dispose the bot afterwards, refraining from doing so will create a "memory leak" by keeping the reference alive. + /// + /// Bot object related to this callback. + void OnBotDestroy(Bot bot); + + /// + /// ASF will call this method after creating the bot object, e.g. after config creation. + /// Bot config is not yet available at this stage. This function will execute only once for every bot object. + /// + /// Bot object related to this callback. + void OnBotInit(Bot bot); } diff --git a/ArchiSteamFarm/Plugins/Interfaces/IBotCardsFarmerInfo.cs b/ArchiSteamFarm/Plugins/Interfaces/IBotCardsFarmerInfo.cs index 4579a20f7..750573bd1 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/IBotCardsFarmerInfo.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/IBotCardsFarmerInfo.cs @@ -22,26 +22,26 @@ using ArchiSteamFarm.Steam; using JetBrains.Annotations; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface IBotCardsFarmerInfo : IPlugin { - /// - /// ASF will call this method when cards farming module is finished on given bot instance. This method will also be called when there is nothing to idle or idling is unavailable, you can use provided boolean value for determining that. - /// - /// Bot object related to this callback. - /// Bool value indicating whether the module has finished successfully, so when there was at least one card to drop, and nothing has interrupted us in the meantime. - void OnBotFarmingFinished(Bot bot, bool farmedSomething); +namespace ArchiSteamFarm.Plugins.Interfaces; - /// - /// ASF will call this method when cards farming module is started on given bot instance. The module is started only when there are valid cards to drop, so this method won't be called when there is nothing to idle. - /// - /// Bot object related to this callback. - void OnBotFarmingStarted(Bot bot); +[PublicAPI] +public interface IBotCardsFarmerInfo : IPlugin { + /// + /// ASF will call this method when cards farming module is finished on given bot instance. This method will also be called when there is nothing to idle or idling is unavailable, you can use provided boolean value for determining that. + /// + /// Bot object related to this callback. + /// Bool value indicating whether the module has finished successfully, so when there was at least one card to drop, and nothing has interrupted us in the meantime. + void OnBotFarmingFinished(Bot bot, bool farmedSomething); - /// - /// ASF will call this method when cards farming module is stopped on given bot instance. The stop could be a result of a natural finish, or other situations (e.g. Steam networking issues, user commands). - /// - /// Bot object related to this callback. - void OnBotFarmingStopped(Bot bot); - } + /// + /// ASF will call this method when cards farming module is started on given bot instance. The module is started only when there are valid cards to drop, so this method won't be called when there is nothing to idle. + /// + /// Bot object related to this callback. + void OnBotFarmingStarted(Bot bot); + + /// + /// ASF will call this method when cards farming module is stopped on given bot instance. The stop could be a result of a natural finish, or other situations (e.g. Steam networking issues, user commands). + /// + /// Bot object related to this callback. + void OnBotFarmingStopped(Bot bot); } diff --git a/ArchiSteamFarm/Plugins/Interfaces/IBotCommand.cs b/ArchiSteamFarm/Plugins/Interfaces/IBotCommand.cs index b0f42a1c3..cad43e54e 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/IBotCommand.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/IBotCommand.cs @@ -24,17 +24,17 @@ using ArchiSteamFarm.Steam; using ArchiSteamFarm.Storage; using JetBrains.Annotations; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface IBotCommand : IPlugin { - /// - /// ASF will call this method for unrecognized commands. - /// - /// Bot object related to this callback. - /// 64-bit long unsigned integer of steamID executing the command. - /// Command message in its raw format, stripped of . - /// Pre-parsed message using standard ASF delimiters. - /// Response to the command, or null/empty (as the task value) if the command isn't handled by this plugin. - Task OnBotCommand(Bot bot, ulong steamID, string message, string[] args); - } +namespace ArchiSteamFarm.Plugins.Interfaces; + +[PublicAPI] +public interface IBotCommand : IPlugin { + /// + /// ASF will call this method for unrecognized commands. + /// + /// Bot object related to this callback. + /// 64-bit long unsigned integer of steamID executing the command. + /// Command message in its raw format, stripped of . + /// Pre-parsed message using standard ASF delimiters. + /// Response to the command, or null/empty (as the task value) if the command isn't handled by this plugin. + Task OnBotCommand(Bot bot, ulong steamID, string message, string[] args); } diff --git a/ArchiSteamFarm/Plugins/Interfaces/IBotConnection.cs b/ArchiSteamFarm/Plugins/Interfaces/IBotConnection.cs index 4825045a1..037a2bdb9 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/IBotConnection.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/IBotConnection.cs @@ -23,20 +23,20 @@ using ArchiSteamFarm.Steam; using JetBrains.Annotations; using SteamKit2; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface IBotConnection : IPlugin { - /// - /// ASF will call this method when bot gets disconnected from Steam network. - /// - /// Bot object related to this callback. - /// Reason for disconnection, or if the disconnection was initiated by ASF (e.g. as a result of a command). - void OnBotDisconnected(Bot bot, EResult reason); +namespace ArchiSteamFarm.Plugins.Interfaces; - /// - /// ASF will call this method when bot successfully connects to Steam network. - /// - /// Bot object related to this callback. - void OnBotLoggedOn(Bot bot); - } +[PublicAPI] +public interface IBotConnection : IPlugin { + /// + /// ASF will call this method when bot gets disconnected from Steam network. + /// + /// Bot object related to this callback. + /// Reason for disconnection, or if the disconnection was initiated by ASF (e.g. as a result of a command). + void OnBotDisconnected(Bot bot, EResult reason); + + /// + /// ASF will call this method when bot successfully connects to Steam network. + /// + /// Bot object related to this callback. + void OnBotLoggedOn(Bot bot); } diff --git a/ArchiSteamFarm/Plugins/Interfaces/IBotFriendRequest.cs b/ArchiSteamFarm/Plugins/Interfaces/IBotFriendRequest.cs index 8af0f142f..7307c3add 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/IBotFriendRequest.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/IBotFriendRequest.cs @@ -23,15 +23,15 @@ using System.Threading.Tasks; using ArchiSteamFarm.Steam; using JetBrains.Annotations; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface IBotFriendRequest : IPlugin { - /// - /// ASF will call this method for unhandled (ignored and rejected) friend requests and Steam group invites received by the bot. - /// - /// Bot object related to this callback. - /// 64-bit Steam identificator of the user that sent a friend request, or a group that the bot has been invited to. - /// True if the request should be accepted as part of this plugin, false otherwise. - Task OnBotFriendRequest(Bot bot, ulong steamID); - } +namespace ArchiSteamFarm.Plugins.Interfaces; + +[PublicAPI] +public interface IBotFriendRequest : IPlugin { + /// + /// ASF will call this method for unhandled (ignored and rejected) friend requests and Steam group invites received by the bot. + /// + /// Bot object related to this callback. + /// 64-bit Steam identificator of the user that sent a friend request, or a group that the bot has been invited to. + /// True if the request should be accepted as part of this plugin, false otherwise. + Task OnBotFriendRequest(Bot bot, ulong steamID); } diff --git a/ArchiSteamFarm/Plugins/Interfaces/IBotMessage.cs b/ArchiSteamFarm/Plugins/Interfaces/IBotMessage.cs index 370e03b09..a9d67003c 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/IBotMessage.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/IBotMessage.cs @@ -24,16 +24,16 @@ using ArchiSteamFarm.Steam; using ArchiSteamFarm.Storage; using JetBrains.Annotations; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface IBotMessage : IPlugin { - /// - /// ASF will call this method for messages that are not commands, so ones that do not start from . - /// - /// Bot object related to this callback. - /// 64-bit long unsigned integer of steamID executing the command. - /// Message in its raw format. - /// Response to the message, or null/empty (as the task value) for silence. - Task OnBotMessage(Bot bot, ulong steamID, string message); - } +namespace ArchiSteamFarm.Plugins.Interfaces; + +[PublicAPI] +public interface IBotMessage : IPlugin { + /// + /// ASF will call this method for messages that are not commands, so ones that do not start from . + /// + /// Bot object related to this callback. + /// 64-bit long unsigned integer of steamID executing the command. + /// Message in its raw format. + /// Response to the message, or null/empty (as the task value) for silence. + Task OnBotMessage(Bot bot, ulong steamID, string message); } diff --git a/ArchiSteamFarm/Plugins/Interfaces/IBotModules.cs b/ArchiSteamFarm/Plugins/Interfaces/IBotModules.cs index 18b2d27f8..0057a2897 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/IBotModules.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/IBotModules.cs @@ -25,14 +25,14 @@ using JetBrains.Annotations; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface IBotModules : IPlugin { - /// - /// ASF will call this method right after bot config initialization. - /// - /// Bot object related to this callback. - /// Extra config properties made out of . Can be null if no extra properties are found. - void OnBotInitModules(Bot bot, IReadOnlyDictionary? additionalConfigProperties = null); - } +namespace ArchiSteamFarm.Plugins.Interfaces; + +[PublicAPI] +public interface IBotModules : IPlugin { + /// + /// ASF will call this method right after bot config initialization. + /// + /// Bot object related to this callback. + /// Extra config properties made out of . Can be null if no extra properties are found. + void OnBotInitModules(Bot bot, IReadOnlyDictionary? additionalConfigProperties = null); } diff --git a/ArchiSteamFarm/Plugins/Interfaces/IBotSteamClient.cs b/ArchiSteamFarm/Plugins/Interfaces/IBotSteamClient.cs index 7a6429a36..91b853a46 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/IBotSteamClient.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/IBotSteamClient.cs @@ -24,21 +24,21 @@ using ArchiSteamFarm.Steam; using JetBrains.Annotations; using SteamKit2; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface IBotSteamClient : IPlugin { - /// - /// ASF will call this method right after custom SK2 client handler initialization in order to allow you listening for callbacks in your own code. - /// - /// Bot object related to this callback. - /// Callback manager object which can be used for establishing subscriptions to standard and custom callbacks. - void OnBotSteamCallbacksInit(Bot bot, CallbackManager callbackManager); +namespace ArchiSteamFarm.Plugins.Interfaces; - /// - /// ASF will call this method right after bot initialization in order to allow you hooking custom SK2 client handlers into the SteamClient. - /// - /// Bot object related to this callback. - /// Collection of custom client handlers that are supposed to be hooked into the SteamClient by ASF. If you do not require any, just return null or empty collection. - IReadOnlyCollection? OnBotSteamHandlersInit(Bot bot); - } +[PublicAPI] +public interface IBotSteamClient : IPlugin { + /// + /// ASF will call this method right after custom SK2 client handler initialization in order to allow you listening for callbacks in your own code. + /// + /// Bot object related to this callback. + /// Callback manager object which can be used for establishing subscriptions to standard and custom callbacks. + void OnBotSteamCallbacksInit(Bot bot, CallbackManager callbackManager); + + /// + /// ASF will call this method right after bot initialization in order to allow you hooking custom SK2 client handlers into the SteamClient. + /// + /// Bot object related to this callback. + /// Collection of custom client handlers that are supposed to be hooked into the SteamClient by ASF. If you do not require any, just return null or empty collection. + IReadOnlyCollection? OnBotSteamHandlersInit(Bot bot); } diff --git a/ArchiSteamFarm/Plugins/Interfaces/IBotTradeOffer.cs b/ArchiSteamFarm/Plugins/Interfaces/IBotTradeOffer.cs index ff017f53b..d81e7b09b 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/IBotTradeOffer.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/IBotTradeOffer.cs @@ -24,15 +24,15 @@ using ArchiSteamFarm.Steam; using ArchiSteamFarm.Steam.Data; using JetBrains.Annotations; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface IBotTradeOffer : IPlugin { - /// - /// ASF will call this method for unhandled (ignored and rejected) trade offers received by the bot. - /// - /// Bot object related to this callback. - /// Trade offer related to this callback. - /// True if the trade offer should be accepted as part of this plugin, false otherwise. - Task OnBotTradeOffer(Bot bot, TradeOffer tradeOffer); - } +namespace ArchiSteamFarm.Plugins.Interfaces; + +[PublicAPI] +public interface IBotTradeOffer : IPlugin { + /// + /// ASF will call this method for unhandled (ignored and rejected) trade offers received by the bot. + /// + /// Bot object related to this callback. + /// Trade offer related to this callback. + /// True if the trade offer should be accepted as part of this plugin, false otherwise. + Task OnBotTradeOffer(Bot bot, TradeOffer tradeOffer); } diff --git a/ArchiSteamFarm/Plugins/Interfaces/IBotTradeOfferResults.cs b/ArchiSteamFarm/Plugins/Interfaces/IBotTradeOfferResults.cs index 5d61753be..f97d5208e 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/IBotTradeOfferResults.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/IBotTradeOfferResults.cs @@ -24,14 +24,14 @@ using ArchiSteamFarm.Steam; using ArchiSteamFarm.Steam.Exchange; using JetBrains.Annotations; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface IBotTradeOfferResults : IPlugin { - /// - /// ASF will call this method for notifying you about the result of each received trade offer being handled. The method is executed for each batch that can contain 1 or more trade offers. - /// - /// Bot object related to this callback. - /// Trade results related to this callback. - void OnBotTradeOfferResults(Bot bot, IReadOnlyCollection tradeResults); - } +namespace ArchiSteamFarm.Plugins.Interfaces; + +[PublicAPI] +public interface IBotTradeOfferResults : IPlugin { + /// + /// ASF will call this method for notifying you about the result of each received trade offer being handled. The method is executed for each batch that can contain 1 or more trade offers. + /// + /// Bot object related to this callback. + /// Trade results related to this callback. + void OnBotTradeOfferResults(Bot bot, IReadOnlyCollection tradeResults); } diff --git a/ArchiSteamFarm/Plugins/Interfaces/IBotUserNotifications.cs b/ArchiSteamFarm/Plugins/Interfaces/IBotUserNotifications.cs index 9dddf1fb7..df61e4de6 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/IBotUserNotifications.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/IBotUserNotifications.cs @@ -24,14 +24,14 @@ using ArchiSteamFarm.Steam; using ArchiSteamFarm.Steam.Integration.Callbacks; using JetBrains.Annotations; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface IBotUserNotifications : IPlugin { - /// - /// ASF will call this method when number of notifications for one or more notification types changes. - /// - /// Bot object related to this callback. - /// Collection containing those notification types that are new (that is, when new count > previous count of that notification type). - void OnBotUserNotifications(Bot bot, IReadOnlyCollection newNotifications); - } +namespace ArchiSteamFarm.Plugins.Interfaces; + +[PublicAPI] +public interface IBotUserNotifications : IPlugin { + /// + /// ASF will call this method when number of notifications for one or more notification types changes. + /// + /// Bot object related to this callback. + /// Collection containing those notification types that are new (that is, when new count > previous count of that notification type). + void OnBotUserNotifications(Bot bot, IReadOnlyCollection newNotifications); } diff --git a/ArchiSteamFarm/Plugins/Interfaces/IBotsComparer.cs b/ArchiSteamFarm/Plugins/Interfaces/IBotsComparer.cs index 5238cdc80..8c3f2d3c3 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/IBotsComparer.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/IBotsComparer.cs @@ -22,14 +22,14 @@ using System; using JetBrains.Annotations; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface IBotsComparer : IPlugin { - /// - /// ASF will use this property for determining the comparer for the bots. - /// Unless you know what you're doing, you should not implement this property yourself and let ASF decide. - /// - /// Comparer that will be used for the bots, as well as bot regexes. - StringComparer BotsComparer { get; } - } +namespace ArchiSteamFarm.Plugins.Interfaces; + +[PublicAPI] +public interface IBotsComparer : IPlugin { + /// + /// ASF will use this property for determining the comparer for the bots. + /// Unless you know what you're doing, you should not implement this property yourself and let ASF decide. + /// + /// Comparer that will be used for the bots, as well as bot regexes. + StringComparer BotsComparer { get; } } diff --git a/ArchiSteamFarm/Plugins/Interfaces/ICrossProcessSemaphoreProvider.cs b/ArchiSteamFarm/Plugins/Interfaces/ICrossProcessSemaphoreProvider.cs index d93985a32..726ace127 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/ICrossProcessSemaphoreProvider.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/ICrossProcessSemaphoreProvider.cs @@ -23,14 +23,14 @@ using System.Threading.Tasks; using ArchiSteamFarm.Helpers; using JetBrains.Annotations; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface ICrossProcessSemaphoreProvider : IPlugin { - /// - /// ASF will call this method when initializing instance of for its internal limiters. - /// - /// Unique resource name provided by ASF for identification purposes. - /// Concrete implementation of providing required functionality. It's allowed to return null if you want to use ASF's default implementation for specified resource instead. - Task GetCrossProcessSemaphore(string resourceName); - } +namespace ArchiSteamFarm.Plugins.Interfaces; + +[PublicAPI] +public interface ICrossProcessSemaphoreProvider : IPlugin { + /// + /// ASF will call this method when initializing instance of for its internal limiters. + /// + /// Unique resource name provided by ASF for identification purposes. + /// Concrete implementation of providing required functionality. It's allowed to return null if you want to use ASF's default implementation for specified resource instead. + Task GetCrossProcessSemaphore(string resourceName); } diff --git a/ArchiSteamFarm/Plugins/Interfaces/IPlugin.cs b/ArchiSteamFarm/Plugins/Interfaces/IPlugin.cs index 3c124111c..33b0c792c 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/IPlugin.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/IPlugin.cs @@ -23,27 +23,27 @@ using System; using JetBrains.Annotations; using Newtonsoft.Json; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface IPlugin { - /// - /// ASF will use this property as general plugin identifier for the user. - /// - /// String that will be used as the name of this plugin. - [JsonProperty] - string Name { get; } +namespace ArchiSteamFarm.Plugins.Interfaces; - /// - /// ASF will use this property as version indicator of your plugin to the user. - /// You have a freedom in deciding what versioning you want to use, this is for identification purposes only. - /// - /// Version that will be shown to the user when plugin is loaded. - [JsonProperty] - Version Version { get; } +[PublicAPI] +public interface IPlugin { + /// + /// ASF will use this property as general plugin identifier for the user. + /// + /// String that will be used as the name of this plugin. + [JsonProperty] + string Name { get; } - /// - /// ASF will call this method right after plugin initialization. - /// - void OnLoaded(); - } + /// + /// ASF will use this property as version indicator of your plugin to the user. + /// You have a freedom in deciding what versioning you want to use, this is for identification purposes only. + /// + /// Version that will be shown to the user when plugin is loaded. + [JsonProperty] + Version Version { get; } + + /// + /// ASF will call this method right after plugin initialization. + /// + void OnLoaded(); } diff --git a/ArchiSteamFarm/Plugins/Interfaces/ISteamPICSChanges.cs b/ArchiSteamFarm/Plugins/Interfaces/ISteamPICSChanges.cs index f391df081..f6045839f 100644 --- a/ArchiSteamFarm/Plugins/Interfaces/ISteamPICSChanges.cs +++ b/ArchiSteamFarm/Plugins/Interfaces/ISteamPICSChanges.cs @@ -24,27 +24,27 @@ using System.Threading.Tasks; using JetBrains.Annotations; using SteamKit2; -namespace ArchiSteamFarm.Plugins.Interfaces { - [PublicAPI] - public interface ISteamPICSChanges : IPlugin { - /// - /// ASF uses this method for determining the point in time from which it should keep history going upon a restart. The actual point in time that will be used is calculated as the lowest change number from all loaded plugins, to guarantee that no plugin will miss any changes, while allowing possible duplicates for those plugins that were already synchronized with newer changes. If you don't care about persistent state and just want to receive the ongoing history, you should return 0 (which is equal to "I'm fine with any"). If there won't be any plugin asking for a specific point in time, ASF will start returning entries since the start of the program. - /// - /// The most recent change number from which you're fine to receive - Task GetPreferredChangeNumberToStartFrom(); +namespace ArchiSteamFarm.Plugins.Interfaces; - /// - /// ASF will call this method upon receiving any app/package PICS changes. The history is guaranteed to be precise and continuous starting from until is called. It's possible for this method to have duplicated calls across different runs, in particular when some other plugin asks for lower , therefore you should keep that in mind (and refer to change number of standalone apps/packages). - /// - /// The change number of current callback. - /// App changes that happened since the previous call of this method. Can be empty. - /// Package changes that happened since the previous call of this method. Can be empty. - void OnPICSChanges(uint currentChangeNumber, IReadOnlyDictionary appChanges, IReadOnlyDictionary packageChanges); +[PublicAPI] +public interface ISteamPICSChanges : IPlugin { + /// + /// ASF uses this method for determining the point in time from which it should keep history going upon a restart. The actual point in time that will be used is calculated as the lowest change number from all loaded plugins, to guarantee that no plugin will miss any changes, while allowing possible duplicates for those plugins that were already synchronized with newer changes. If you don't care about persistent state and just want to receive the ongoing history, you should return 0 (which is equal to "I'm fine with any"). If there won't be any plugin asking for a specific point in time, ASF will start returning entries since the start of the program. + /// + /// The most recent change number from which you're fine to receive + Task GetPreferredChangeNumberToStartFrom(); - /// - /// ASF will call this method when it'll be necessary to restart the history of PICS changes. This can happen due to Steam limitation in which we're unable to keep history going if we're too far behind (approx 5k changeNumbers). If you're relying on continuous history of app/package PICS changes sent by , ASF can no longer guarantee that upon calling this method, therefore you should start clean. - /// - /// The change number from which we're restarting the PICS history. - void OnPICSChangesRestart(uint currentChangeNumber); - } + /// + /// ASF will call this method upon receiving any app/package PICS changes. The history is guaranteed to be precise and continuous starting from until is called. It's possible for this method to have duplicated calls across different runs, in particular when some other plugin asks for lower , therefore you should keep that in mind (and refer to change number of standalone apps/packages). + /// + /// The change number of current callback. + /// App changes that happened since the previous call of this method. Can be empty. + /// Package changes that happened since the previous call of this method. Can be empty. + void OnPICSChanges(uint currentChangeNumber, IReadOnlyDictionary appChanges, IReadOnlyDictionary packageChanges); + + /// + /// ASF will call this method when it'll be necessary to restart the history of PICS changes. This can happen due to Steam limitation in which we're unable to keep history going if we're too far behind (approx 5k changeNumbers). If you're relying on continuous history of app/package PICS changes sent by , ASF can no longer guarantee that upon calling this method, therefore you should start clean. + /// + /// The change number from which we're restarting the PICS history. + void OnPICSChangesRestart(uint currentChangeNumber); } diff --git a/ArchiSteamFarm/Plugins/OfficialPlugin.cs b/ArchiSteamFarm/Plugins/OfficialPlugin.cs index 9ae23de45..07a19eb2a 100644 --- a/ArchiSteamFarm/Plugins/OfficialPlugin.cs +++ b/ArchiSteamFarm/Plugins/OfficialPlugin.cs @@ -22,12 +22,12 @@ using System; using ArchiSteamFarm.Plugins.Interfaces; -namespace ArchiSteamFarm.Plugins { - internal abstract class OfficialPlugin : IPlugin { - public abstract string Name { get; } - public abstract Version Version { get; } - public abstract void OnLoaded(); +namespace ArchiSteamFarm.Plugins; - internal bool HasSameVersion() => Version == SharedInfo.Version; - } +internal abstract class OfficialPlugin : IPlugin { + public abstract string Name { get; } + public abstract Version Version { get; } + public abstract void OnLoaded(); + + internal bool HasSameVersion() => Version == SharedInfo.Version; } diff --git a/ArchiSteamFarm/Plugins/PluginsCore.cs b/ArchiSteamFarm/Plugins/PluginsCore.cs index 957d1d359..d8e4213d4 100644 --- a/ArchiSteamFarm/Plugins/PluginsCore.cs +++ b/ArchiSteamFarm/Plugins/PluginsCore.cs @@ -41,609 +41,609 @@ using ArchiSteamFarm.Steam.Integration.Callbacks; using Newtonsoft.Json.Linq; using SteamKit2; -namespace ArchiSteamFarm.Plugins { - internal static class PluginsCore { - internal static bool HasCustomPluginsLoaded => ActivePlugins?.Any(static plugin => plugin is not OfficialPlugin officialPlugin || !officialPlugin.HasSameVersion()) == true; +namespace ArchiSteamFarm.Plugins; - [ImportMany] - internal static ImmutableHashSet? ActivePlugins { get; private set; } +internal static class PluginsCore { + internal static bool HasCustomPluginsLoaded => ActivePlugins?.Any(static plugin => plugin is not OfficialPlugin officialPlugin || !officialPlugin.HasSameVersion()) == true; - internal static async Task GetBotsComparer() { - if (ActivePlugins == null) { - return StringComparer.Ordinal; - } + [ImportMany] + internal static ImmutableHashSet? ActivePlugins { get; private set; } - IList results; - - try { - results = await Utilities.InParallel(ActivePlugins.OfType().Select(static plugin => Task.Run(() => plugin.BotsComparer))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return StringComparer.Ordinal; - } - - StringComparer? result = results.FirstOrDefault(); - - return result ?? StringComparer.Ordinal; + internal static async Task GetBotsComparer() { + if (ActivePlugins == null) { + return StringComparer.Ordinal; } - internal static async Task GetChangeNumberToStartFrom() { - uint lastChangeNumber = ASF.GlobalDatabase?.LastChangeNumber ?? 0; + IList results; - if ((lastChangeNumber == 0) || (ActivePlugins == null)) { - return lastChangeNumber; - } + try { + results = await Utilities.InParallel(ActivePlugins.OfType().Select(static plugin => Task.Run(() => plugin.BotsComparer))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); - IList results; + return StringComparer.Ordinal; + } - try { - results = await Utilities.InParallel(ActivePlugins.OfType().Select(static plugin => plugin.GetPreferredChangeNumberToStartFrom())).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); + StringComparer? result = results.FirstOrDefault(); - return lastChangeNumber; - } + return result ?? StringComparer.Ordinal; + } - foreach (uint result in results.Where(result => (result > 0) && (result < lastChangeNumber))) { - lastChangeNumber = result; - } + internal static async Task GetChangeNumberToStartFrom() { + uint lastChangeNumber = ASF.GlobalDatabase?.LastChangeNumber ?? 0; + + if ((lastChangeNumber == 0) || (ActivePlugins == null)) { + return lastChangeNumber; + } + + IList results; + + try { + results = await Utilities.InParallel(ActivePlugins.OfType().Select(static plugin => plugin.GetPreferredChangeNumberToStartFrom())).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); return lastChangeNumber; } - internal static async Task GetCrossProcessSemaphore(string objectName) { - if (string.IsNullOrEmpty(objectName)) { - throw new ArgumentNullException(nameof(objectName)); - } - - string resourceName = OS.GetOsResourceName(objectName); - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return new CrossProcessFileBasedSemaphore(resourceName); - } - - IList responses; - - try { - responses = await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => plugin.GetCrossProcessSemaphore(resourceName))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return new CrossProcessFileBasedSemaphore(resourceName); - } - - return responses.FirstOrDefault(static response => response != null) ?? new CrossProcessFileBasedSemaphore(resourceName); + foreach (uint result in results.Where(result => (result > 0) && (result < lastChangeNumber))) { + lastChangeNumber = result; } - internal static bool InitPlugins() { - if (ActivePlugins != null) { - return false; + return lastChangeNumber; + } + + internal static async Task GetCrossProcessSemaphore(string objectName) { + if (string.IsNullOrEmpty(objectName)) { + throw new ArgumentNullException(nameof(objectName)); + } + + string resourceName = OS.GetOsResourceName(objectName); + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return new CrossProcessFileBasedSemaphore(resourceName); + } + + IList responses; + + try { + responses = await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => plugin.GetCrossProcessSemaphore(resourceName))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return new CrossProcessFileBasedSemaphore(resourceName); + } + + return responses.FirstOrDefault(static response => response != null) ?? new CrossProcessFileBasedSemaphore(resourceName); + } + + internal static bool InitPlugins() { + if (ActivePlugins != null) { + return false; + } + + HashSet? assemblies = LoadAssemblies(); + + if ((assemblies == null) || (assemblies.Count == 0)) { + ASF.ArchiLogger.LogGenericTrace(Strings.NothingFound); + + return true; + } + + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.Initializing, nameof(Plugins))); + + foreach (Assembly assembly in assemblies) { + if (Debugging.IsUserDebugging) { + ASF.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Initializing, assembly.FullName)); } - HashSet? assemblies = LoadAssemblies(); - - if ((assemblies == null) || (assemblies.Count == 0)) { - ASF.ArchiLogger.LogGenericTrace(Strings.NothingFound); - - return true; - } - - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.Initializing, nameof(Plugins))); - - foreach (Assembly assembly in assemblies) { - if (Debugging.IsUserDebugging) { - ASF.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Initializing, assembly.FullName)); - } - - try { - // This call is bare minimum to verify if the assembly can load itself - assembly.GetTypes(); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, assembly.FullName)); - ASF.ArchiLogger.LogGenericException(e); - - return false; - } - } - - ConventionBuilder conventions = new(); - conventions.ForTypesDerivedFrom().Export(); - - ContainerConfiguration configuration = new ContainerConfiguration().WithAssemblies(assemblies, conventions); - - HashSet activePlugins; - try { - using CompositionHost container = configuration.CreateContainer(); - - activePlugins = container.GetExports().ToHashSet(); + // This call is bare minimum to verify if the assembly can load itself + assembly.GetTypes(); } catch (Exception e) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, assembly.FullName)); ASF.ArchiLogger.LogGenericException(e); return false; } + } + + ConventionBuilder conventions = new(); + conventions.ForTypesDerivedFrom().Export(); + + ContainerConfiguration configuration = new ContainerConfiguration().WithAssemblies(assemblies, conventions); + + HashSet activePlugins; + + try { + using CompositionHost container = configuration.CreateContainer(); + + activePlugins = container.GetExports().ToHashSet(); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return false; + } + + if (activePlugins.Count == 0) { + return true; + } + + HashSet invalidPlugins = new(); + + foreach (IPlugin plugin in activePlugins) { + try { + string pluginName = plugin.Name; + + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginLoading, pluginName, plugin.Version)); + plugin.OnLoaded(); + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginLoaded, pluginName)); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + invalidPlugins.Add(plugin); + } + } + + if (invalidPlugins.Count > 0) { + activePlugins.ExceptWith(invalidPlugins); if (activePlugins.Count == 0) { - return true; + return false; } - - HashSet invalidPlugins = new(); - - foreach (IPlugin plugin in activePlugins) { - try { - string pluginName = plugin.Name; - - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginLoading, pluginName, plugin.Version)); - plugin.OnLoaded(); - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.PluginLoaded, pluginName)); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - invalidPlugins.Add(plugin); - } - } - - if (invalidPlugins.Count > 0) { - activePlugins.ExceptWith(invalidPlugins); - - if (activePlugins.Count == 0) { - return false; - } - } - - ActivePlugins = activePlugins.ToImmutableHashSet(); - - if (HasCustomPluginsLoaded) { - ASF.ArchiLogger.LogGenericInfo(Strings.PluginsWarning); - - // Loading plugins changes the program identifier, refresh the console title - Console.Title = SharedInfo.ProgramIdentifier; - } - - return invalidPlugins.Count == 0; } - internal static HashSet? LoadAssemblies() { - HashSet? assemblies = null; + ActivePlugins = activePlugins.ToImmutableHashSet(); - string pluginsPath = Path.Combine(SharedInfo.HomeDirectory, SharedInfo.PluginsDirectory); + if (HasCustomPluginsLoaded) { + ASF.ArchiLogger.LogGenericInfo(Strings.PluginsWarning); - if (Directory.Exists(pluginsPath)) { - HashSet? loadedAssemblies = LoadAssembliesFrom(pluginsPath); + // Loading plugins changes the program identifier, refresh the console title + Console.Title = SharedInfo.ProgramIdentifier; + } - if (loadedAssemblies?.Count > 0) { + return invalidPlugins.Count == 0; + } + + internal static HashSet? LoadAssemblies() { + HashSet? assemblies = null; + + string pluginsPath = Path.Combine(SharedInfo.HomeDirectory, SharedInfo.PluginsDirectory); + + if (Directory.Exists(pluginsPath)) { + HashSet? loadedAssemblies = LoadAssembliesFrom(pluginsPath); + + if (loadedAssemblies?.Count > 0) { + assemblies = loadedAssemblies; + } + } + + string customPluginsPath = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.PluginsDirectory); + + if ((pluginsPath != customPluginsPath) && Directory.Exists(customPluginsPath)) { + HashSet? loadedAssemblies = LoadAssembliesFrom(customPluginsPath); + + if (loadedAssemblies?.Count > 0) { + if (assemblies?.Count > 0) { + assemblies.UnionWith(loadedAssemblies); + } else { assemblies = loadedAssemblies; } } - - string customPluginsPath = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.PluginsDirectory); - - if ((pluginsPath != customPluginsPath) && Directory.Exists(customPluginsPath)) { - HashSet? loadedAssemblies = LoadAssembliesFrom(customPluginsPath); - - if (loadedAssemblies?.Count > 0) { - if (assemblies?.Count > 0) { - assemblies.UnionWith(loadedAssemblies); - } else { - assemblies = loadedAssemblies; - } - } - } - - return assemblies; } - internal static async Task OnASFInitModules(IReadOnlyDictionary? additionalConfigProperties = null) { - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return; - } + return assemblies; + } - try { - await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnASFInit(additionalConfigProperties)))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } + internal static async Task OnASFInitModules(IReadOnlyDictionary? additionalConfigProperties = null) { + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return; } - internal static async Task OnBotCommand(Bot bot, ulong steamID, string message, string[] args) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } - - if ((args == null) || (args.Length == 0)) { - throw new ArgumentNullException(nameof(args)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return null; - } - - IList responses; - - try { - responses = await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => plugin.OnBotCommand(bot, steamID, message, args))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return null; - } - - return string.Join(Environment.NewLine, responses.Where(static response => !string.IsNullOrEmpty(response))); - } - - internal static async Task OnBotDestroy(Bot bot) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return; - } - - try { - await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotDestroy(bot)))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - internal static async Task OnBotDisconnected(Bot bot, EResult reason) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return; - } - - try { - await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotDisconnected(bot, reason)))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - internal static async Task OnBotFarmingFinished(Bot bot, bool farmedSomething) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return; - } - - try { - await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotFarmingFinished(bot, farmedSomething)))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - internal static async Task OnBotFarmingStarted(Bot bot) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return; - } - - try { - await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotFarmingStarted(bot)))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - internal static async Task OnBotFarmingStopped(Bot bot) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return; - } - - try { - await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotFarmingStopped(bot)))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - internal static async Task OnBotFriendRequest(Bot bot, ulong steamID) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if ((steamID == 0) || !new SteamID(steamID).IsValid) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return false; - } - - IList responses; - - try { - responses = await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => plugin.OnBotFriendRequest(bot, steamID))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return false; - } - - return responses.Any(static response => response); - } - - internal static async Task OnBotInit(Bot bot) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return; - } - - try { - await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotInit(bot)))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - internal static async Task OnBotInitModules(Bot bot, IReadOnlyDictionary? additionalConfigProperties = null) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return; - } - - try { - await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotInitModules(bot, additionalConfigProperties)))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - internal static async Task OnBotLoggedOn(Bot bot) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return; - } - - try { - await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotLoggedOn(bot)))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - internal static async Task OnBotMessage(Bot bot, ulong steamID, string message) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return null; - } - - IList responses; - - try { - responses = await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => plugin.OnBotMessage(bot, steamID, message))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return null; - } - - return string.Join(Environment.NewLine, responses.Where(static response => !string.IsNullOrEmpty(response))); - } - - internal static async Task OnBotSteamCallbacksInit(Bot bot, CallbackManager callbackManager) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if (callbackManager == null) { - throw new ArgumentNullException(nameof(callbackManager)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return; - } - - try { - await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotSteamCallbacksInit(bot, callbackManager)))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - internal static async Task?> OnBotSteamHandlersInit(Bot bot) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return null; - } - - IList?> responses; - - try { - responses = await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotSteamHandlersInit(bot)))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return null; - } - - return responses.Where(static response => response != null).SelectMany(static handlers => handlers ?? Enumerable.Empty()).ToHashSet(); - } - - internal static async Task OnBotTradeOffer(Bot bot, TradeOffer tradeOffer) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if (tradeOffer == null) { - throw new ArgumentNullException(nameof(tradeOffer)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return false; - } - - IList responses; - - try { - responses = await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => plugin.OnBotTradeOffer(bot, tradeOffer))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return false; - } - - return responses.Any(static response => response); - } - - internal static async Task OnBotTradeOfferResults(Bot bot, IReadOnlyCollection tradeResults) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if ((tradeResults == null) || (tradeResults.Count == 0)) { - throw new ArgumentNullException(nameof(tradeResults)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return; - } - - try { - await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotTradeOfferResults(bot, tradeResults)))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - internal static async Task OnBotUserNotifications(Bot bot, IReadOnlyCollection newNotifications) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if ((newNotifications == null) || (newNotifications.Count == 0)) { - throw new ArgumentNullException(nameof(newNotifications)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return; - } - - try { - await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotUserNotifications(bot, newNotifications)))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - internal static async Task OnPICSChanges(uint currentChangeNumber, IReadOnlyDictionary appChanges, IReadOnlyDictionary packageChanges) { - if (currentChangeNumber == 0) { - throw new ArgumentOutOfRangeException(nameof(currentChangeNumber)); - } - - if (appChanges == null) { - throw new ArgumentNullException(nameof(appChanges)); - } - - if (packageChanges == null) { - throw new ArgumentNullException(nameof(packageChanges)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return; - } - - try { - await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnPICSChanges(currentChangeNumber, appChanges, packageChanges)))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - internal static async Task OnPICSChangesRestart(uint currentChangeNumber) { - if (currentChangeNumber == 0) { - throw new ArgumentNullException(nameof(currentChangeNumber)); - } - - if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { - return; - } - - try { - await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnPICSChangesRestart(currentChangeNumber)))).ConfigureAwait(false); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - private static HashSet? LoadAssembliesFrom(string path) { - if (string.IsNullOrEmpty(path)) { - throw new ArgumentNullException(nameof(path)); - } - - if (!Directory.Exists(path)) { - return null; - } - - HashSet assemblies = new(); - - try { - foreach (string assemblyPath in Directory.EnumerateFiles(path, "*.dll", SearchOption.AllDirectories)) { - Assembly assembly; - - try { - assembly = Assembly.LoadFrom(assemblyPath); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, assemblyPath)); - ASF.ArchiLogger.LogGenericWarningException(e); - - continue; - } - - assemblies.Add(assembly); - } - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return null; - } - - return assemblies; + try { + await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnASFInit(additionalConfigProperties)))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); } } + + internal static async Task OnBotCommand(Bot bot, ulong steamID, string message, string[] args) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + if ((args == null) || (args.Length == 0)) { + throw new ArgumentNullException(nameof(args)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return null; + } + + IList responses; + + try { + responses = await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => plugin.OnBotCommand(bot, steamID, message, args))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return null; + } + + return string.Join(Environment.NewLine, responses.Where(static response => !string.IsNullOrEmpty(response))); + } + + internal static async Task OnBotDestroy(Bot bot) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return; + } + + try { + await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotDestroy(bot)))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + internal static async Task OnBotDisconnected(Bot bot, EResult reason) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return; + } + + try { + await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotDisconnected(bot, reason)))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + internal static async Task OnBotFarmingFinished(Bot bot, bool farmedSomething) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return; + } + + try { + await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotFarmingFinished(bot, farmedSomething)))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + internal static async Task OnBotFarmingStarted(Bot bot) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return; + } + + try { + await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotFarmingStarted(bot)))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + internal static async Task OnBotFarmingStopped(Bot bot) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return; + } + + try { + await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotFarmingStopped(bot)))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + internal static async Task OnBotFriendRequest(Bot bot, ulong steamID) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if ((steamID == 0) || !new SteamID(steamID).IsValid) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return false; + } + + IList responses; + + try { + responses = await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => plugin.OnBotFriendRequest(bot, steamID))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return false; + } + + return responses.Any(static response => response); + } + + internal static async Task OnBotInit(Bot bot) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return; + } + + try { + await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotInit(bot)))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + internal static async Task OnBotInitModules(Bot bot, IReadOnlyDictionary? additionalConfigProperties = null) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return; + } + + try { + await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotInitModules(bot, additionalConfigProperties)))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + internal static async Task OnBotLoggedOn(Bot bot) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return; + } + + try { + await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotLoggedOn(bot)))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + internal static async Task OnBotMessage(Bot bot, ulong steamID, string message) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return null; + } + + IList responses; + + try { + responses = await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => plugin.OnBotMessage(bot, steamID, message))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return null; + } + + return string.Join(Environment.NewLine, responses.Where(static response => !string.IsNullOrEmpty(response))); + } + + internal static async Task OnBotSteamCallbacksInit(Bot bot, CallbackManager callbackManager) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if (callbackManager == null) { + throw new ArgumentNullException(nameof(callbackManager)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return; + } + + try { + await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotSteamCallbacksInit(bot, callbackManager)))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + internal static async Task?> OnBotSteamHandlersInit(Bot bot) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return null; + } + + IList?> responses; + + try { + responses = await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotSteamHandlersInit(bot)))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return null; + } + + return responses.Where(static response => response != null).SelectMany(static handlers => handlers ?? Enumerable.Empty()).ToHashSet(); + } + + internal static async Task OnBotTradeOffer(Bot bot, TradeOffer tradeOffer) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if (tradeOffer == null) { + throw new ArgumentNullException(nameof(tradeOffer)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return false; + } + + IList responses; + + try { + responses = await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => plugin.OnBotTradeOffer(bot, tradeOffer))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return false; + } + + return responses.Any(static response => response); + } + + internal static async Task OnBotTradeOfferResults(Bot bot, IReadOnlyCollection tradeResults) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if ((tradeResults == null) || (tradeResults.Count == 0)) { + throw new ArgumentNullException(nameof(tradeResults)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return; + } + + try { + await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotTradeOfferResults(bot, tradeResults)))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + internal static async Task OnBotUserNotifications(Bot bot, IReadOnlyCollection newNotifications) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if ((newNotifications == null) || (newNotifications.Count == 0)) { + throw new ArgumentNullException(nameof(newNotifications)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return; + } + + try { + await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnBotUserNotifications(bot, newNotifications)))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + internal static async Task OnPICSChanges(uint currentChangeNumber, IReadOnlyDictionary appChanges, IReadOnlyDictionary packageChanges) { + if (currentChangeNumber == 0) { + throw new ArgumentOutOfRangeException(nameof(currentChangeNumber)); + } + + if (appChanges == null) { + throw new ArgumentNullException(nameof(appChanges)); + } + + if (packageChanges == null) { + throw new ArgumentNullException(nameof(packageChanges)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return; + } + + try { + await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnPICSChanges(currentChangeNumber, appChanges, packageChanges)))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + internal static async Task OnPICSChangesRestart(uint currentChangeNumber) { + if (currentChangeNumber == 0) { + throw new ArgumentNullException(nameof(currentChangeNumber)); + } + + if ((ActivePlugins == null) || (ActivePlugins.Count == 0)) { + return; + } + + try { + await Utilities.InParallel(ActivePlugins.OfType().Select(plugin => Task.Run(() => plugin.OnPICSChangesRestart(currentChangeNumber)))).ConfigureAwait(false); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + private static HashSet? LoadAssembliesFrom(string path) { + if (string.IsNullOrEmpty(path)) { + throw new ArgumentNullException(nameof(path)); + } + + if (!Directory.Exists(path)) { + return null; + } + + HashSet assemblies = new(); + + try { + foreach (string assemblyPath in Directory.EnumerateFiles(path, "*.dll", SearchOption.AllDirectories)) { + Assembly assembly; + + try { + assembly = Assembly.LoadFrom(assemblyPath); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, assemblyPath)); + ASF.ArchiLogger.LogGenericWarningException(e); + + continue; + } + + assemblies.Add(assembly); + } + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return null; + } + + return assemblies; + } } diff --git a/ArchiSteamFarm/Program.cs b/ArchiSteamFarm/Program.cs index 89a62f786..0242c63a1 100644 --- a/ArchiSteamFarm/Program.cs +++ b/ArchiSteamFarm/Program.cs @@ -44,558 +44,558 @@ using NLog; using NLog.Targets; using SteamKit2; -namespace ArchiSteamFarm { - internal static class Program { - internal static bool ConfigMigrate { get; private set; } = true; - internal static bool ConfigWatch { get; private set; } = true; - internal static string? NetworkGroup { get; private set; } - internal static bool ProcessRequired { get; private set; } - internal static bool RestartAllowed { get; private set; } = true; - internal static bool Service { get; private set; } - internal static bool ShutdownSequenceInitialized { get; private set; } +namespace ArchiSteamFarm; + +internal static class Program { + internal static bool ConfigMigrate { get; private set; } = true; + internal static bool ConfigWatch { get; private set; } = true; + internal static string? NetworkGroup { get; private set; } + internal static bool ProcessRequired { get; private set; } + internal static bool RestartAllowed { get; private set; } = true; + internal static bool Service { get; private set; } + internal static bool ShutdownSequenceInitialized { get; private set; } #if !NETFRAMEWORK - private static readonly Dictionary RegisteredPosixSignals = new(); + private static readonly Dictionary RegisteredPosixSignals = new(); #endif - private static readonly TaskCompletionSource ShutdownResetEvent = new(); + private static readonly TaskCompletionSource ShutdownResetEvent = new(); #if !NETFRAMEWORK - private static readonly ImmutableHashSet SupportedPosixSignals = ImmutableHashSet.Create(PosixSignal.SIGINT, PosixSignal.SIGTERM); + private static readonly ImmutableHashSet SupportedPosixSignals = ImmutableHashSet.Create(PosixSignal.SIGINT, PosixSignal.SIGTERM); #endif - private static bool IgnoreUnsupportedEnvironment; - private static bool SystemRequired; + private static bool IgnoreUnsupportedEnvironment; + private static bool SystemRequired; - internal static async Task Exit(byte exitCode = 0) { - if (exitCode != 0) { - ASF.ArchiLogger.LogGenericError(Strings.ErrorExitingWithNonZeroErrorCode); - } - - await Shutdown(exitCode).ConfigureAwait(false); - Environment.Exit(exitCode); + internal static async Task Exit(byte exitCode = 0) { + if (exitCode != 0) { + ASF.ArchiLogger.LogGenericError(Strings.ErrorExitingWithNonZeroErrorCode); } - internal static async Task Restart() { - if (!await InitShutdownSequence().ConfigureAwait(false)) { - return; - } + await Shutdown(exitCode).ConfigureAwait(false); + Environment.Exit(exitCode); + } - string executableName = Path.GetFileNameWithoutExtension(OS.ProcessFileName); - - if (string.IsNullOrEmpty(executableName)) { - throw new ArgumentNullException(nameof(executableName)); - } - - IEnumerable arguments = Environment.GetCommandLineArgs().Skip(executableName.Equals(SharedInfo.AssemblyName, StringComparison.Ordinal) ? 1 : 0); - - try { - Process.Start(OS.ProcessFileName, string.Join(" ", arguments)); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - - // Give new process some time to take over the window (if needed) - await Task.Delay(2000).ConfigureAwait(false); - - ShutdownResetEvent.TrySetResult(0); - Environment.Exit(0); + internal static async Task Restart() { + if (!await InitShutdownSequence().ConfigureAwait(false)) { + return; } - private static void HandleCryptKeyArgument(string cryptKey) { - if (string.IsNullOrEmpty(cryptKey)) { - throw new ArgumentNullException(nameof(cryptKey)); - } + string executableName = Path.GetFileNameWithoutExtension(OS.ProcessFileName); - ArchiCryptoHelper.SetEncryptionKey(cryptKey); + if (string.IsNullOrEmpty(executableName)) { + throw new ArgumentNullException(nameof(executableName)); } - private static void HandleNetworkGroupArgument(string networkGroup) { - if (string.IsNullOrEmpty(networkGroup)) { - throw new ArgumentNullException(nameof(networkGroup)); - } + IEnumerable arguments = Environment.GetCommandLineArgs().Skip(executableName.Equals(SharedInfo.AssemblyName, StringComparison.Ordinal) ? 1 : 0); - NetworkGroup = networkGroup; + try { + Process.Start(OS.ProcessFileName, string.Join(" ", arguments)); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); } - private static void HandlePathArgument(string path) { - if (string.IsNullOrEmpty(path)) { - throw new ArgumentNullException(nameof(path)); - } + // Give new process some time to take over the window (if needed) + await Task.Delay(2000).ConfigureAwait(false); - try { - Directory.SetCurrentDirectory(path); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } + ShutdownResetEvent.TrySetResult(0); + Environment.Exit(0); + } + + private static void HandleCryptKeyArgument(string cryptKey) { + if (string.IsNullOrEmpty(cryptKey)) { + throw new ArgumentNullException(nameof(cryptKey)); } - private static async Task Init(IReadOnlyCollection? args) { - AppDomain.CurrentDomain.ProcessExit += OnProcessExit; - AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; - TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; + ArchiCryptoHelper.SetEncryptionKey(cryptKey); + } -#if !NETFRAMEWORK - if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { - foreach (PosixSignal signal in SupportedPosixSignals) { - RegisteredPosixSignals[signal] = PosixSignalRegistration.Create(signal, OnPosixSignal); - } - } -#endif - - Console.CancelKeyPress += OnCancelKeyPress; - - // Add support for custom encodings - Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - - // Add support for custom logging targets - Target.Register(HistoryTarget.TargetName); - Target.Register(SteamTarget.TargetName); - - if (!await InitCore(args).ConfigureAwait(false) || !await InitASF().ConfigureAwait(false)) { - await Exit(1).ConfigureAwait(false); - } + private static void HandleNetworkGroupArgument(string networkGroup) { + if (string.IsNullOrEmpty(networkGroup)) { + throw new ArgumentNullException(nameof(networkGroup)); } - private static async Task InitASF() { - if (!await InitGlobalConfigAndLanguage().ConfigureAwait(false)) { - return false; - } + NetworkGroup = networkGroup; + } - if (ASF.GlobalConfig == null) { - throw new InvalidOperationException(nameof(ASF.GlobalConfig)); - } - - OS.Init(ASF.GlobalConfig.OptimizationMode); - - if (!await InitGlobalDatabaseAndServices().ConfigureAwait(false)) { - return false; - } - - await ASF.Init().ConfigureAwait(false); - - return true; + private static void HandlePathArgument(string path) { + if (string.IsNullOrEmpty(path)) { + throw new ArgumentNullException(nameof(path)); } - private static async Task InitCore(IReadOnlyCollection? args) { - Directory.SetCurrentDirectory(SharedInfo.HomeDirectory); - - // Allow loading configs from source tree if it's a debug build - if (Debugging.IsDebugBuild) { - // Common structure is bin/(x64/)Debug/ArchiSteamFarm.exe, so we allow up to 4 directories up - for (byte i = 0; i < 4; i++) { - Directory.SetCurrentDirectory(".."); - - if (Directory.Exists(SharedInfo.ConfigDirectory)) { - break; - } - } - - // If config directory doesn't exist after our adjustment, abort all of that - if (!Directory.Exists(SharedInfo.ConfigDirectory)) { - Directory.SetCurrentDirectory(SharedInfo.HomeDirectory); - } - } - - // Parse environment variables - ParseEnvironmentVariables(); - - // Parse args - if (args?.Count > 0) { - ParseArgs(args); - } - - bool uniqueInstance = await OS.RegisterProcess().ConfigureAwait(false); - - Logging.InitCoreLoggers(uniqueInstance); - - if (!uniqueInstance) { - ASF.ArchiLogger.LogGenericError(Strings.ErrorSingleInstanceRequired); - await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); - - return false; - } - - OS.CoreInit(SystemRequired); - - Console.Title = SharedInfo.ProgramIdentifier; - ASF.ArchiLogger.LogGenericInfo(SharedInfo.ProgramIdentifier); - - string? copyright = Assembly.GetExecutingAssembly().GetCustomAttribute()?.Copyright; - - if (!string.IsNullOrEmpty(copyright)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - ASF.ArchiLogger.LogGenericInfo(copyright!); - } - - if (IgnoreUnsupportedEnvironment) { - ASF.ArchiLogger.LogGenericWarning(Strings.WarningRunningInUnsupportedEnvironment); - } else { - if (!OS.VerifyEnvironment()) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnsupportedEnvironment, SharedInfo.BuildInfo.Variant, OS.Version)); - await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); - - return false; - } - - if (OS.IsRunningAsRoot()) { - ASF.ArchiLogger.LogGenericError(Strings.WarningRunningAsRoot); - await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); - - return false; - } - } - - if (!Directory.Exists(SharedInfo.ConfigDirectory)) { - ASF.ArchiLogger.LogGenericError(Strings.ErrorConfigDirectoryNotFound); - await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); - - return false; - } - - return true; - } - - private static async Task InitGlobalConfigAndLanguage() { - string globalConfigFile = ASF.GetFilePath(ASF.EFileType.Config); - - if (string.IsNullOrEmpty(globalConfigFile)) { - throw new ArgumentNullException(nameof(globalConfigFile)); - } - - string? latestJson = null; - - GlobalConfig? globalConfig; - - if (File.Exists(globalConfigFile)) { - (globalConfig, latestJson) = await GlobalConfig.Load(globalConfigFile).ConfigureAwait(false); - - if (globalConfig == null) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorGlobalConfigNotLoaded, globalConfigFile)); - await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false); - - return false; - } - } else { - globalConfig = new GlobalConfig(); - } - - if (globalConfig.Debug) { - ASF.ArchiLogger.LogGenericDebug($"{globalConfigFile}: {JsonConvert.SerializeObject(globalConfig, Formatting.Indented)}"); - } - - if (!string.IsNullOrEmpty(globalConfig.CurrentCulture)) { - try { - // GetCultureInfo() would be better but we can't use it for specifying neutral cultures such as "en" - CultureInfo culture = CultureInfo.CreateSpecificCulture(globalConfig.CurrentCulture!); - CultureInfo.DefaultThreadCurrentCulture = CultureInfo.DefaultThreadCurrentUICulture = culture; - } catch (Exception e) { - ASF.ArchiLogger.LogGenericWarningException(e); - - ASF.ArchiLogger.LogGenericError(Strings.ErrorInvalidCurrentCulture); - } - } else { - // April Fools easter egg logic - AprilFools.Init(); - } - - if (!string.IsNullOrEmpty(latestJson)) { - ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.AutomaticFileMigration, globalConfigFile)); - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - await SerializableFile.Write(globalConfigFile, latestJson!).ConfigureAwait(false); - - ASF.ArchiLogger.LogGenericInfo(Strings.Done); - } - - ASF.GlobalConfig = globalConfig; - - Utilities.WarnAboutIncompleteTranslation(Strings.ResourceManager); - - return true; - } - - private static async Task InitGlobalDatabaseAndServices() { - string globalDatabaseFile = ASF.GetFilePath(ASF.EFileType.Database); - - if (string.IsNullOrEmpty(globalDatabaseFile)) { - throw new ArgumentNullException(nameof(globalDatabaseFile)); - } - - if (!File.Exists(globalDatabaseFile)) { - ASF.ArchiLogger.LogGenericInfo(Strings.Welcome); - await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); - ASF.ArchiLogger.LogGenericWarning(Strings.WarningPrivacyPolicy); - await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false); - } - - GlobalDatabase? globalDatabase = await GlobalDatabase.CreateOrLoad(globalDatabaseFile).ConfigureAwait(false); - - if (globalDatabase == null) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorDatabaseInvalid, globalDatabaseFile)); - await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false); - - return false; - } - - ASF.GlobalDatabase = globalDatabase; - - // If debugging is on, we prepare debug directory prior to running - if (Debugging.IsUserDebugging) { - if (Debugging.IsDebugConfigured) { - ASF.ArchiLogger.LogGenericDebug($"{globalDatabaseFile}: {JsonConvert.SerializeObject(ASF.GlobalDatabase, Formatting.Indented)}"); - } - - Logging.EnableTraceLogging(); - - if (Debugging.IsDebugConfigured) { - DebugLog.AddListener(new Debugging.DebugListener()); - DebugLog.Enabled = true; - - if (Directory.Exists(SharedInfo.DebugDirectory)) { - try { - Directory.Delete(SharedInfo.DebugDirectory, true); - await Task.Delay(1000).ConfigureAwait(false); // Dirty workaround giving Windows some time to sync - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - } - - try { - Directory.CreateDirectory(SharedInfo.DebugDirectory); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - WebBrowser.Init(); - - return true; - } - - private static async Task InitShutdownSequence() { - if (ShutdownSequenceInitialized) { - return false; - } - - ShutdownSequenceInitialized = true; - -#if !NETFRAMEWORK - if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { - // Unregister from registered signals - foreach (PosixSignalRegistration registration in RegisteredPosixSignals.Values) { - registration.Dispose(); - } - - RegisteredPosixSignals.Clear(); - } -#endif - - // Sockets created by IPC might still be running for a short while after complete app shutdown - // Ensure that IPC is stopped before we finalize shutdown sequence - await ArchiKestrel.Stop().ConfigureAwait(false); - - // Stop all the active bots so they can disconnect cleanly - if (Bot.Bots?.Count > 0) { - // Stop() function can block due to SK2 sockets, don't forget a maximum delay - await Task.WhenAny(Utilities.InParallel(Bot.Bots.Values.Select(static bot => Task.Run(() => bot.Stop(true)))), Task.Delay(Bot.Bots.Count * WebBrowser.MaxTries * 1000)).ConfigureAwait(false); - - // Extra second for Steam requests to go through - await Task.Delay(1000).ConfigureAwait(false); - } - - // Flush all the pending writes to log files - LogManager.Flush(); - - // Unregister the process from single instancing - OS.UnregisterProcess(); - - return true; - } - - private static async Task Main(string[] args) { - if (args == null) { - throw new ArgumentNullException(nameof(args)); - } - - // Initialize - await Init(args.Length > 0 ? args : null).ConfigureAwait(false); - - // Wait for shutdown event - return await ShutdownResetEvent.Task.ConfigureAwait(false); - } - - private static async void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) => await Exit(130).ConfigureAwait(false); - -#if !NETFRAMEWORK - private static async void OnPosixSignal(PosixSignalContext signal) { - if (signal == null) { - throw new ArgumentNullException(nameof(signal)); - } - - switch (signal.Signal) { - case PosixSignal.SIGINT: - await Exit(130).ConfigureAwait(false); - - break; - case PosixSignal.SIGTERM: - await Exit().ConfigureAwait(false); - - break; - } - } -#endif - - private static async void OnProcessExit(object? sender, EventArgs e) => await Shutdown().ConfigureAwait(false); - - private static async void OnUnhandledException(object? sender, UnhandledExceptionEventArgs e) { - if (e == null) { - throw new ArgumentNullException(nameof(e)); - } - - if (e.ExceptionObject == null) { - throw new ArgumentNullException(nameof(e)); - } - - await ASF.ArchiLogger.LogFatalException((Exception) e.ExceptionObject).ConfigureAwait(false); - await Exit(1).ConfigureAwait(false); - } - - private static async void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) { - if (e == null) { - throw new ArgumentNullException(nameof(e)); - } - - if (e.Exception == null) { - throw new ArgumentNullException(nameof(e)); - } - - await ASF.ArchiLogger.LogFatalException(e.Exception).ConfigureAwait(false); - - // Normally we should abort the application here, but many tasks are in fact failing in SK2 code which we can't easily fix - // Thanks Valve. - e.SetObserved(); - } - - private static void ParseArgs(IReadOnlyCollection args) { - if ((args == null) || (args.Count == 0)) { - throw new ArgumentNullException(nameof(args)); - } - - bool cryptKeyNext = false; - bool networkGroupNext = false; - bool pathNext = false; - - foreach (string arg in args) { - switch (arg.ToUpperInvariant()) { - case "--CRYPTKEY" when !cryptKeyNext && !networkGroupNext && !pathNext: - cryptKeyNext = true; - - break; - case "--IGNORE-UNSUPPORTED-ENVIRONMENT" when !cryptKeyNext && !networkGroupNext && !pathNext: - IgnoreUnsupportedEnvironment = true; - - break; - case "--NETWORK-GROUP" when !cryptKeyNext && !networkGroupNext && !pathNext: - networkGroupNext = true; - - break; - case "--NO-CONFIG-MIGRATE" when !cryptKeyNext && !networkGroupNext && !pathNext: - ConfigMigrate = false; - - break; - case "--NO-CONFIG-WATCH" when !cryptKeyNext && !networkGroupNext && !pathNext: - ConfigWatch = false; - - break; - case "--NO-RESTART" when !cryptKeyNext && !networkGroupNext && !pathNext: - RestartAllowed = false; - - break; - case "--PROCESS-REQUIRED" when !cryptKeyNext && !networkGroupNext && !pathNext: - ProcessRequired = true; - - break; - case "--PATH" when !cryptKeyNext && !networkGroupNext && !pathNext: - pathNext = true; - - break; - case "--SERVICE" when !cryptKeyNext && !networkGroupNext && !pathNext: - Service = true; - - break; - case "--SYSTEM-REQUIRED" when !cryptKeyNext && !networkGroupNext && !pathNext: - SystemRequired = true; - - break; - default: - if (cryptKeyNext) { - cryptKeyNext = false; - HandleCryptKeyArgument(arg); - } else if (networkGroupNext) { - networkGroupNext = false; - HandleNetworkGroupArgument(arg); - } else if (pathNext) { - pathNext = false; - HandlePathArgument(arg); - } else { - switch (arg.Length) { - case > 16 when arg.StartsWith("--NETWORK-GROUP=", StringComparison.OrdinalIgnoreCase): - HandleNetworkGroupArgument(arg[16..]); - - break; - case > 11 when arg.StartsWith("--CRYPTKEY=", StringComparison.OrdinalIgnoreCase): - HandleCryptKeyArgument(arg[11..]); - - break; - case > 7 when arg.StartsWith("--PATH=", StringComparison.OrdinalIgnoreCase): - HandlePathArgument(arg[7..]); - - break; - default: - ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownCommandLineArgument, arg)); - - break; - } - } - - break; - } - } - } - - private static void ParseEnvironmentVariables() { - // We're using a single try-catch block here, as a failure for getting one variable will result in the same failure for all other ones - try { - string? envCryptKey = Environment.GetEnvironmentVariable(SharedInfo.EnvironmentVariableCryptKey); - - if (!string.IsNullOrEmpty(envCryptKey)) { - HandleCryptKeyArgument(envCryptKey); - } - - string? envNetworkGroup = Environment.GetEnvironmentVariable(SharedInfo.EnvironmentVariableNetworkGroup); - - if (!string.IsNullOrEmpty(envNetworkGroup)) { - HandleNetworkGroupArgument(envNetworkGroup); - } - - string? envPath = Environment.GetEnvironmentVariable(SharedInfo.EnvironmentVariablePath); - - if (!string.IsNullOrEmpty(envPath)) { - HandlePathArgument(envPath); - } - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - } - } - - private static async Task Shutdown(byte exitCode = 0) { - if (!await InitShutdownSequence().ConfigureAwait(false)) { - return; - } - - ShutdownResetEvent.TrySetResult(exitCode); + try { + Directory.SetCurrentDirectory(path); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); } } + + private static async Task Init(IReadOnlyCollection? args) { + AppDomain.CurrentDomain.ProcessExit += OnProcessExit; + AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; + TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; + +#if !NETFRAMEWORK + if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { + foreach (PosixSignal signal in SupportedPosixSignals) { + RegisteredPosixSignals[signal] = PosixSignalRegistration.Create(signal, OnPosixSignal); + } + } +#endif + + Console.CancelKeyPress += OnCancelKeyPress; + + // Add support for custom encodings + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + // Add support for custom logging targets + Target.Register(HistoryTarget.TargetName); + Target.Register(SteamTarget.TargetName); + + if (!await InitCore(args).ConfigureAwait(false) || !await InitASF().ConfigureAwait(false)) { + await Exit(1).ConfigureAwait(false); + } + } + + private static async Task InitASF() { + if (!await InitGlobalConfigAndLanguage().ConfigureAwait(false)) { + return false; + } + + if (ASF.GlobalConfig == null) { + throw new InvalidOperationException(nameof(ASF.GlobalConfig)); + } + + OS.Init(ASF.GlobalConfig.OptimizationMode); + + if (!await InitGlobalDatabaseAndServices().ConfigureAwait(false)) { + return false; + } + + await ASF.Init().ConfigureAwait(false); + + return true; + } + + private static async Task InitCore(IReadOnlyCollection? args) { + Directory.SetCurrentDirectory(SharedInfo.HomeDirectory); + + // Allow loading configs from source tree if it's a debug build + if (Debugging.IsDebugBuild) { + // Common structure is bin/(x64/)Debug/ArchiSteamFarm.exe, so we allow up to 4 directories up + for (byte i = 0; i < 4; i++) { + Directory.SetCurrentDirectory(".."); + + if (Directory.Exists(SharedInfo.ConfigDirectory)) { + break; + } + } + + // If config directory doesn't exist after our adjustment, abort all of that + if (!Directory.Exists(SharedInfo.ConfigDirectory)) { + Directory.SetCurrentDirectory(SharedInfo.HomeDirectory); + } + } + + // Parse environment variables + ParseEnvironmentVariables(); + + // Parse args + if (args?.Count > 0) { + ParseArgs(args); + } + + bool uniqueInstance = await OS.RegisterProcess().ConfigureAwait(false); + + Logging.InitCoreLoggers(uniqueInstance); + + if (!uniqueInstance) { + ASF.ArchiLogger.LogGenericError(Strings.ErrorSingleInstanceRequired); + await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); + + return false; + } + + OS.CoreInit(SystemRequired); + + Console.Title = SharedInfo.ProgramIdentifier; + ASF.ArchiLogger.LogGenericInfo(SharedInfo.ProgramIdentifier); + + string? copyright = Assembly.GetExecutingAssembly().GetCustomAttribute()?.Copyright; + + if (!string.IsNullOrEmpty(copyright)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + ASF.ArchiLogger.LogGenericInfo(copyright!); + } + + if (IgnoreUnsupportedEnvironment) { + ASF.ArchiLogger.LogGenericWarning(Strings.WarningRunningInUnsupportedEnvironment); + } else { + if (!OS.VerifyEnvironment()) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnsupportedEnvironment, SharedInfo.BuildInfo.Variant, OS.Version)); + await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); + + return false; + } + + if (OS.IsRunningAsRoot()) { + ASF.ArchiLogger.LogGenericError(Strings.WarningRunningAsRoot); + await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); + + return false; + } + } + + if (!Directory.Exists(SharedInfo.ConfigDirectory)) { + ASF.ArchiLogger.LogGenericError(Strings.ErrorConfigDirectoryNotFound); + await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); + + return false; + } + + return true; + } + + private static async Task InitGlobalConfigAndLanguage() { + string globalConfigFile = ASF.GetFilePath(ASF.EFileType.Config); + + if (string.IsNullOrEmpty(globalConfigFile)) { + throw new ArgumentNullException(nameof(globalConfigFile)); + } + + string? latestJson = null; + + GlobalConfig? globalConfig; + + if (File.Exists(globalConfigFile)) { + (globalConfig, latestJson) = await GlobalConfig.Load(globalConfigFile).ConfigureAwait(false); + + if (globalConfig == null) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorGlobalConfigNotLoaded, globalConfigFile)); + await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false); + + return false; + } + } else { + globalConfig = new GlobalConfig(); + } + + if (globalConfig.Debug) { + ASF.ArchiLogger.LogGenericDebug($"{globalConfigFile}: {JsonConvert.SerializeObject(globalConfig, Formatting.Indented)}"); + } + + if (!string.IsNullOrEmpty(globalConfig.CurrentCulture)) { + try { + // GetCultureInfo() would be better but we can't use it for specifying neutral cultures such as "en" + CultureInfo culture = CultureInfo.CreateSpecificCulture(globalConfig.CurrentCulture!); + CultureInfo.DefaultThreadCurrentCulture = CultureInfo.DefaultThreadCurrentUICulture = culture; + } catch (Exception e) { + ASF.ArchiLogger.LogGenericWarningException(e); + + ASF.ArchiLogger.LogGenericError(Strings.ErrorInvalidCurrentCulture); + } + } else { + // April Fools easter egg logic + AprilFools.Init(); + } + + if (!string.IsNullOrEmpty(latestJson)) { + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.AutomaticFileMigration, globalConfigFile)); + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + await SerializableFile.Write(globalConfigFile, latestJson!).ConfigureAwait(false); + + ASF.ArchiLogger.LogGenericInfo(Strings.Done); + } + + ASF.GlobalConfig = globalConfig; + + Utilities.WarnAboutIncompleteTranslation(Strings.ResourceManager); + + return true; + } + + private static async Task InitGlobalDatabaseAndServices() { + string globalDatabaseFile = ASF.GetFilePath(ASF.EFileType.Database); + + if (string.IsNullOrEmpty(globalDatabaseFile)) { + throw new ArgumentNullException(nameof(globalDatabaseFile)); + } + + if (!File.Exists(globalDatabaseFile)) { + ASF.ArchiLogger.LogGenericInfo(Strings.Welcome); + await Task.Delay(SharedInfo.InformationDelay).ConfigureAwait(false); + ASF.ArchiLogger.LogGenericWarning(Strings.WarningPrivacyPolicy); + await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false); + } + + GlobalDatabase? globalDatabase = await GlobalDatabase.CreateOrLoad(globalDatabaseFile).ConfigureAwait(false); + + if (globalDatabase == null) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorDatabaseInvalid, globalDatabaseFile)); + await Task.Delay(SharedInfo.ShortInformationDelay).ConfigureAwait(false); + + return false; + } + + ASF.GlobalDatabase = globalDatabase; + + // If debugging is on, we prepare debug directory prior to running + if (Debugging.IsUserDebugging) { + if (Debugging.IsDebugConfigured) { + ASF.ArchiLogger.LogGenericDebug($"{globalDatabaseFile}: {JsonConvert.SerializeObject(ASF.GlobalDatabase, Formatting.Indented)}"); + } + + Logging.EnableTraceLogging(); + + if (Debugging.IsDebugConfigured) { + DebugLog.AddListener(new Debugging.DebugListener()); + DebugLog.Enabled = true; + + if (Directory.Exists(SharedInfo.DebugDirectory)) { + try { + Directory.Delete(SharedInfo.DebugDirectory, true); + await Task.Delay(1000).ConfigureAwait(false); // Dirty workaround giving Windows some time to sync + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + } + + try { + Directory.CreateDirectory(SharedInfo.DebugDirectory); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + WebBrowser.Init(); + + return true; + } + + private static async Task InitShutdownSequence() { + if (ShutdownSequenceInitialized) { + return false; + } + + ShutdownSequenceInitialized = true; + +#if !NETFRAMEWORK + if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { + // Unregister from registered signals + foreach (PosixSignalRegistration registration in RegisteredPosixSignals.Values) { + registration.Dispose(); + } + + RegisteredPosixSignals.Clear(); + } +#endif + + // Sockets created by IPC might still be running for a short while after complete app shutdown + // Ensure that IPC is stopped before we finalize shutdown sequence + await ArchiKestrel.Stop().ConfigureAwait(false); + + // Stop all the active bots so they can disconnect cleanly + if (Bot.Bots?.Count > 0) { + // Stop() function can block due to SK2 sockets, don't forget a maximum delay + await Task.WhenAny(Utilities.InParallel(Bot.Bots.Values.Select(static bot => Task.Run(() => bot.Stop(true)))), Task.Delay(Bot.Bots.Count * WebBrowser.MaxTries * 1000)).ConfigureAwait(false); + + // Extra second for Steam requests to go through + await Task.Delay(1000).ConfigureAwait(false); + } + + // Flush all the pending writes to log files + LogManager.Flush(); + + // Unregister the process from single instancing + OS.UnregisterProcess(); + + return true; + } + + private static async Task Main(string[] args) { + if (args == null) { + throw new ArgumentNullException(nameof(args)); + } + + // Initialize + await Init(args.Length > 0 ? args : null).ConfigureAwait(false); + + // Wait for shutdown event + return await ShutdownResetEvent.Task.ConfigureAwait(false); + } + + private static async void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) => await Exit(130).ConfigureAwait(false); + +#if !NETFRAMEWORK + private static async void OnPosixSignal(PosixSignalContext signal) { + if (signal == null) { + throw new ArgumentNullException(nameof(signal)); + } + + switch (signal.Signal) { + case PosixSignal.SIGINT: + await Exit(130).ConfigureAwait(false); + + break; + case PosixSignal.SIGTERM: + await Exit().ConfigureAwait(false); + + break; + } + } +#endif + + private static async void OnProcessExit(object? sender, EventArgs e) => await Shutdown().ConfigureAwait(false); + + private static async void OnUnhandledException(object? sender, UnhandledExceptionEventArgs e) { + if (e == null) { + throw new ArgumentNullException(nameof(e)); + } + + if (e.ExceptionObject == null) { + throw new ArgumentNullException(nameof(e)); + } + + await ASF.ArchiLogger.LogFatalException((Exception) e.ExceptionObject).ConfigureAwait(false); + await Exit(1).ConfigureAwait(false); + } + + private static async void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) { + if (e == null) { + throw new ArgumentNullException(nameof(e)); + } + + if (e.Exception == null) { + throw new ArgumentNullException(nameof(e)); + } + + await ASF.ArchiLogger.LogFatalException(e.Exception).ConfigureAwait(false); + + // Normally we should abort the application here, but many tasks are in fact failing in SK2 code which we can't easily fix + // Thanks Valve. + e.SetObserved(); + } + + private static void ParseArgs(IReadOnlyCollection args) { + if ((args == null) || (args.Count == 0)) { + throw new ArgumentNullException(nameof(args)); + } + + bool cryptKeyNext = false; + bool networkGroupNext = false; + bool pathNext = false; + + foreach (string arg in args) { + switch (arg.ToUpperInvariant()) { + case "--CRYPTKEY" when !cryptKeyNext && !networkGroupNext && !pathNext: + cryptKeyNext = true; + + break; + case "--IGNORE-UNSUPPORTED-ENVIRONMENT" when !cryptKeyNext && !networkGroupNext && !pathNext: + IgnoreUnsupportedEnvironment = true; + + break; + case "--NETWORK-GROUP" when !cryptKeyNext && !networkGroupNext && !pathNext: + networkGroupNext = true; + + break; + case "--NO-CONFIG-MIGRATE" when !cryptKeyNext && !networkGroupNext && !pathNext: + ConfigMigrate = false; + + break; + case "--NO-CONFIG-WATCH" when !cryptKeyNext && !networkGroupNext && !pathNext: + ConfigWatch = false; + + break; + case "--NO-RESTART" when !cryptKeyNext && !networkGroupNext && !pathNext: + RestartAllowed = false; + + break; + case "--PROCESS-REQUIRED" when !cryptKeyNext && !networkGroupNext && !pathNext: + ProcessRequired = true; + + break; + case "--PATH" when !cryptKeyNext && !networkGroupNext && !pathNext: + pathNext = true; + + break; + case "--SERVICE" when !cryptKeyNext && !networkGroupNext && !pathNext: + Service = true; + + break; + case "--SYSTEM-REQUIRED" when !cryptKeyNext && !networkGroupNext && !pathNext: + SystemRequired = true; + + break; + default: + if (cryptKeyNext) { + cryptKeyNext = false; + HandleCryptKeyArgument(arg); + } else if (networkGroupNext) { + networkGroupNext = false; + HandleNetworkGroupArgument(arg); + } else if (pathNext) { + pathNext = false; + HandlePathArgument(arg); + } else { + switch (arg.Length) { + case > 16 when arg.StartsWith("--NETWORK-GROUP=", StringComparison.OrdinalIgnoreCase): + HandleNetworkGroupArgument(arg[16..]); + + break; + case > 11 when arg.StartsWith("--CRYPTKEY=", StringComparison.OrdinalIgnoreCase): + HandleCryptKeyArgument(arg[11..]); + + break; + case > 7 when arg.StartsWith("--PATH=", StringComparison.OrdinalIgnoreCase): + HandlePathArgument(arg[7..]); + + break; + default: + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownCommandLineArgument, arg)); + + break; + } + } + + break; + } + } + } + + private static void ParseEnvironmentVariables() { + // We're using a single try-catch block here, as a failure for getting one variable will result in the same failure for all other ones + try { + string? envCryptKey = Environment.GetEnvironmentVariable(SharedInfo.EnvironmentVariableCryptKey); + + if (!string.IsNullOrEmpty(envCryptKey)) { + HandleCryptKeyArgument(envCryptKey); + } + + string? envNetworkGroup = Environment.GetEnvironmentVariable(SharedInfo.EnvironmentVariableNetworkGroup); + + if (!string.IsNullOrEmpty(envNetworkGroup)) { + HandleNetworkGroupArgument(envNetworkGroup); + } + + string? envPath = Environment.GetEnvironmentVariable(SharedInfo.EnvironmentVariablePath); + + if (!string.IsNullOrEmpty(envPath)) { + HandlePathArgument(envPath); + } + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + } + } + + private static async Task Shutdown(byte exitCode = 0) { + if (!await InitShutdownSequence().ConfigureAwait(false)) { + return; + } + + ShutdownResetEvent.TrySetResult(exitCode); + } } diff --git a/ArchiSteamFarm/SharedInfo.cs b/ArchiSteamFarm/SharedInfo.cs index db3e3b6c6..4a86f05d4 100644 --- a/ArchiSteamFarm/SharedInfo.cs +++ b/ArchiSteamFarm/SharedInfo.cs @@ -26,75 +26,76 @@ using ArchiSteamFarm.Core; using ArchiSteamFarm.Plugins; using JetBrains.Annotations; -namespace ArchiSteamFarm { - public static class SharedInfo { - [PublicAPI] - public const string ConfigDirectory = "config"; +namespace ArchiSteamFarm; - internal const ulong ArchiSteamID = 76561198006963719; - internal const string ArchivalLogFile = "log.{#}.txt"; - internal const string ArchivalLogsDirectory = "logs"; - internal const string ASF = nameof(ASF); - internal const ulong ASFGroupSteamID = 103582791440160998; - internal const string AssemblyDocumentation = AssemblyName + ".xml"; - internal const string AssemblyName = nameof(ArchiSteamFarm); - internal const string DatabaseExtension = ".db"; - internal const string DebugDirectory = "debug"; - internal const string EnvironmentVariableCryptKey = ASF + "_CRYPTKEY"; - internal const string EnvironmentVariableNetworkGroup = ASF + "_NETWORK_GROUP"; - internal const string EnvironmentVariablePath = ASF + "_PATH"; - internal const string GithubReleaseURL = "https://api.github.com/repos/" + GithubRepo + "/releases"; - internal const string GithubRepo = "JustArchiNET/" + AssemblyName; - internal const string GlobalConfigFileName = ASF + JsonConfigExtension; - internal const string GlobalDatabaseFileName = ASF + DatabaseExtension; - internal const ushort InformationDelay = 10000; - internal const string IPCConfigExtension = ".config"; - internal const string IPCConfigFile = nameof(IPC) + IPCConfigExtension; - internal const string JsonConfigExtension = ".json"; - internal const string KeysExtension = ".keys"; - internal const string KeysUnusedExtension = ".unused"; - internal const string KeysUsedExtension = ".used"; - internal const string LicenseName = "Apache 2.0"; - internal const string LicenseURL = "https://www.apache.org/licenses/LICENSE-2.0"; - internal const string LogFile = "log.txt"; - internal const string LolcatCultureName = "qps-Ploc"; - internal const string MobileAuthenticatorExtension = ".maFile"; - internal const string PluginsDirectory = "plugins"; - internal const string ProjectURL = "https://github.com/" + GithubRepo; - internal const string SentryHashExtension = ".bin"; - internal const ushort ShortInformationDelay = InformationDelay / 2; - internal const string StatisticsServer = "asf.justarchi.net"; - internal const string UlongCompatibilityStringPrefix = "s_"; - internal const string UpdateDirectory = "_old"; - internal const string WebsiteDirectory = "www"; +public static class SharedInfo { + [PublicAPI] + public const string ConfigDirectory = "config"; - internal static string HomeDirectory { - get { - if (!string.IsNullOrEmpty(CachedHomeDirectory)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - return CachedHomeDirectory!; - } + internal const ulong ArchiSteamID = 76561198006963719; + internal const string ArchivalLogFile = "log.{#}.txt"; + internal const string ArchivalLogsDirectory = "logs"; + internal const string ASF = nameof(ASF); + internal const ulong ASFGroupSteamID = 103582791440160998; + internal const string AssemblyDocumentation = AssemblyName + ".xml"; + internal const string AssemblyName = nameof(ArchiSteamFarm); + internal const string DatabaseExtension = ".db"; + internal const string DebugDirectory = "debug"; + internal const string EnvironmentVariableCryptKey = ASF + "_CRYPTKEY"; + internal const string EnvironmentVariableNetworkGroup = ASF + "_NETWORK_GROUP"; + internal const string EnvironmentVariablePath = ASF + "_PATH"; + internal const string GithubReleaseURL = "https://api.github.com/repos/" + GithubRepo + "/releases"; + internal const string GithubRepo = "JustArchiNET/" + AssemblyName; + internal const string GlobalConfigFileName = ASF + JsonConfigExtension; + internal const string GlobalDatabaseFileName = ASF + DatabaseExtension; + internal const ushort InformationDelay = 10000; + internal const string IPCConfigExtension = ".config"; + internal const string IPCConfigFile = nameof(IPC) + IPCConfigExtension; + internal const string JsonConfigExtension = ".json"; + internal const string KeysExtension = ".keys"; + internal const string KeysUnusedExtension = ".unused"; + internal const string KeysUsedExtension = ".used"; + internal const string LicenseName = "Apache 2.0"; + internal const string LicenseURL = "https://www.apache.org/licenses/LICENSE-2.0"; + internal const string LogFile = "log.txt"; + internal const string LolcatCultureName = "qps-Ploc"; + internal const string MobileAuthenticatorExtension = ".maFile"; + internal const string PluginsDirectory = "plugins"; + internal const string ProjectURL = "https://github.com/" + GithubRepo; + internal const string SentryHashExtension = ".bin"; + internal const ushort ShortInformationDelay = InformationDelay / 2; + internal const string StatisticsServer = "asf.justarchi.net"; + internal const string UlongCompatibilityStringPrefix = "s_"; + internal const string UpdateDirectory = "_old"; + internal const string WebsiteDirectory = "www"; - // We're aiming to handle two possible cases here, classic publish and single-file publish which is possible with OS-specific builds - // In order to achieve that, we have to guess the case above from the binary's name - // We can't just return our base directory since it could lead to the (wrong) temporary directory of extracted files in a single-publish scenario - // If the path goes to our own binary, the user is using OS-specific build, single-file or not, we'll use path to location of that binary then - // Otherwise, this path goes to some third-party binary, likely dotnet/mono, the user is using our generic build or other custom binary, we need to trust our base directory then - CachedHomeDirectory = Path.GetFileNameWithoutExtension(OS.ProcessFileName) == AssemblyName ? Path.GetDirectoryName(OS.ProcessFileName) ?? AppContext.BaseDirectory : AppContext.BaseDirectory; - - return CachedHomeDirectory; + internal static string HomeDirectory { + get { + if (!string.IsNullOrEmpty(CachedHomeDirectory)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + return CachedHomeDirectory!; } + + // We're aiming to handle two possible cases here, classic publish and single-file publish which is possible with OS-specific builds + // In order to achieve that, we have to guess the case above from the binary's name + // We can't just return our base directory since it could lead to the (wrong) temporary directory of extracted files in a single-publish scenario + // If the path goes to our own binary, the user is using OS-specific build, single-file or not, we'll use path to location of that binary then + // Otherwise, this path goes to some third-party binary, likely dotnet/mono, the user is using our generic build or other custom binary, we need to trust our base directory then + CachedHomeDirectory = Path.GetFileNameWithoutExtension(OS.ProcessFileName) == AssemblyName ? Path.GetDirectoryName(OS.ProcessFileName) ?? AppContext.BaseDirectory : AppContext.BaseDirectory; + + return CachedHomeDirectory; } + } - internal static string ProgramIdentifier => $"{PublicIdentifier} V{Version} ({BuildInfo.Variant}/{ModuleVersion} | {OS.Version})"; - internal static string PublicIdentifier => AssemblyName + (BuildInfo.IsCustomBuild ? "-custom" : PluginsCore.HasCustomPluginsLoaded ? "-modded" : ""); - internal static Version Version => Assembly.GetExecutingAssembly().GetName().Version ?? throw new InvalidOperationException(nameof(Version)); + internal static string ProgramIdentifier => $"{PublicIdentifier} V{Version} ({BuildInfo.Variant}/{ModuleVersion} | {OS.Version})"; + internal static string PublicIdentifier => AssemblyName + (BuildInfo.IsCustomBuild ? "-custom" : PluginsCore.HasCustomPluginsLoaded ? "-modded" : ""); + internal static Version Version => Assembly.GetExecutingAssembly().GetName().Version ?? throw new InvalidOperationException(nameof(Version)); - private static Guid ModuleVersion => Assembly.GetExecutingAssembly().ManifestModule.ModuleVersionId; + private static Guid ModuleVersion => Assembly.GetExecutingAssembly().ManifestModule.ModuleVersionId; - private static string? CachedHomeDirectory; + private static string? CachedHomeDirectory; - internal static class BuildInfo { + internal static class BuildInfo { #if ASF_VARIANT_DOCKER internal static bool CanUpdate => false; internal static string Variant => "docker"; @@ -123,13 +124,12 @@ namespace ArchiSteamFarm { internal static bool CanUpdate => true; internal static string Variant => "win-x64"; #else - internal static bool CanUpdate => false; - internal static string Variant => SourceVariant; + internal static bool CanUpdate => false; + internal static string Variant => SourceVariant; #endif - private const string SourceVariant = "source"; + private const string SourceVariant = "source"; - internal static bool IsCustomBuild => Variant == SourceVariant; - } + internal static bool IsCustomBuild => Variant == SourceVariant; } } diff --git a/ArchiSteamFarm/Steam/Bot.cs b/ArchiSteamFarm/Steam/Bot.cs index 5b62b54d0..bab8d1ecf 100644 --- a/ArchiSteamFarm/Steam/Bot.cs +++ b/ArchiSteamFarm/Steam/Bot.cs @@ -56,1755 +56,1244 @@ using Newtonsoft.Json; using SteamKit2; using SteamKit2.Internal; -namespace ArchiSteamFarm.Steam { - public sealed class Bot : IAsyncDisposable { - internal const ushort CallbackSleep = 500; // In milliseconds - internal const byte MinCardsPerBadge = 5; - internal const byte MinPlayingBlockedTTL = 60; // Delay in seconds added when account was occupied during our disconnect, to not disconnect other Steam client session too soon +namespace ArchiSteamFarm.Steam; - private const char DefaultBackgroundKeysRedeemerSeparator = '\t'; - private const byte LoginCooldownInMinutes = 25; // Captcha disappears after around 20 minutes, so we make it 25 - private const uint LoginID = 1242; // This must be the same for all ASF bots and all ASF processes - private const byte MaxInvalidPasswordFailures = WebBrowser.MaxTries; // Max InvalidPassword failures in a row before we determine that our password is invalid (because Steam wrongly returns those, of course) - private const byte MaxTwoFactorCodeFailures = WebBrowser.MaxTries; // Max TwoFactorCodeMismatch failures in a row before we determine that our 2FA credentials are invalid (because Steam wrongly returns those, of course) - private const byte RedeemCooldownInHours = 1; // 1 hour since first redeem attempt, this is a limitation enforced by Steam +public sealed class Bot : IAsyncDisposable { + internal const ushort CallbackSleep = 500; // In milliseconds + internal const byte MinCardsPerBadge = 5; + internal const byte MinPlayingBlockedTTL = 60; // Delay in seconds added when account was occupied during our disconnect, to not disconnect other Steam client session too soon - [PublicAPI] - public static IReadOnlyDictionary? BotsReadOnly => Bots; + private const char DefaultBackgroundKeysRedeemerSeparator = '\t'; + private const byte LoginCooldownInMinutes = 25; // Captcha disappears after around 20 minutes, so we make it 25 + private const uint LoginID = 1242; // This must be the same for all ASF bots and all ASF processes + private const byte MaxInvalidPasswordFailures = WebBrowser.MaxTries; // Max InvalidPassword failures in a row before we determine that our password is invalid (because Steam wrongly returns those, of course) + private const byte MaxTwoFactorCodeFailures = WebBrowser.MaxTries; // Max TwoFactorCodeMismatch failures in a row before we determine that our 2FA credentials are invalid (because Steam wrongly returns those, of course) + private const byte RedeemCooldownInHours = 1; // 1 hour since first redeem attempt, this is a limitation enforced by Steam - internal static ConcurrentDictionary? Bots { get; private set; } - internal static StringComparer? BotsComparer { get; private set; } - internal static EOSType OSType { get; private set; } = EOSType.Unknown; + [PublicAPI] + public static IReadOnlyDictionary? BotsReadOnly => Bots; - private static readonly SemaphoreSlim BotsSemaphore = new(1, 1); + internal static ConcurrentDictionary? Bots { get; private set; } + internal static StringComparer? BotsComparer { get; private set; } + internal static EOSType OSType { get; private set; } = EOSType.Unknown; - [JsonIgnore] - [PublicAPI] - public Actions Actions { get; } + private static readonly SemaphoreSlim BotsSemaphore = new(1, 1); - [JsonIgnore] - [PublicAPI] - public ArchiHandler ArchiHandler { get; } + [JsonIgnore] + [PublicAPI] + public Actions Actions { get; } - [JsonIgnore] - [PublicAPI] - public ArchiLogger ArchiLogger { get; } + [JsonIgnore] + [PublicAPI] + public ArchiHandler ArchiHandler { get; } - [JsonIgnore] - [PublicAPI] - public ArchiWebHandler ArchiWebHandler { get; } + [JsonIgnore] + [PublicAPI] + public ArchiLogger ArchiLogger { get; } - [JsonProperty] - [PublicAPI] - public string BotName { get; } + [JsonIgnore] + [PublicAPI] + public ArchiWebHandler ArchiWebHandler { get; } - [JsonProperty] - [PublicAPI] - public CardsFarmer CardsFarmer { get; } + [JsonProperty] + [PublicAPI] + public string BotName { get; } - [JsonIgnore] - [PublicAPI] - public Commands Commands { get; } + [JsonProperty] + [PublicAPI] + public CardsFarmer CardsFarmer { get; } - [JsonProperty] - [PublicAPI] - public uint GamesToRedeemInBackgroundCount => BotDatabase.GamesToRedeemInBackgroundCount; + [JsonIgnore] + [PublicAPI] + public Commands Commands { get; } - [JsonProperty] - [PublicAPI] - public bool HasMobileAuthenticator => BotDatabase.MobileAuthenticator != null; + [JsonProperty] + [PublicAPI] + public uint GamesToRedeemInBackgroundCount => BotDatabase.GamesToRedeemInBackgroundCount; - [JsonProperty] - [PublicAPI] - public bool IsConnectedAndLoggedOn => SteamClient.SteamID != null; + [JsonProperty] + [PublicAPI] + public bool HasMobileAuthenticator => BotDatabase.MobileAuthenticator != null; - [JsonProperty] - [PublicAPI] - public bool IsPlayingPossible => !PlayingBlocked && !LibraryLocked; + [JsonProperty] + [PublicAPI] + public bool IsConnectedAndLoggedOn => SteamClient.SteamID != null; - [JsonIgnore] - [PublicAPI] - public SteamApps SteamApps { get; } + [JsonProperty] + [PublicAPI] + public bool IsPlayingPossible => !PlayingBlocked && !LibraryLocked; - [JsonIgnore] - [PublicAPI] - public SteamConfiguration SteamConfiguration { get; } + [JsonIgnore] + [PublicAPI] + public SteamApps SteamApps { get; } - [JsonIgnore] - [PublicAPI] - public SteamFriends SteamFriends { get; } + [JsonIgnore] + [PublicAPI] + public SteamConfiguration SteamConfiguration { get; } - internal readonly BotDatabase BotDatabase; + [JsonIgnore] + [PublicAPI] + public SteamFriends SteamFriends { get; } - internal bool CanReceiveSteamCards => !IsAccountLimited && !IsAccountLocked; - internal bool IsAccountLimited => AccountFlags.HasFlag(EAccountFlags.LimitedUser) || AccountFlags.HasFlag(EAccountFlags.LimitedUserForce); - internal bool IsAccountLocked => AccountFlags.HasFlag(EAccountFlags.Lockdown); + internal readonly BotDatabase BotDatabase; - private readonly CallbackManager CallbackManager; - private readonly SemaphoreSlim CallbackSemaphore = new(1, 1); - private readonly SemaphoreSlim GamesRedeemerInBackgroundSemaphore = new(1, 1); + internal bool CanReceiveSteamCards => !IsAccountLimited && !IsAccountLocked; + internal bool IsAccountLimited => AccountFlags.HasFlag(EAccountFlags.LimitedUser) || AccountFlags.HasFlag(EAccountFlags.LimitedUserForce); + internal bool IsAccountLocked => AccountFlags.HasFlag(EAccountFlags.Lockdown); + + private readonly CallbackManager CallbackManager; + private readonly SemaphoreSlim CallbackSemaphore = new(1, 1); + private readonly SemaphoreSlim GamesRedeemerInBackgroundSemaphore = new(1, 1); #pragma warning disable CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private readonly Timer HeartBeatTimer; + private readonly Timer HeartBeatTimer; #pragma warning restore CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private readonly SemaphoreSlim InitializationSemaphore = new(1, 1); - private readonly SemaphoreSlim MessagingSemaphore = new(1, 1); - private readonly ConcurrentDictionary PastNotifications = new(); - private readonly SemaphoreSlim SendCompleteTypesSemaphore = new(1, 1); - private readonly Statistics? Statistics; - private readonly SteamClient SteamClient; - private readonly ConcurrentHashSet SteamFamilySharingIDs = new(); - private readonly SteamUser SteamUser; - private readonly Trading Trading; + private readonly SemaphoreSlim InitializationSemaphore = new(1, 1); + private readonly SemaphoreSlim MessagingSemaphore = new(1, 1); + private readonly ConcurrentDictionary PastNotifications = new(); + private readonly SemaphoreSlim SendCompleteTypesSemaphore = new(1, 1); + private readonly Statistics? Statistics; + private readonly SteamClient SteamClient; + private readonly ConcurrentHashSet SteamFamilySharingIDs = new(); + private readonly SteamUser SteamUser; + private readonly Trading Trading; - private IEnumerable<(string FilePath, EFileType FileType)> RelatedFiles { - get { - foreach (EFileType fileType in Enum.GetValues(typeof(EFileType))) { - string filePath = GetFilePath(fileType); + private IEnumerable<(string FilePath, EFileType FileType)> RelatedFiles { + get { + foreach (EFileType fileType in Enum.GetValues(typeof(EFileType))) { + string filePath = GetFilePath(fileType); - if (string.IsNullOrEmpty(filePath)) { - ArchiLogger.LogNullError(nameof(filePath)); + if (string.IsNullOrEmpty(filePath)) { + ArchiLogger.LogNullError(nameof(filePath)); - yield break; - } - - yield return (filePath, fileType); + yield break; } + + yield return (filePath, fileType); } } + } - /// - /// Login keys are not guaranteed to be valid, we should use them only if we don't have full details available from the user - /// - private bool ShouldUseLoginKeys => BotConfig.UseLoginKeys && (!BotConfig.IsSteamPasswordSet || string.IsNullOrEmpty(BotConfig.DecryptedSteamPassword) || !HasMobileAuthenticator); + /// + /// Login keys are not guaranteed to be valid, we should use them only if we don't have full details available from the user + /// + private bool ShouldUseLoginKeys => BotConfig.UseLoginKeys && (!BotConfig.IsSteamPasswordSet || string.IsNullOrEmpty(BotConfig.DecryptedSteamPassword) || !HasMobileAuthenticator); - [JsonProperty(PropertyName = SharedInfo.UlongCompatibilityStringPrefix + nameof(SteamID))] + [JsonProperty(PropertyName = SharedInfo.UlongCompatibilityStringPrefix + nameof(SteamID))] - private string SSteamID => SteamID.ToString(CultureInfo.InvariantCulture); + private string SSteamID => SteamID.ToString(CultureInfo.InvariantCulture); - [JsonProperty] - [PublicAPI] - public EAccountFlags AccountFlags { get; private set; } + [JsonProperty] + [PublicAPI] + public EAccountFlags AccountFlags { get; private set; } - [JsonProperty] - [PublicAPI] - public BotConfig BotConfig { get; private set; } + [JsonProperty] + [PublicAPI] + public BotConfig BotConfig { get; private set; } - [JsonProperty] - [PublicAPI] - public bool KeepRunning { get; private set; } + [JsonProperty] + [PublicAPI] + public bool KeepRunning { get; private set; } - [JsonProperty] - [PublicAPI] - public string? Nickname { get; private set; } + [JsonProperty] + [PublicAPI] + public string? Nickname { get; private set; } - [JsonIgnore] - [PublicAPI] - public ImmutableDictionary OwnedPackageIDs { get; private set; } = ImmutableDictionary.Empty; + [JsonIgnore] + [PublicAPI] + public ImmutableDictionary OwnedPackageIDs { get; private set; } = ImmutableDictionary.Empty; - [JsonProperty] - [PublicAPI] - public ASF.EUserInputType RequiredInput { get; private set; } + [JsonProperty] + [PublicAPI] + public ASF.EUserInputType RequiredInput { get; private set; } - [JsonProperty] - [PublicAPI] - public ulong SteamID { get; private set; } + [JsonProperty] + [PublicAPI] + public ulong SteamID { get; private set; } - [JsonProperty] - [PublicAPI] - public long WalletBalance { get; private set; } + [JsonProperty] + [PublicAPI] + public long WalletBalance { get; private set; } - [JsonProperty] - [PublicAPI] - public ECurrencyCode WalletCurrency { get; private set; } + [JsonProperty] + [PublicAPI] + public ECurrencyCode WalletCurrency { get; private set; } - internal bool PlayingBlocked { get; private set; } - internal bool PlayingWasBlocked { get; private set; } + internal bool PlayingBlocked { get; private set; } + internal bool PlayingWasBlocked { get; private set; } - private string? AuthCode; + private string? AuthCode; - [JsonProperty] - private string? AvatarHash; + [JsonProperty] + private string? AvatarHash; #pragma warning disable CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private Timer? ConnectionFailureTimer; + private Timer? ConnectionFailureTimer; #pragma warning restore CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private bool FirstTradeSent; + private bool FirstTradeSent; #pragma warning disable CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private Timer? GamesRedeemerInBackgroundTimer; + private Timer? GamesRedeemerInBackgroundTimer; #pragma warning restore CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private byte HeartBeatFailures; - private byte InvalidPasswordFailures; - private EResult LastLogOnResult; - private DateTime LastLogonSessionReplaced; - private bool LibraryLocked; - private ulong MasterChatGroupID; + private byte HeartBeatFailures; + private byte InvalidPasswordFailures; + private EResult LastLogOnResult; + private DateTime LastLogonSessionReplaced; + private bool LibraryLocked; + private ulong MasterChatGroupID; #pragma warning disable CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private Timer? PlayingWasBlockedTimer; + private Timer? PlayingWasBlockedTimer; #pragma warning restore CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private bool ReconnectOnUserInitiated; - private bool SendCompleteTypesScheduled; + private bool ReconnectOnUserInitiated; + private bool SendCompleteTypesScheduled; #pragma warning disable CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private Timer? SendItemsTimer; + private Timer? SendItemsTimer; #pragma warning restore CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private bool SteamParentalActive = true; - private SteamSaleEvent? SteamSaleEvent; - private string? TwoFactorCode; - private byte TwoFactorCodeFailures; + private bool SteamParentalActive = true; + private SteamSaleEvent? SteamSaleEvent; + private string? TwoFactorCode; + private byte TwoFactorCodeFailures; - private Bot(string botName, BotConfig botConfig, BotDatabase botDatabase) { - BotName = !string.IsNullOrEmpty(botName) ? botName : throw new ArgumentNullException(nameof(botName)); - BotConfig = botConfig ?? throw new ArgumentNullException(nameof(botConfig)); - BotDatabase = botDatabase ?? throw new ArgumentNullException(nameof(botDatabase)); + private Bot(string botName, BotConfig botConfig, BotDatabase botDatabase) { + BotName = !string.IsNullOrEmpty(botName) ? botName : throw new ArgumentNullException(nameof(botName)); + BotConfig = botConfig ?? throw new ArgumentNullException(nameof(botConfig)); + BotDatabase = botDatabase ?? throw new ArgumentNullException(nameof(botDatabase)); - if (ASF.GlobalConfig == null) { - throw new InvalidOperationException(nameof(ASF.GlobalConfig)); - } - - if (ASF.GlobalDatabase == null) { - throw new InvalidOperationException(nameof(ASF.GlobalDatabase)); - } - - ArchiLogger = new ArchiLogger(botName); - - BotDatabase.MobileAuthenticator?.Init(this); - - ArchiWebHandler = new ArchiWebHandler(this); - - SteamConfiguration = SteamConfiguration.Create(builder => builder.WithProtocolTypes(ASF.GlobalConfig.SteamProtocols).WithCellID(ASF.GlobalDatabase.CellID).WithServerListProvider(ASF.GlobalDatabase.ServerListProvider).WithHttpClientFactory(ArchiWebHandler.GenerateDisposableHttpClient)); - - // Initialize - SteamClient = new SteamClient(SteamConfiguration, botName); - - if (Debugging.IsDebugConfigured && Directory.Exists(SharedInfo.DebugDirectory)) { - string debugListenerPath = Path.Combine(SharedInfo.DebugDirectory, botName); - - try { - Directory.CreateDirectory(debugListenerPath); - SteamClient.DebugNetworkListener = new NetHookNetworkListener(debugListenerPath, SteamClient); - } catch (Exception e) { - ArchiLogger.LogGenericException(e); - } - } - - SteamUnifiedMessages? steamUnifiedMessages = SteamClient.GetHandler(); - - ArchiHandler = new ArchiHandler(ArchiLogger, steamUnifiedMessages ?? throw new InvalidOperationException(nameof(steamUnifiedMessages))); - SteamClient.AddHandler(ArchiHandler); - - CallbackManager = new CallbackManager(SteamClient); - CallbackManager.Subscribe(OnConnected); - CallbackManager.Subscribe(OnDisconnected); - - SteamApps = SteamClient.GetHandler() ?? throw new InvalidOperationException(nameof(SteamApps)); - CallbackManager.Subscribe(OnGuestPassList); - CallbackManager.Subscribe(OnLicenseList); - - SteamFriends = SteamClient.GetHandler() ?? throw new InvalidOperationException(nameof(SteamFriends)); - CallbackManager.Subscribe(OnFriendsList); - CallbackManager.Subscribe(OnPersonaState); - - CallbackManager.Subscribe(OnServiceMethod); - - SteamUser = SteamClient.GetHandler() ?? throw new InvalidOperationException(nameof(SteamUser)); - CallbackManager.Subscribe(OnLoggedOff); - CallbackManager.Subscribe(OnLoggedOn); - CallbackManager.Subscribe(OnLoginKey); - CallbackManager.Subscribe(OnMachineAuth); - CallbackManager.Subscribe(OnWalletUpdate); - - CallbackManager.Subscribe(OnPlayingSessionState); - CallbackManager.Subscribe(OnSharedLibraryLockStatus); - CallbackManager.Subscribe(OnUserNotifications); - CallbackManager.Subscribe(OnVanityURLChangedCallback); - - Actions = new Actions(this); - CardsFarmer = new CardsFarmer(this); - Commands = new Commands(this); - Trading = new Trading(this); - - if (!Debugging.IsDebugBuild && ASF.GlobalConfig.Statistics) { - Statistics = new Statistics(this); - } - - HeartBeatTimer = new Timer( - HeartBeat, - null, - TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(ASF.LoadBalancingDelay * Bots?.Count ?? 0), // Delay - TimeSpan.FromMinutes(1) // Period - ); + if (ASF.GlobalConfig == null) { + throw new InvalidOperationException(nameof(ASF.GlobalConfig)); } - public async ValueTask DisposeAsync() { - // Those are objects that are always being created if constructor doesn't throw exception - ArchiWebHandler.Dispose(); - BotDatabase.Dispose(); - CallbackSemaphore.Dispose(); - GamesRedeemerInBackgroundSemaphore.Dispose(); - SendCompleteTypesSemaphore.Dispose(); - InitializationSemaphore.Dispose(); - MessagingSemaphore.Dispose(); - Trading.Dispose(); - - await Actions.DisposeAsync().ConfigureAwait(false); - await CardsFarmer.DisposeAsync().ConfigureAwait(false); - await HeartBeatTimer.DisposeAsync().ConfigureAwait(false); - - // Those are objects that might be null and the check should be in-place - if (ConnectionFailureTimer != null) { - await ConnectionFailureTimer.DisposeAsync().ConfigureAwait(false); - } - - if (GamesRedeemerInBackgroundTimer != null) { - await GamesRedeemerInBackgroundTimer.DisposeAsync().ConfigureAwait(false); - } - - if (PlayingWasBlockedTimer != null) { - await PlayingWasBlockedTimer.DisposeAsync().ConfigureAwait(false); - } - - if (SendItemsTimer != null) { - await SendItemsTimer.DisposeAsync().ConfigureAwait(false); - } - - if (Statistics != null) { - await Statistics.DisposeAsync().ConfigureAwait(false); - } - - if (SteamSaleEvent != null) { - await SteamSaleEvent.DisposeAsync().ConfigureAwait(false); - } + if (ASF.GlobalDatabase == null) { + throw new InvalidOperationException(nameof(ASF.GlobalDatabase)); } - [PublicAPI] - public async Task DeleteAllRelatedFiles() { - await BotDatabase.MakeReadOnly().ConfigureAwait(false); + ArchiLogger = new ArchiLogger(botName); - foreach (string filePath in RelatedFiles.Select(static file => file.FilePath).Where(File.Exists)) { - try { - File.Delete(filePath); - } catch (Exception e) { - ArchiLogger.LogGenericException(e); + BotDatabase.MobileAuthenticator?.Init(this); - return false; - } - } + ArchiWebHandler = new ArchiWebHandler(this); - return true; - } + SteamConfiguration = SteamConfiguration.Create(builder => builder.WithProtocolTypes(ASF.GlobalConfig.SteamProtocols).WithCellID(ASF.GlobalDatabase.CellID).WithServerListProvider(ASF.GlobalDatabase.ServerListProvider).WithHttpClientFactory(ArchiWebHandler.GenerateDisposableHttpClient)); - [PublicAPI] - public static Bot? GetBot(string botName) { - if (string.IsNullOrEmpty(botName)) { - throw new ArgumentNullException(nameof(botName)); - } + // Initialize + SteamClient = new SteamClient(SteamConfiguration, botName); - if (Bots == null) { - throw new InvalidOperationException(nameof(Bots)); - } - - if (Bots.TryGetValue(botName, out Bot? targetBot)) { - return targetBot; - } - - if (!ulong.TryParse(botName, out ulong steamID) || (steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - return null; - } - - return Bots.Values.FirstOrDefault(bot => bot.SteamID == steamID); - } - - [PublicAPI] - public static HashSet? GetBots(string args) { - if (string.IsNullOrEmpty(args)) { - throw new ArgumentNullException(nameof(args)); - } - - if (Bots == null) { - throw new InvalidOperationException(nameof(Bots)); - } - - string[] botNames = args.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - HashSet result = new(); - - foreach (string botName in botNames) { - if (botName.Equals(SharedInfo.ASF, StringComparison.OrdinalIgnoreCase)) { - IEnumerable allBots = Bots.OrderBy(static bot => bot.Key, BotsComparer).Select(static bot => bot.Value); - result.UnionWith(allBots); - - return result; - } - - if (botName.Contains("..", StringComparison.Ordinal)) { - string[] botRange = botName.Split(new[] { ".." }, StringSplitOptions.RemoveEmptyEntries); - - if (botRange.Length == 2) { - Bot? firstBot = GetBot(botRange[0]); - - if (firstBot != null) { - Bot? lastBot = GetBot(botRange[1]); - - if (lastBot != null) { - foreach (Bot bot in Bots.OrderBy(static bot => bot.Key, BotsComparer).Select(static bot => bot.Value).SkipWhile(bot => bot != firstBot)) { - result.Add(bot); - - if (bot == lastBot) { - break; - } - } - - continue; - } - } - } - } - - if (botName.StartsWith("r!", StringComparison.OrdinalIgnoreCase)) { - string botsPattern = botName[2..]; - - RegexOptions botsRegex = RegexOptions.None; - - if ((BotsComparer == StringComparer.InvariantCulture) || (BotsComparer == StringComparer.Ordinal)) { - botsRegex |= RegexOptions.CultureInvariant; - } else if ((BotsComparer == StringComparer.InvariantCultureIgnoreCase) || (BotsComparer == StringComparer.OrdinalIgnoreCase)) { - botsRegex |= RegexOptions.CultureInvariant | RegexOptions.IgnoreCase; - } - - Regex regex; - - try { -#pragma warning disable CA3012 - regex = new Regex(botsPattern, botsRegex); -#pragma warning restore CA3012 - } catch (ArgumentException e) { - ASF.ArchiLogger.LogGenericWarningException(e); - - return null; - } - - IEnumerable regexMatches = Bots.Where(kvp => regex.IsMatch(kvp.Key)).Select(static kvp => kvp.Value); - result.UnionWith(regexMatches); - - continue; - } - - Bot? singleBot = GetBot(botName); - - if (singleBot == null) { - continue; - } - - result.Add(singleBot); - } - - return result; - } - - [PublicAPI] - public static string GetFilePath(string botName, EFileType fileType) { - if (string.IsNullOrEmpty(botName)) { - throw new ArgumentNullException(nameof(botName)); - } - - if (!Enum.IsDefined(typeof(EFileType), fileType)) { - throw new InvalidEnumArgumentException(nameof(fileType), (int) fileType, typeof(EFileType)); - } - - string botPath = Path.Combine(SharedInfo.ConfigDirectory, botName); - - return fileType switch { - EFileType.Config => botPath + SharedInfo.JsonConfigExtension, - EFileType.Database => botPath + SharedInfo.DatabaseExtension, - EFileType.KeysToRedeem => botPath + SharedInfo.KeysExtension, - EFileType.KeysToRedeemUnused => botPath + SharedInfo.KeysExtension + SharedInfo.KeysUnusedExtension, - EFileType.KeysToRedeemUsed => botPath + SharedInfo.KeysExtension + SharedInfo.KeysUsedExtension, - EFileType.MobileAuthenticator => botPath + SharedInfo.MobileAuthenticatorExtension, - EFileType.SentryFile => botPath + SharedInfo.SentryHashExtension, - _ => throw new ArgumentOutOfRangeException(nameof(fileType)) - }; - } - - [PublicAPI] - public string GetFilePath(EFileType fileType) { - if (!Enum.IsDefined(typeof(EFileType), fileType)) { - throw new InvalidEnumArgumentException(nameof(fileType), (int) fileType, typeof(EFileType)); - } - - return GetFilePath(BotName, fileType); - } - - [PublicAPI] - public static HashSet GetItemsForFullSets(IReadOnlyCollection inventory, IReadOnlyDictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), (uint SetsToExtract, byte ItemsPerSet)> amountsToExtract, ushort maxItems = Trading.MaxItemsPerTrade) { - if ((inventory == null) || (inventory.Count == 0)) { - throw new ArgumentNullException(nameof(inventory)); - } - - if ((amountsToExtract == null) || (amountsToExtract.Count == 0)) { - throw new ArgumentNullException(nameof(amountsToExtract)); - } - - if (maxItems < MinCardsPerBadge) { - throw new ArgumentOutOfRangeException(nameof(maxItems)); - } - - HashSet result = new(); - Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary>> itemsPerClassIDPerSet = inventory.GroupBy(static item => (item.RealAppID, item.Type, item.Rarity)).ToDictionary(static grouping => grouping.Key, static grouping => grouping.GroupBy(static item => item.ClassID).ToDictionary(static group => group.Key, static group => group.ToHashSet())); - - foreach (((uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) set, (uint setsToExtract, byte itemsPerSet)) in amountsToExtract.OrderBy(static kv => kv.Value.ItemsPerSet)) { - if (!itemsPerClassIDPerSet.TryGetValue(set, out Dictionary>? itemsPerClassID)) { - continue; - } - - if (itemsPerSet < itemsPerClassID.Count) { - throw new InvalidOperationException($"{nameof(inventory)} && {nameof(amountsToExtract)}"); - } - - if (itemsPerSet > itemsPerClassID.Count) { - continue; - } - - ushort maxSetsAllowed = (ushort) (maxItems - result.Count); - maxSetsAllowed -= (ushort) (maxSetsAllowed % itemsPerSet); - maxSetsAllowed /= itemsPerSet; - ushort realSetsToExtract = (ushort) Math.Min(setsToExtract, maxSetsAllowed); - - if (realSetsToExtract == 0) { - break; - } - - foreach (HashSet itemsOfClass in itemsPerClassID.Values) { - ushort classRemaining = realSetsToExtract; - - foreach (Asset item in itemsOfClass.TakeWhile(_ => classRemaining > 0)) { - if (item.Amount > classRemaining) { - Asset itemToSend = item.CreateShallowCopy(); - itemToSend.Amount = classRemaining; - result.Add(itemToSend); - - classRemaining = 0; - } else { - result.Add(item); - - classRemaining -= (ushort) item.Amount; - } - } - } - } - - return result; - } - - [PublicAPI] - public async Task?> GetPossiblyCompletedBadgeAppIDs() { - using IDocument? badgePage = await ArchiWebHandler.GetBadgePage(1).ConfigureAwait(false); - - if (badgePage == null) { - ArchiLogger.LogGenericWarning(Strings.WarningCouldNotCheckBadges); - - return null; - } - - byte maxPages = 1; - IElement? htmlNode = badgePage.SelectSingleNode("(//a[@class='pagelink'])[last()]"); - - if (htmlNode != null) { - string lastPage = htmlNode.TextContent; - - if (string.IsNullOrEmpty(lastPage)) { - ArchiLogger.LogNullError(nameof(lastPage)); - - return null; - } - - if (!byte.TryParse(lastPage, out maxPages) || (maxPages == 0)) { - ArchiLogger.LogNullError(nameof(maxPages)); - - return null; - } - } - - HashSet? firstPageResult = GetPossiblyCompletedBadgeAppIDs(badgePage); - - if (firstPageResult == null) { - return null; - } - - if (maxPages == 1) { - return firstPageResult; - } - - switch (ASF.GlobalConfig?.OptimizationMode) { - case GlobalConfig.EOptimizationMode.MinMemoryUsage: - for (byte page = 2; page <= maxPages; page++) { - HashSet? pageIDs = await GetPossiblyCompletedBadgeAppIDs(page).ConfigureAwait(false); - - if (pageIDs == null) { - return null; - } - - firstPageResult.UnionWith(pageIDs); - } - - return firstPageResult; - default: - HashSet?>> tasks = new(maxPages - 1); - - for (byte page = 2; page <= maxPages; page++) { - // ReSharper disable once InlineTemporaryVariable - we need a copy of variable being passed when in for loops, as loop will proceed before our task is launched - byte currentPage = page; - tasks.Add(GetPossiblyCompletedBadgeAppIDs(currentPage)); - } - - IList?> results = await Utilities.InParallel(tasks).ConfigureAwait(false); - - foreach (HashSet? result in results) { - if (result == null) { - return null; - } - - firstPageResult.UnionWith(result); - } - - return firstPageResult; - } - } - - [PublicAPI] - public async Task GetTradeHoldDuration(ulong steamID, ulong tradeID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (tradeID == 0) { - throw new ArgumentOutOfRangeException(nameof(tradeID)); - } - - if (Bots == null) { - throw new InvalidOperationException(nameof(Bots)); - } - - if (SteamFriends.GetFriendRelationship(steamID) == EFriendRelationship.Friend) { - byte? tradeHoldDurationForUser = await ArchiWebHandler.GetTradeHoldDurationForUser(steamID).ConfigureAwait(false); - - if (tradeHoldDurationForUser.HasValue) { - return tradeHoldDurationForUser; - } - } - - Bot? targetBot = Bots.Values.FirstOrDefault(bot => bot.SteamID == steamID); - - if (targetBot?.IsConnectedAndLoggedOn == true) { - string? targetTradeToken = await targetBot.ArchiHandler.GetTradeToken().ConfigureAwait(false); - - if (!string.IsNullOrEmpty(targetTradeToken)) { - byte? tradeHoldDurationForUser = await ArchiWebHandler.GetTradeHoldDurationForUser(steamID, targetTradeToken).ConfigureAwait(false); - - if (tradeHoldDurationForUser.HasValue) { - return tradeHoldDurationForUser; - } - } - } - - return await ArchiWebHandler.GetTradeHoldDurationForTrade(tradeID).ConfigureAwait(false); - } - - [PublicAPI] - public bool HasAccess(ulong steamID, BotConfig.EAccess access) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentNullException(nameof(steamID)); - } - - if ((access == BotConfig.EAccess.None) || !Enum.IsDefined(typeof(BotConfig.EAccess), access)) { - throw new InvalidEnumArgumentException(nameof(access), (int) access, typeof(BotConfig.EAccess)); - } - - if (ASF.IsOwner(steamID)) { - return true; - } - - return access switch { - BotConfig.EAccess.FamilySharing when SteamFamilySharingIDs.Contains(steamID) => true, - _ => BotConfig.SteamUserPermissions.TryGetValue(steamID, out BotConfig.EAccess realPermission) && (realPermission >= access) - }; - } - - [PublicAPI] - public async Task?> LoadCardsPerSet(IReadOnlyCollection appIDs) { - if ((appIDs == null) || (appIDs.Count == 0)) { - throw new ArgumentNullException(nameof(appIDs)); - } - -#pragma warning disable CA1508 // False positive, not every IReadOnlyCollection is ISet, and this is public API - ISet uniqueAppIDs = appIDs as ISet ?? appIDs.ToHashSet(); -#pragma warning restore CA1508 // False positive, not every IReadOnlyCollection is ISet, and this is public API - - switch (ASF.GlobalConfig?.OptimizationMode) { - case GlobalConfig.EOptimizationMode.MinMemoryUsage: - Dictionary result = new(uniqueAppIDs.Count); - - foreach (uint appID in uniqueAppIDs) { - byte cardCount = await ArchiWebHandler.GetCardCountForGame(appID).ConfigureAwait(false); - - if (cardCount == 0) { - return null; - } - - result.Add(appID, cardCount); - } - - return result; - default: - IEnumerable> tasks = uniqueAppIDs.Select(async appID => (AppID: appID, Cards: await ArchiWebHandler.GetCardCountForGame(appID).ConfigureAwait(false))); - IList<(uint AppID, byte Cards)> results = await Utilities.InParallel(tasks).ConfigureAwait(false); - - return results.All(static tuple => tuple.Cards > 0) ? results.ToDictionary(static res => res.AppID, static res => res.Cards) : null; - } - } - - [PublicAPI] - public async Task SendMessage(ulong steamID, string message) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } - - if (!IsConnectedAndLoggedOn) { - return false; - } - - ArchiLogger.LogChatMessage(true, message, steamID: steamID); - - string? steamMessagePrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.SteamMessagePrefix : GlobalConfig.DefaultSteamMessagePrefix; - - await foreach (string messagePart in SteamChatMessage.GetMessageParts(message, steamMessagePrefix, IsAccountLimited).ConfigureAwait(false)) { - if (!await SendMessagePart(steamID, messagePart).ConfigureAwait(false)) { - ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return false; - } - } - - return true; - } - - [PublicAPI] - public async Task SendMessage(ulong chatGroupID, ulong chatID, string message) { - if (chatGroupID == 0) { - throw new ArgumentOutOfRangeException(nameof(chatGroupID)); - } - - if (chatID == 0) { - throw new ArgumentOutOfRangeException(nameof(chatID)); - } - - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } - - if (!IsConnectedAndLoggedOn) { - return false; - } - - ArchiLogger.LogChatMessage(true, message, chatGroupID, chatID); - - string? steamMessagePrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.SteamMessagePrefix : GlobalConfig.DefaultSteamMessagePrefix; - - await foreach (string messagePart in SteamChatMessage.GetMessageParts(message, steamMessagePrefix, IsAccountLimited).ConfigureAwait(false)) { - if (!await SendMessagePart(chatID, messagePart, chatGroupID).ConfigureAwait(false)) { - ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return false; - } - } - - return true; - } - - [PublicAPI] - public bool SetUserInput(ASF.EUserInputType inputType, string inputValue) { - if ((inputType == ASF.EUserInputType.None) || !Enum.IsDefined(typeof(ASF.EUserInputType), inputType)) { - throw new InvalidEnumArgumentException(nameof(inputType), (int) inputType, typeof(ASF.EUserInputType)); - } - - if (string.IsNullOrEmpty(inputValue)) { - throw new ArgumentNullException(nameof(inputValue)); - } - - // This switch should cover ONLY bot properties - switch (inputType) { - case ASF.EUserInputType.Login: - BotConfig.SteamLogin = inputValue; - BotConfig.IsSteamLoginSet = false; - - break; - case ASF.EUserInputType.Password: - BotConfig.DecryptedSteamPassword = inputValue; - BotConfig.IsSteamPasswordSet = false; - - break; - case ASF.EUserInputType.SteamGuard: - if (inputValue.Length != 5) { - return false; - } - - AuthCode = inputValue; - - break; - case ASF.EUserInputType.SteamParentalCode: - if (inputValue.Length != BotConfig.SteamParentalCodeLength) { - return false; - } - - BotConfig.SteamParentalCode = inputValue; - BotConfig.IsSteamParentalCodeSet = false; - - break; - case ASF.EUserInputType.TwoFactorAuthentication: - switch (inputValue.Length) { - case MobileAuthenticator.BackupCodeDigits: - case MobileAuthenticator.CodeDigits: - break; - default: - return false; - } - - inputValue = inputValue.ToUpperInvariant(); - - if (inputValue.Any(static character => !MobileAuthenticator.CodeCharacters.Contains(character))) { - return false; - } - - TwoFactorCode = inputValue; - - break; - default: - throw new ArgumentOutOfRangeException(nameof(inputType)); - } - - if (RequiredInput == inputType) { - RequiredInput = ASF.EUserInputType.None; - } - - return true; - } - - internal void AddGamesToRedeemInBackground(IOrderedDictionary gamesToRedeemInBackground) { - if ((gamesToRedeemInBackground == null) || (gamesToRedeemInBackground.Count == 0)) { - throw new ArgumentNullException(nameof(gamesToRedeemInBackground)); - } - - BotDatabase.AddGamesToRedeemInBackground(gamesToRedeemInBackground); - - if ((GamesRedeemerInBackgroundTimer == null) && BotDatabase.HasGamesToRedeemInBackground && IsConnectedAndLoggedOn) { - Utilities.InBackground(() => RedeemGamesInBackground()); - } - } - - internal async Task CheckOccupationStatus() { - StopPlayingWasBlockedTimer(); - - if (!IsPlayingPossible) { - PlayingWasBlocked = true; - ArchiLogger.LogGenericInfo(Strings.BotAccountOccupied); - - return; - } - - if (PlayingWasBlocked && (PlayingWasBlockedTimer == null)) { - InitPlayingWasBlockedTimer(); - } - - ArchiLogger.LogGenericInfo(Strings.BotAccountFree); - - if (!await CardsFarmer.Resume(false).ConfigureAwait(false)) { - await ResetGamesPlayed().ConfigureAwait(false); - } - } - - internal bool DeleteRedeemedKeysFiles() { - string unusedKeysFilePath = GetFilePath(EFileType.KeysToRedeemUnused); - - if (string.IsNullOrEmpty(unusedKeysFilePath)) { - ASF.ArchiLogger.LogNullError(nameof(unusedKeysFilePath)); - - return false; - } - - if (File.Exists(unusedKeysFilePath)) { - try { - File.Delete(unusedKeysFilePath); - } catch (Exception e) { - ArchiLogger.LogGenericException(e); - - return false; - } - } - - string usedKeysFilePath = GetFilePath(EFileType.KeysToRedeemUsed); - - if (string.IsNullOrEmpty(usedKeysFilePath)) { - ASF.ArchiLogger.LogNullError(nameof(usedKeysFilePath)); - - return false; - } - - if (File.Exists(usedKeysFilePath)) { - try { - File.Delete(usedKeysFilePath); - } catch (Exception e) { - ArchiLogger.LogGenericException(e); - - return false; - } - } - - return true; - } - - internal static string FormatBotResponse(string response, string botName) { - if (string.IsNullOrEmpty(response)) { - throw new ArgumentNullException(nameof(response)); - } - - if (string.IsNullOrEmpty(botName)) { - throw new ArgumentNullException(nameof(botName)); - } - - return $"{Environment.NewLine}<{botName}> {response}"; - } - - internal async Task<(uint PlayableAppID, DateTime IgnoredUntil, bool IgnoredGlobally)> GetAppDataForIdling(uint appID, float hoursPlayed, bool allowRecursiveDiscovery = true, bool optimisticDiscovery = true) { - if (appID == 0) { - throw new ArgumentOutOfRangeException(nameof(appID)); - } - - if (hoursPlayed < 0) { - throw new ArgumentOutOfRangeException(nameof(hoursPlayed)); - } - - HashSet? packageIDs = ASF.GlobalDatabase?.GetPackageIDs(appID, OwnedPackageIDs.Keys); - - if ((packageIDs == null) || (packageIDs.Count == 0)) { - return (0, DateTime.MaxValue, true); - } - - if ((hoursPlayed < CardsFarmer.HoursForRefund) && BotConfig.SkipRefundableGames) { - DateTime mostRecent = DateTime.MinValue; - - foreach (uint packageID in packageIDs) { - if (!OwnedPackageIDs.TryGetValue(packageID, out (EPaymentMethod PaymentMethod, DateTime TimeCreated) packageData)) { - continue; - } - - if ((packageData.PaymentMethod > EPaymentMethod.None) && IsRefundable(packageData.PaymentMethod) && (packageData.TimeCreated > mostRecent)) { - mostRecent = packageData.TimeCreated; - } - } - - if (mostRecent > DateTime.MinValue) { - DateTime playableIn = mostRecent.AddDays(CardsFarmer.DaysForRefund); - - if (playableIn > DateTime.UtcNow) { - return (0, playableIn, false); - } - } - } - - SteamApps.PICSTokensCallback? tokenCallback = null; - - for (byte i = 0; (i < WebBrowser.MaxTries) && (tokenCallback == null) && IsConnectedAndLoggedOn; i++) { - try { - tokenCallback = await SteamApps.PICSGetAccessTokens(appID, null).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - } - } - - if (tokenCallback == null) { - return (optimisticDiscovery ? appID : 0, DateTime.MinValue, true); - } - - SteamApps.PICSRequest request = new(appID, tokenCallback.AppTokens.TryGetValue(appID, out ulong accessToken) ? accessToken : 0); - - AsyncJobMultiple.ResultSet? productInfoResultSet = null; - - for (byte i = 0; (i < WebBrowser.MaxTries) && (productInfoResultSet == null) && IsConnectedAndLoggedOn; i++) { - try { - productInfoResultSet = await SteamApps.PICSGetProductInfo(request.ToEnumerable(), Enumerable.Empty()).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - } - } - - if (productInfoResultSet?.Results == null) { - return (optimisticDiscovery ? appID : 0, DateTime.MinValue, true); - } - - foreach (Dictionary productInfoApps in productInfoResultSet.Results.Select(static result => result.Apps)) { - if (!productInfoApps.TryGetValue(appID, out SteamApps.PICSProductInfoCallback.PICSProductInfo? productInfoApp)) { - continue; - } - - KeyValue productInfo = productInfoApp.KeyValues; - - if (productInfo == KeyValue.Invalid) { - ArchiLogger.LogNullError(nameof(productInfo)); - - break; - } - - KeyValue commonProductInfo = productInfo["common"]; - - if (commonProductInfo == KeyValue.Invalid) { - continue; - } - - string? releaseState = commonProductInfo["ReleaseState"].AsString(); - - if (!string.IsNullOrEmpty(releaseState)) { - // We must convert this to uppercase, since Valve doesn't stick to any convention and we can have a case mismatch - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - switch (releaseState!.ToUpperInvariant()) { - case "RELEASED": - break; - case "PRELOADONLY": - case "PRERELEASE": - return (0, DateTime.MaxValue, true); - default: - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(releaseState), releaseState)); - - break; - } - } - - string? type = commonProductInfo["type"].AsString(); - - if (string.IsNullOrEmpty(type)) { - return (appID, DateTime.MinValue, true); - } - - // We must convert this to uppercase, since Valve doesn't stick to any convention and we can have a case mismatch - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - switch (type!.ToUpperInvariant()) { - case "APPLICATION": - case "EPISODE": - case "GAME": - case "MOD": - case "MOVIE": - case "SERIES": - case "TOOL": - case "VIDEO": - // Types that can be idled - return (appID, DateTime.MinValue, true); - case "ADVERTISING": - case "DEMO": - case "DLC": - case "GUIDE": - case "HARDWARE": - case "MUSIC": - // Types that can't be idled - break; - default: - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(type), type)); - - break; - } - - if (!allowRecursiveDiscovery) { - return (0, DateTime.MinValue, true); - } - - string? listOfDlc = productInfo["extended"]["listofdlc"].AsString(); - - if (string.IsNullOrEmpty(listOfDlc)) { - return (appID, DateTime.MinValue, true); - } - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - string[] dlcAppIDsTexts = listOfDlc!.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - foreach (string dlcAppIDsText in dlcAppIDsTexts) { - if (!uint.TryParse(dlcAppIDsText, out uint dlcAppID) || (dlcAppID == 0)) { - ArchiLogger.LogNullError(nameof(dlcAppID)); - - break; - } - - (uint playableAppID, _, _) = await GetAppDataForIdling(dlcAppID, hoursPlayed, false, false).ConfigureAwait(false); - - if (playableAppID != 0) { - return (playableAppID, DateTime.MinValue, true); - } - } - - return (appID, DateTime.MinValue, true); - } - - return ((productInfoResultSet.Complete && !productInfoResultSet.Failed) || optimisticDiscovery ? appID : 0, DateTime.MinValue, true); - } - - internal Task?> GetMarketableAppIDs() => ArchiWebHandler.GetAppList(); - - internal async Task? AppIDs)>?> GetPackagesData(IReadOnlyCollection packageIDs) { - if ((packageIDs == null) || (packageIDs.Count == 0)) { - throw new ArgumentNullException(nameof(packageIDs)); - } - - if (ASF.GlobalDatabase == null) { - throw new InvalidOperationException(nameof(ASF.GlobalDatabase)); - } - - HashSet packageRequests = new(); - - foreach (uint packageID in packageIDs) { - if (!ASF.GlobalDatabase.PackageAccessTokensReadOnly.TryGetValue(packageID, out ulong packageAccessToken)) { - continue; - } - - packageRequests.Add(new SteamApps.PICSRequest(packageID, packageAccessToken)); - } - - if (packageRequests.Count == 0) { - return new Dictionary? AppIDs)>(0); - } - - AsyncJobMultiple.ResultSet? productInfoResultSet = null; - - for (byte i = 0; (i < WebBrowser.MaxTries) && (productInfoResultSet == null) && IsConnectedAndLoggedOn; i++) { - try { - productInfoResultSet = await SteamApps.PICSGetProductInfo(Enumerable.Empty(), packageRequests).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - } - } - - if (productInfoResultSet?.Results == null) { - return null; - } - - Dictionary? AppIDs)> result = new(); - - foreach (SteamApps.PICSProductInfoCallback.PICSProductInfo productInfo in productInfoResultSet.Results.SelectMany(static productInfoResult => productInfoResult.Packages).Where(static productInfoPackages => productInfoPackages.Key != 0).Select(static productInfoPackages => productInfoPackages.Value)) { - if (productInfo.KeyValues == KeyValue.Invalid) { - ArchiLogger.LogNullError(nameof(productInfo)); - - return null; - } - - uint changeNumber = productInfo.ChangeNumber; - HashSet? appIDs = null; - - try { - KeyValue appIDsKv = productInfo.KeyValues["appids"]; - - if (appIDsKv == KeyValue.Invalid) { - continue; - } - - appIDs = new HashSet(appIDsKv.Children.Count); - - foreach (string? appIDText in appIDsKv.Children.Select(static app => app.Value)) { - if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { - ArchiLogger.LogNullError(nameof(appID)); - - return null; - } - - appIDs.Add(appID); - } - } finally { - result[productInfo.ID] = (changeNumber, appIDs?.ToImmutableHashSet()); - } - } - - return result; - } - - internal async Task<(Dictionary? UnusedKeys, Dictionary? UsedKeys)> GetUsedAndUnusedKeys() { - string[] files = { GetFilePath(EFileType.KeysToRedeemUnused), GetFilePath(EFileType.KeysToRedeemUsed) }; - - IList?> results = await Utilities.InParallel(files.Select(GetKeysFromFile)).ConfigureAwait(false); - - return (results[0], results[1]); - } - - internal async Task HasPublicInventory() { - if (!IsConnectedAndLoggedOn) { - return null; - } - - CPrivacySettings? privacySettings = await ArchiHandler.GetPrivacySettings().ConfigureAwait(false); - - if (privacySettings == null) { - ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return null; - } - - return ((ArchiHandler.EPrivacySetting) privacySettings.privacy_state == ArchiHandler.EPrivacySetting.Public) && ((ArchiHandler.EPrivacySetting) privacySettings.privacy_state_inventory == ArchiHandler.EPrivacySetting.Public); - } - - internal async Task IdleGame(Game game) { - if (game == null) { - throw new ArgumentNullException(nameof(game)); - } - - string? gameName = null; - - if (!string.IsNullOrEmpty(BotConfig.CustomGamePlayedWhileFarming)) { - gameName = string.Format(CultureInfo.CurrentCulture, BotConfig.CustomGamePlayedWhileFarming!, game.AppID, game.GameName); - } - - await ArchiHandler.PlayGames(new HashSet(1) { game.PlayableAppID }, gameName).ConfigureAwait(false); - } - - internal async Task IdleGames(IReadOnlyCollection games) { - if ((games == null) || (games.Count == 0)) { - throw new ArgumentNullException(nameof(games)); - } - - string? gameName = null; - - if (!string.IsNullOrEmpty(BotConfig.CustomGamePlayedWhileFarming)) { - gameName = string.Format(CultureInfo.CurrentCulture, BotConfig.CustomGamePlayedWhileFarming!, string.Join(", ", games.Select(static game => game.AppID)), string.Join(", ", games.Select(static game => game.GameName))); - } - - await ArchiHandler.PlayGames(games.Select(static game => game.PlayableAppID).ToHashSet(), gameName).ConfigureAwait(false); - } - - internal async Task ImportKeysToRedeem(string filePath) { - if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) { - throw new ArgumentNullException(nameof(filePath)); - } + if (Debugging.IsDebugConfigured && Directory.Exists(SharedInfo.DebugDirectory)) { + string debugListenerPath = Path.Combine(SharedInfo.DebugDirectory, botName); try { - OrderedDictionary gamesToRedeemInBackground = new(); - - using (StreamReader reader = new(filePath)) { - string? line; - - while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) { - if (line.Length == 0) { - continue; - } - - // Valid formats: - // Key (name will be the same as key and replaced from redemption result, if possible) - // Name + Key (user provides both, if name is equal to key, above logic is used, otherwise name is kept) - // Name + + Key (BGR output format, we include extra properties in the middle, those are ignored during import) - string[] parsedArgs = line.Split(DefaultBackgroundKeysRedeemerSeparator, StringSplitOptions.RemoveEmptyEntries); - - if (parsedArgs.Length < 1) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, line)); - - continue; - } - - string name = parsedArgs[0]; - string key = parsedArgs[^1]; - - gamesToRedeemInBackground[key] = name; - } - } - - if (gamesToRedeemInBackground.Count > 0) { - IOrderedDictionary validGamesToRedeemInBackground = ValidateGamesToRedeemInBackground(gamesToRedeemInBackground); - - if (validGamesToRedeemInBackground.Count > 0) { - AddGamesToRedeemInBackground(validGamesToRedeemInBackground); - } - } - - File.Delete(filePath); + Directory.CreateDirectory(debugListenerPath); + SteamClient.DebugNetworkListener = new NetHookNetworkListener(debugListenerPath, SteamClient); } catch (Exception e) { ArchiLogger.LogGenericException(e); } } - internal static void Init(StringComparer botsComparer) { - if (Bots != null) { - throw new InvalidOperationException(nameof(Bots)); - } + SteamUnifiedMessages? steamUnifiedMessages = SteamClient.GetHandler(); - BotsComparer = botsComparer ?? throw new ArgumentNullException(nameof(botsComparer)); - Bots = new ConcurrentDictionary(botsComparer); + ArchiHandler = new ArchiHandler(ArchiLogger, steamUnifiedMessages ?? throw new InvalidOperationException(nameof(steamUnifiedMessages))); + SteamClient.AddHandler(ArchiHandler); + + CallbackManager = new CallbackManager(SteamClient); + CallbackManager.Subscribe(OnConnected); + CallbackManager.Subscribe(OnDisconnected); + + SteamApps = SteamClient.GetHandler() ?? throw new InvalidOperationException(nameof(SteamApps)); + CallbackManager.Subscribe(OnGuestPassList); + CallbackManager.Subscribe(OnLicenseList); + + SteamFriends = SteamClient.GetHandler() ?? throw new InvalidOperationException(nameof(SteamFriends)); + CallbackManager.Subscribe(OnFriendsList); + CallbackManager.Subscribe(OnPersonaState); + + CallbackManager.Subscribe(OnServiceMethod); + + SteamUser = SteamClient.GetHandler() ?? throw new InvalidOperationException(nameof(SteamUser)); + CallbackManager.Subscribe(OnLoggedOff); + CallbackManager.Subscribe(OnLoggedOn); + CallbackManager.Subscribe(OnLoginKey); + CallbackManager.Subscribe(OnMachineAuth); + CallbackManager.Subscribe(OnWalletUpdate); + + CallbackManager.Subscribe(OnPlayingSessionState); + CallbackManager.Subscribe(OnSharedLibraryLockStatus); + CallbackManager.Subscribe(OnUserNotifications); + CallbackManager.Subscribe(OnVanityURLChangedCallback); + + Actions = new Actions(this); + CardsFarmer = new CardsFarmer(this); + Commands = new Commands(this); + Trading = new Trading(this); + + if (!Debugging.IsDebugBuild && ASF.GlobalConfig.Statistics) { + Statistics = new Statistics(this); } - internal bool IsBlacklistedFromIdling(uint appID) { - if (appID == 0) { - throw new ArgumentOutOfRangeException(nameof(appID)); - } + HeartBeatTimer = new Timer( + HeartBeat, + null, + TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(ASF.LoadBalancingDelay * Bots?.Count ?? 0), // Delay + TimeSpan.FromMinutes(1) // Period + ); + } - return BotDatabase.IdlingBlacklistedAppIDs.Contains(appID); + public async ValueTask DisposeAsync() { + // Those are objects that are always being created if constructor doesn't throw exception + ArchiWebHandler.Dispose(); + BotDatabase.Dispose(); + CallbackSemaphore.Dispose(); + GamesRedeemerInBackgroundSemaphore.Dispose(); + SendCompleteTypesSemaphore.Dispose(); + InitializationSemaphore.Dispose(); + MessagingSemaphore.Dispose(); + Trading.Dispose(); + + await Actions.DisposeAsync().ConfigureAwait(false); + await CardsFarmer.DisposeAsync().ConfigureAwait(false); + await HeartBeatTimer.DisposeAsync().ConfigureAwait(false); + + // Those are objects that might be null and the check should be in-place + if (ConnectionFailureTimer != null) { + await ConnectionFailureTimer.DisposeAsync().ConfigureAwait(false); } - internal bool IsBlacklistedFromTrades(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - return BotDatabase.BlacklistedFromTradesSteamIDs.Contains(steamID); + if (GamesRedeemerInBackgroundTimer != null) { + await GamesRedeemerInBackgroundTimer.DisposeAsync().ConfigureAwait(false); } - internal bool IsPriorityIdling(uint appID) { - if (appID == 0) { - throw new ArgumentOutOfRangeException(nameof(appID)); - } - - return BotDatabase.IdlingPriorityAppIDs.Contains(appID); + if (PlayingWasBlockedTimer != null) { + await PlayingWasBlockedTimer.DisposeAsync().ConfigureAwait(false); } - internal async Task OnConfigChanged(bool deleted) { - if (deleted) { - await Destroy().ConfigureAwait(false); + if (SendItemsTimer != null) { + await SendItemsTimer.DisposeAsync().ConfigureAwait(false); + } - return; - } + if (Statistics != null) { + await Statistics.DisposeAsync().ConfigureAwait(false); + } - string configFile = GetFilePath(EFileType.Config); + if (SteamSaleEvent != null) { + await SteamSaleEvent.DisposeAsync().ConfigureAwait(false); + } + } - if (string.IsNullOrEmpty(configFile)) { - ArchiLogger.LogNullError(nameof(configFile)); - - return; - } - - (BotConfig? botConfig, _) = await BotConfig.Load(configFile).ConfigureAwait(false); - - if (botConfig == null) { - await Destroy().ConfigureAwait(false); - - return; - } - - if (botConfig == BotConfig) { - return; - } - - await InitializationSemaphore.WaitAsync().ConfigureAwait(false); + [PublicAPI] + public async Task DeleteAllRelatedFiles() { + await BotDatabase.MakeReadOnly().ConfigureAwait(false); + foreach (string filePath in RelatedFiles.Select(static file => file.FilePath).Where(File.Exists)) { try { - if (botConfig == BotConfig) { - return; + File.Delete(filePath); + } catch (Exception e) { + ArchiLogger.LogGenericException(e); + + return false; + } + } + + return true; + } + + [PublicAPI] + public static Bot? GetBot(string botName) { + if (string.IsNullOrEmpty(botName)) { + throw new ArgumentNullException(nameof(botName)); + } + + if (Bots == null) { + throw new InvalidOperationException(nameof(Bots)); + } + + if (Bots.TryGetValue(botName, out Bot? targetBot)) { + return targetBot; + } + + if (!ulong.TryParse(botName, out ulong steamID) || (steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + return null; + } + + return Bots.Values.FirstOrDefault(bot => bot.SteamID == steamID); + } + + [PublicAPI] + public static HashSet? GetBots(string args) { + if (string.IsNullOrEmpty(args)) { + throw new ArgumentNullException(nameof(args)); + } + + if (Bots == null) { + throw new InvalidOperationException(nameof(Bots)); + } + + string[] botNames = args.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + HashSet result = new(); + + foreach (string botName in botNames) { + if (botName.Equals(SharedInfo.ASF, StringComparison.OrdinalIgnoreCase)) { + IEnumerable allBots = Bots.OrderBy(static bot => bot.Key, BotsComparer).Select(static bot => bot.Value); + result.UnionWith(allBots); + + return result; + } + + if (botName.Contains("..", StringComparison.Ordinal)) { + string[] botRange = botName.Split(new[] { ".." }, StringSplitOptions.RemoveEmptyEntries); + + if (botRange.Length == 2) { + Bot? firstBot = GetBot(botRange[0]); + + if (firstBot != null) { + Bot? lastBot = GetBot(botRange[1]); + + if (lastBot != null) { + foreach (Bot bot in Bots.OrderBy(static bot => bot.Key, BotsComparer).Select(static bot => bot.Value).SkipWhile(bot => bot != firstBot)) { + result.Add(bot); + + if (bot == lastBot) { + break; + } + } + + continue; + } + } + } + } + + if (botName.StartsWith("r!", StringComparison.OrdinalIgnoreCase)) { + string botsPattern = botName[2..]; + + RegexOptions botsRegex = RegexOptions.None; + + if ((BotsComparer == StringComparer.InvariantCulture) || (BotsComparer == StringComparer.Ordinal)) { + botsRegex |= RegexOptions.CultureInvariant; + } else if ((BotsComparer == StringComparer.InvariantCultureIgnoreCase) || (BotsComparer == StringComparer.OrdinalIgnoreCase)) { + botsRegex |= RegexOptions.CultureInvariant | RegexOptions.IgnoreCase; } - Stop(botConfig.Enabled); - BotConfig = botConfig; + Regex regex; - await InitModules().ConfigureAwait(false); - InitStart(); - } finally { - InitializationSemaphore.Release(); + try { +#pragma warning disable CA3012 + regex = new Regex(botsPattern, botsRegex); +#pragma warning restore CA3012 + } catch (ArgumentException e) { + ASF.ArchiLogger.LogGenericWarningException(e); + + return null; + } + + IEnumerable regexMatches = Bots.Where(kvp => regex.IsMatch(kvp.Key)).Select(static kvp => kvp.Value); + result.UnionWith(regexMatches); + + continue; + } + + Bot? singleBot = GetBot(botName); + + if (singleBot == null) { + continue; + } + + result.Add(singleBot); + } + + return result; + } + + [PublicAPI] + public static string GetFilePath(string botName, EFileType fileType) { + if (string.IsNullOrEmpty(botName)) { + throw new ArgumentNullException(nameof(botName)); + } + + if (!Enum.IsDefined(typeof(EFileType), fileType)) { + throw new InvalidEnumArgumentException(nameof(fileType), (int) fileType, typeof(EFileType)); + } + + string botPath = Path.Combine(SharedInfo.ConfigDirectory, botName); + + return fileType switch { + EFileType.Config => botPath + SharedInfo.JsonConfigExtension, + EFileType.Database => botPath + SharedInfo.DatabaseExtension, + EFileType.KeysToRedeem => botPath + SharedInfo.KeysExtension, + EFileType.KeysToRedeemUnused => botPath + SharedInfo.KeysExtension + SharedInfo.KeysUnusedExtension, + EFileType.KeysToRedeemUsed => botPath + SharedInfo.KeysExtension + SharedInfo.KeysUsedExtension, + EFileType.MobileAuthenticator => botPath + SharedInfo.MobileAuthenticatorExtension, + EFileType.SentryFile => botPath + SharedInfo.SentryHashExtension, + _ => throw new ArgumentOutOfRangeException(nameof(fileType)) + }; + } + + [PublicAPI] + public string GetFilePath(EFileType fileType) { + if (!Enum.IsDefined(typeof(EFileType), fileType)) { + throw new InvalidEnumArgumentException(nameof(fileType), (int) fileType, typeof(EFileType)); + } + + return GetFilePath(BotName, fileType); + } + + [PublicAPI] + public static HashSet GetItemsForFullSets(IReadOnlyCollection inventory, IReadOnlyDictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), (uint SetsToExtract, byte ItemsPerSet)> amountsToExtract, ushort maxItems = Trading.MaxItemsPerTrade) { + if ((inventory == null) || (inventory.Count == 0)) { + throw new ArgumentNullException(nameof(inventory)); + } + + if ((amountsToExtract == null) || (amountsToExtract.Count == 0)) { + throw new ArgumentNullException(nameof(amountsToExtract)); + } + + if (maxItems < MinCardsPerBadge) { + throw new ArgumentOutOfRangeException(nameof(maxItems)); + } + + HashSet result = new(); + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary>> itemsPerClassIDPerSet = inventory.GroupBy(static item => (item.RealAppID, item.Type, item.Rarity)).ToDictionary(static grouping => grouping.Key, static grouping => grouping.GroupBy(static item => item.ClassID).ToDictionary(static group => group.Key, static group => group.ToHashSet())); + + foreach (((uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) set, (uint setsToExtract, byte itemsPerSet)) in amountsToExtract.OrderBy(static kv => kv.Value.ItemsPerSet)) { + if (!itemsPerClassIDPerSet.TryGetValue(set, out Dictionary>? itemsPerClassID)) { + continue; + } + + if (itemsPerSet < itemsPerClassID.Count) { + throw new InvalidOperationException($"{nameof(inventory)} && {nameof(amountsToExtract)}"); + } + + if (itemsPerSet > itemsPerClassID.Count) { + continue; + } + + ushort maxSetsAllowed = (ushort) (maxItems - result.Count); + maxSetsAllowed -= (ushort) (maxSetsAllowed % itemsPerSet); + maxSetsAllowed /= itemsPerSet; + ushort realSetsToExtract = (ushort) Math.Min(setsToExtract, maxSetsAllowed); + + if (realSetsToExtract == 0) { + break; + } + + foreach (HashSet itemsOfClass in itemsPerClassID.Values) { + ushort classRemaining = realSetsToExtract; + + foreach (Asset item in itemsOfClass.TakeWhile(_ => classRemaining > 0)) { + if (item.Amount > classRemaining) { + Asset itemToSend = item.CreateShallowCopy(); + itemToSend.Amount = classRemaining; + result.Add(itemToSend); + + classRemaining = 0; + } else { + result.Add(item); + + classRemaining -= (ushort) item.Amount; + } + } } } - internal async Task OnFarmingFinished(bool farmedSomething) { - await OnFarmingStopped().ConfigureAwait(false); + return result; + } - if (BotConfig.SendOnFarmingFinished && (BotConfig.LootableTypes.Count > 0) && (farmedSomething || !FirstTradeSent)) { - FirstTradeSent = true; + [PublicAPI] + public async Task?> GetPossiblyCompletedBadgeAppIDs() { + using IDocument? badgePage = await ArchiWebHandler.GetBadgePage(1).ConfigureAwait(false); - await Actions.SendInventory(filterFunction: item => BotConfig.LootableTypes.Contains(item.Type)).ConfigureAwait(false); - } + if (badgePage == null) { + ArchiLogger.LogGenericWarning(Strings.WarningCouldNotCheckBadges); - if (BotConfig.ShutdownOnFarmingFinished) { - Stop(); - } - - await PluginsCore.OnBotFarmingFinished(this, farmedSomething).ConfigureAwait(false); + return null; } - internal async Task OnFarmingStopped() { + byte maxPages = 1; + IElement? htmlNode = badgePage.SelectSingleNode("(//a[@class='pagelink'])[last()]"); + + if (htmlNode != null) { + string lastPage = htmlNode.TextContent; + + if (string.IsNullOrEmpty(lastPage)) { + ArchiLogger.LogNullError(nameof(lastPage)); + + return null; + } + + if (!byte.TryParse(lastPage, out maxPages) || (maxPages == 0)) { + ArchiLogger.LogNullError(nameof(maxPages)); + + return null; + } + } + + HashSet? firstPageResult = GetPossiblyCompletedBadgeAppIDs(badgePage); + + if (firstPageResult == null) { + return null; + } + + if (maxPages == 1) { + return firstPageResult; + } + + switch (ASF.GlobalConfig?.OptimizationMode) { + case GlobalConfig.EOptimizationMode.MinMemoryUsage: + for (byte page = 2; page <= maxPages; page++) { + HashSet? pageIDs = await GetPossiblyCompletedBadgeAppIDs(page).ConfigureAwait(false); + + if (pageIDs == null) { + return null; + } + + firstPageResult.UnionWith(pageIDs); + } + + return firstPageResult; + default: + HashSet?>> tasks = new(maxPages - 1); + + for (byte page = 2; page <= maxPages; page++) { + // ReSharper disable once InlineTemporaryVariable - we need a copy of variable being passed when in for loops, as loop will proceed before our task is launched + byte currentPage = page; + tasks.Add(GetPossiblyCompletedBadgeAppIDs(currentPage)); + } + + IList?> results = await Utilities.InParallel(tasks).ConfigureAwait(false); + + foreach (HashSet? result in results) { + if (result == null) { + return null; + } + + firstPageResult.UnionWith(result); + } + + return firstPageResult; + } + } + + [PublicAPI] + public async Task GetTradeHoldDuration(ulong steamID, ulong tradeID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (tradeID == 0) { + throw new ArgumentOutOfRangeException(nameof(tradeID)); + } + + if (Bots == null) { + throw new InvalidOperationException(nameof(Bots)); + } + + if (SteamFriends.GetFriendRelationship(steamID) == EFriendRelationship.Friend) { + byte? tradeHoldDurationForUser = await ArchiWebHandler.GetTradeHoldDurationForUser(steamID).ConfigureAwait(false); + + if (tradeHoldDurationForUser.HasValue) { + return tradeHoldDurationForUser; + } + } + + Bot? targetBot = Bots.Values.FirstOrDefault(bot => bot.SteamID == steamID); + + if (targetBot?.IsConnectedAndLoggedOn == true) { + string? targetTradeToken = await targetBot.ArchiHandler.GetTradeToken().ConfigureAwait(false); + + if (!string.IsNullOrEmpty(targetTradeToken)) { + byte? tradeHoldDurationForUser = await ArchiWebHandler.GetTradeHoldDurationForUser(steamID, targetTradeToken).ConfigureAwait(false); + + if (tradeHoldDurationForUser.HasValue) { + return tradeHoldDurationForUser; + } + } + } + + return await ArchiWebHandler.GetTradeHoldDurationForTrade(tradeID).ConfigureAwait(false); + } + + [PublicAPI] + public bool HasAccess(ulong steamID, BotConfig.EAccess access) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentNullException(nameof(steamID)); + } + + if ((access == BotConfig.EAccess.None) || !Enum.IsDefined(typeof(BotConfig.EAccess), access)) { + throw new InvalidEnumArgumentException(nameof(access), (int) access, typeof(BotConfig.EAccess)); + } + + if (ASF.IsOwner(steamID)) { + return true; + } + + return access switch { + BotConfig.EAccess.FamilySharing when SteamFamilySharingIDs.Contains(steamID) => true, + _ => BotConfig.SteamUserPermissions.TryGetValue(steamID, out BotConfig.EAccess realPermission) && (realPermission >= access) + }; + } + + [PublicAPI] + public async Task?> LoadCardsPerSet(IReadOnlyCollection appIDs) { + if ((appIDs == null) || (appIDs.Count == 0)) { + throw new ArgumentNullException(nameof(appIDs)); + } + +#pragma warning disable CA1508 // False positive, not every IReadOnlyCollection is ISet, and this is public API + ISet uniqueAppIDs = appIDs as ISet ?? appIDs.ToHashSet(); +#pragma warning restore CA1508 // False positive, not every IReadOnlyCollection is ISet, and this is public API + + switch (ASF.GlobalConfig?.OptimizationMode) { + case GlobalConfig.EOptimizationMode.MinMemoryUsage: + Dictionary result = new(uniqueAppIDs.Count); + + foreach (uint appID in uniqueAppIDs) { + byte cardCount = await ArchiWebHandler.GetCardCountForGame(appID).ConfigureAwait(false); + + if (cardCount == 0) { + return null; + } + + result.Add(appID, cardCount); + } + + return result; + default: + IEnumerable> tasks = uniqueAppIDs.Select(async appID => (AppID: appID, Cards: await ArchiWebHandler.GetCardCountForGame(appID).ConfigureAwait(false))); + IList<(uint AppID, byte Cards)> results = await Utilities.InParallel(tasks).ConfigureAwait(false); + + return results.All(static tuple => tuple.Cards > 0) ? results.ToDictionary(static res => res.AppID, static res => res.Cards) : null; + } + } + + [PublicAPI] + public async Task SendMessage(ulong steamID, string message) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + if (!IsConnectedAndLoggedOn) { + return false; + } + + ArchiLogger.LogChatMessage(true, message, steamID: steamID); + + string? steamMessagePrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.SteamMessagePrefix : GlobalConfig.DefaultSteamMessagePrefix; + + await foreach (string messagePart in SteamChatMessage.GetMessageParts(message, steamMessagePrefix, IsAccountLimited).ConfigureAwait(false)) { + if (!await SendMessagePart(steamID, messagePart).ConfigureAwait(false)) { + ArchiLogger.LogGenericWarning(Strings.WarningFailed); + + return false; + } + } + + return true; + } + + [PublicAPI] + public async Task SendMessage(ulong chatGroupID, ulong chatID, string message) { + if (chatGroupID == 0) { + throw new ArgumentOutOfRangeException(nameof(chatGroupID)); + } + + if (chatID == 0) { + throw new ArgumentOutOfRangeException(nameof(chatID)); + } + + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + if (!IsConnectedAndLoggedOn) { + return false; + } + + ArchiLogger.LogChatMessage(true, message, chatGroupID, chatID); + + string? steamMessagePrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.SteamMessagePrefix : GlobalConfig.DefaultSteamMessagePrefix; + + await foreach (string messagePart in SteamChatMessage.GetMessageParts(message, steamMessagePrefix, IsAccountLimited).ConfigureAwait(false)) { + if (!await SendMessagePart(chatID, messagePart, chatGroupID).ConfigureAwait(false)) { + ArchiLogger.LogGenericWarning(Strings.WarningFailed); + + return false; + } + } + + return true; + } + + [PublicAPI] + public bool SetUserInput(ASF.EUserInputType inputType, string inputValue) { + if ((inputType == ASF.EUserInputType.None) || !Enum.IsDefined(typeof(ASF.EUserInputType), inputType)) { + throw new InvalidEnumArgumentException(nameof(inputType), (int) inputType, typeof(ASF.EUserInputType)); + } + + if (string.IsNullOrEmpty(inputValue)) { + throw new ArgumentNullException(nameof(inputValue)); + } + + // This switch should cover ONLY bot properties + switch (inputType) { + case ASF.EUserInputType.Login: + BotConfig.SteamLogin = inputValue; + BotConfig.IsSteamLoginSet = false; + + break; + case ASF.EUserInputType.Password: + BotConfig.DecryptedSteamPassword = inputValue; + BotConfig.IsSteamPasswordSet = false; + + break; + case ASF.EUserInputType.SteamGuard: + if (inputValue.Length != 5) { + return false; + } + + AuthCode = inputValue; + + break; + case ASF.EUserInputType.SteamParentalCode: + if (inputValue.Length != BotConfig.SteamParentalCodeLength) { + return false; + } + + BotConfig.SteamParentalCode = inputValue; + BotConfig.IsSteamParentalCodeSet = false; + + break; + case ASF.EUserInputType.TwoFactorAuthentication: + switch (inputValue.Length) { + case MobileAuthenticator.BackupCodeDigits: + case MobileAuthenticator.CodeDigits: + break; + default: + return false; + } + + inputValue = inputValue.ToUpperInvariant(); + + if (inputValue.Any(static character => !MobileAuthenticator.CodeCharacters.Contains(character))) { + return false; + } + + TwoFactorCode = inputValue; + + break; + default: + throw new ArgumentOutOfRangeException(nameof(inputType)); + } + + if (RequiredInput == inputType) { + RequiredInput = ASF.EUserInputType.None; + } + + return true; + } + + internal void AddGamesToRedeemInBackground(IOrderedDictionary gamesToRedeemInBackground) { + if ((gamesToRedeemInBackground == null) || (gamesToRedeemInBackground.Count == 0)) { + throw new ArgumentNullException(nameof(gamesToRedeemInBackground)); + } + + BotDatabase.AddGamesToRedeemInBackground(gamesToRedeemInBackground); + + if ((GamesRedeemerInBackgroundTimer == null) && BotDatabase.HasGamesToRedeemInBackground && IsConnectedAndLoggedOn) { + Utilities.InBackground(() => RedeemGamesInBackground()); + } + } + + internal async Task CheckOccupationStatus() { + StopPlayingWasBlockedTimer(); + + if (!IsPlayingPossible) { + PlayingWasBlocked = true; + ArchiLogger.LogGenericInfo(Strings.BotAccountOccupied); + + return; + } + + if (PlayingWasBlocked && (PlayingWasBlockedTimer == null)) { + InitPlayingWasBlockedTimer(); + } + + ArchiLogger.LogGenericInfo(Strings.BotAccountFree); + + if (!await CardsFarmer.Resume(false).ConfigureAwait(false)) { await ResetGamesPlayed().ConfigureAwait(false); - await PluginsCore.OnBotFarmingStopped(this).ConfigureAwait(false); } + } - internal async Task RefreshSession() { - if (!IsConnectedAndLoggedOn) { - return false; - } + internal bool DeleteRedeemedKeysFiles() { + string unusedKeysFilePath = GetFilePath(EFileType.KeysToRedeemUnused); - SteamUser.WebAPIUserNonceCallback callback; - - try { - callback = await SteamUser.RequestWebAPIUserNonce().ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - await Connect(true).ConfigureAwait(false); - - return false; - } - - if (string.IsNullOrEmpty(callback.Nonce)) { - await Connect(true).ConfigureAwait(false); - - return false; - } - - if (await ArchiWebHandler.Init(SteamID, SteamClient.Universe, callback.Nonce, SteamParentalActive ? BotConfig.SteamParentalCode : null).ConfigureAwait(false)) { - return true; - } - - await Connect(true).ConfigureAwait(false); + if (string.IsNullOrEmpty(unusedKeysFilePath)) { + ASF.ArchiLogger.LogNullError(nameof(unusedKeysFilePath)); return false; } - internal static async Task RegisterBot(string botName) { - if (string.IsNullOrEmpty(botName)) { - throw new ArgumentNullException(nameof(botName)); - } - - if (Bots == null) { - throw new InvalidOperationException(nameof(Bots)); - } - - if (Bots.ContainsKey(botName)) { - return; - } - - string configFilePath = GetFilePath(botName, EFileType.Config); - - if (string.IsNullOrEmpty(configFilePath)) { - ASF.ArchiLogger.LogNullError(nameof(configFilePath)); - - return; - } - - (BotConfig? botConfig, string? latestJson) = await BotConfig.Load(configFilePath).ConfigureAwait(false); - - if (botConfig == null) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorBotConfigInvalid, configFilePath)); - - return; - } - - if (Debugging.IsDebugConfigured) { - ASF.ArchiLogger.LogGenericDebug($"{configFilePath}: {JsonConvert.SerializeObject(botConfig, Formatting.Indented)}"); - } - - if (!string.IsNullOrEmpty(latestJson)) { - ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.AutomaticFileMigration, configFilePath)); - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - await SerializableFile.Write(configFilePath, latestJson!).ConfigureAwait(false); - - ASF.ArchiLogger.LogGenericInfo(Strings.Done); - } - - string databaseFilePath = GetFilePath(botName, EFileType.Database); - - if (string.IsNullOrEmpty(databaseFilePath)) { - ASF.ArchiLogger.LogNullError(nameof(databaseFilePath)); - - return; - } - - BotDatabase? botDatabase = await BotDatabase.CreateOrLoad(databaseFilePath).ConfigureAwait(false); - - if (botDatabase == null) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorDatabaseInvalid, databaseFilePath)); - - return; - } - - if (Debugging.IsDebugConfigured) { - ASF.ArchiLogger.LogGenericDebug($"{databaseFilePath}: {JsonConvert.SerializeObject(botDatabase, Formatting.Indented)}"); - } - - Bot bot; - - await BotsSemaphore.WaitAsync().ConfigureAwait(false); - + if (File.Exists(unusedKeysFilePath)) { try { - if (Bots.ContainsKey(botName)) { - return; + File.Delete(unusedKeysFilePath); + } catch (Exception e) { + ArchiLogger.LogGenericException(e); + + return false; + } + } + + string usedKeysFilePath = GetFilePath(EFileType.KeysToRedeemUsed); + + if (string.IsNullOrEmpty(usedKeysFilePath)) { + ASF.ArchiLogger.LogNullError(nameof(usedKeysFilePath)); + + return false; + } + + if (File.Exists(usedKeysFilePath)) { + try { + File.Delete(usedKeysFilePath); + } catch (Exception e) { + ArchiLogger.LogGenericException(e); + + return false; + } + } + + return true; + } + + internal static string FormatBotResponse(string response, string botName) { + if (string.IsNullOrEmpty(response)) { + throw new ArgumentNullException(nameof(response)); + } + + if (string.IsNullOrEmpty(botName)) { + throw new ArgumentNullException(nameof(botName)); + } + + return $"{Environment.NewLine}<{botName}> {response}"; + } + + internal async Task<(uint PlayableAppID, DateTime IgnoredUntil, bool IgnoredGlobally)> GetAppDataForIdling(uint appID, float hoursPlayed, bool allowRecursiveDiscovery = true, bool optimisticDiscovery = true) { + if (appID == 0) { + throw new ArgumentOutOfRangeException(nameof(appID)); + } + + if (hoursPlayed < 0) { + throw new ArgumentOutOfRangeException(nameof(hoursPlayed)); + } + + HashSet? packageIDs = ASF.GlobalDatabase?.GetPackageIDs(appID, OwnedPackageIDs.Keys); + + if ((packageIDs == null) || (packageIDs.Count == 0)) { + return (0, DateTime.MaxValue, true); + } + + if ((hoursPlayed < CardsFarmer.HoursForRefund) && BotConfig.SkipRefundableGames) { + DateTime mostRecent = DateTime.MinValue; + + foreach (uint packageID in packageIDs) { + if (!OwnedPackageIDs.TryGetValue(packageID, out (EPaymentMethod PaymentMethod, DateTime TimeCreated) packageData)) { + continue; } - bot = new Bot(botName, botConfig, botDatabase); + if ((packageData.PaymentMethod > EPaymentMethod.None) && IsRefundable(packageData.PaymentMethod) && (packageData.TimeCreated > mostRecent)) { + mostRecent = packageData.TimeCreated; + } + } - if (!Bots.TryAdd(botName, bot)) { - ASF.ArchiLogger.LogNullError(nameof(bot)); + if (mostRecent > DateTime.MinValue) { + DateTime playableIn = mostRecent.AddDays(CardsFarmer.DaysForRefund); - await bot.DisposeAsync().ConfigureAwait(false); + if (playableIn > DateTime.UtcNow) { + return (0, playableIn, false); + } + } + } - return; + SteamApps.PICSTokensCallback? tokenCallback = null; + + for (byte i = 0; (i < WebBrowser.MaxTries) && (tokenCallback == null) && IsConnectedAndLoggedOn; i++) { + try { + tokenCallback = await SteamApps.PICSGetAccessTokens(appID, null).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + } + } + + if (tokenCallback == null) { + return (optimisticDiscovery ? appID : 0, DateTime.MinValue, true); + } + + SteamApps.PICSRequest request = new(appID, tokenCallback.AppTokens.TryGetValue(appID, out ulong accessToken) ? accessToken : 0); + + AsyncJobMultiple.ResultSet? productInfoResultSet = null; + + for (byte i = 0; (i < WebBrowser.MaxTries) && (productInfoResultSet == null) && IsConnectedAndLoggedOn; i++) { + try { + productInfoResultSet = await SteamApps.PICSGetProductInfo(request.ToEnumerable(), Enumerable.Empty()).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + } + } + + if (productInfoResultSet?.Results == null) { + return (optimisticDiscovery ? appID : 0, DateTime.MinValue, true); + } + + foreach (Dictionary productInfoApps in productInfoResultSet.Results.Select(static result => result.Apps)) { + if (!productInfoApps.TryGetValue(appID, out SteamApps.PICSProductInfoCallback.PICSProductInfo? productInfoApp)) { + continue; + } + + KeyValue productInfo = productInfoApp.KeyValues; + + if (productInfo == KeyValue.Invalid) { + ArchiLogger.LogNullError(nameof(productInfo)); + + break; + } + + KeyValue commonProductInfo = productInfo["common"]; + + if (commonProductInfo == KeyValue.Invalid) { + continue; + } + + string? releaseState = commonProductInfo["ReleaseState"].AsString(); + + if (!string.IsNullOrEmpty(releaseState)) { + // We must convert this to uppercase, since Valve doesn't stick to any convention and we can have a case mismatch + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + switch (releaseState!.ToUpperInvariant()) { + case "RELEASED": + break; + case "PRELOADONLY": + case "PRERELEASE": + return (0, DateTime.MaxValue, true); + default: + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(releaseState), releaseState)); + + break; + } + } + + string? type = commonProductInfo["type"].AsString(); + + if (string.IsNullOrEmpty(type)) { + return (appID, DateTime.MinValue, true); + } + + // We must convert this to uppercase, since Valve doesn't stick to any convention and we can have a case mismatch + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + switch (type!.ToUpperInvariant()) { + case "APPLICATION": + case "EPISODE": + case "GAME": + case "MOD": + case "MOVIE": + case "SERIES": + case "TOOL": + case "VIDEO": + // Types that can be idled + return (appID, DateTime.MinValue, true); + case "ADVERTISING": + case "DEMO": + case "DLC": + case "GUIDE": + case "HARDWARE": + case "MUSIC": + // Types that can't be idled + break; + default: + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(type), type)); + + break; + } + + if (!allowRecursiveDiscovery) { + return (0, DateTime.MinValue, true); + } + + string? listOfDlc = productInfo["extended"]["listofdlc"].AsString(); + + if (string.IsNullOrEmpty(listOfDlc)) { + return (appID, DateTime.MinValue, true); + } + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + string[] dlcAppIDsTexts = listOfDlc!.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (string dlcAppIDsText in dlcAppIDsTexts) { + if (!uint.TryParse(dlcAppIDsText, out uint dlcAppID) || (dlcAppID == 0)) { + ArchiLogger.LogNullError(nameof(dlcAppID)); + + break; + } + + (uint playableAppID, _, _) = await GetAppDataForIdling(dlcAppID, hoursPlayed, false, false).ConfigureAwait(false); + + if (playableAppID != 0) { + return (playableAppID, DateTime.MinValue, true); + } + } + + return (appID, DateTime.MinValue, true); + } + + return ((productInfoResultSet.Complete && !productInfoResultSet.Failed) || optimisticDiscovery ? appID : 0, DateTime.MinValue, true); + } + + internal Task?> GetMarketableAppIDs() => ArchiWebHandler.GetAppList(); + + internal async Task? AppIDs)>?> GetPackagesData(IReadOnlyCollection packageIDs) { + if ((packageIDs == null) || (packageIDs.Count == 0)) { + throw new ArgumentNullException(nameof(packageIDs)); + } + + if (ASF.GlobalDatabase == null) { + throw new InvalidOperationException(nameof(ASF.GlobalDatabase)); + } + + HashSet packageRequests = new(); + + foreach (uint packageID in packageIDs) { + if (!ASF.GlobalDatabase.PackageAccessTokensReadOnly.TryGetValue(packageID, out ulong packageAccessToken)) { + continue; + } + + packageRequests.Add(new SteamApps.PICSRequest(packageID, packageAccessToken)); + } + + if (packageRequests.Count == 0) { + return new Dictionary? AppIDs)>(0); + } + + AsyncJobMultiple.ResultSet? productInfoResultSet = null; + + for (byte i = 0; (i < WebBrowser.MaxTries) && (productInfoResultSet == null) && IsConnectedAndLoggedOn; i++) { + try { + productInfoResultSet = await SteamApps.PICSGetProductInfo(Enumerable.Empty(), packageRequests).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + } + } + + if (productInfoResultSet?.Results == null) { + return null; + } + + Dictionary? AppIDs)> result = new(); + + foreach (SteamApps.PICSProductInfoCallback.PICSProductInfo productInfo in productInfoResultSet.Results.SelectMany(static productInfoResult => productInfoResult.Packages).Where(static productInfoPackages => productInfoPackages.Key != 0).Select(static productInfoPackages => productInfoPackages.Value)) { + if (productInfo.KeyValues == KeyValue.Invalid) { + ArchiLogger.LogNullError(nameof(productInfo)); + + return null; + } + + uint changeNumber = productInfo.ChangeNumber; + HashSet? appIDs = null; + + try { + KeyValue appIDsKv = productInfo.KeyValues["appids"]; + + if (appIDsKv == KeyValue.Invalid) { + continue; + } + + appIDs = new HashSet(appIDsKv.Children.Count); + + foreach (string? appIDText in appIDsKv.Children.Select(static app => app.Value)) { + if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { + ArchiLogger.LogNullError(nameof(appID)); + + return null; + } + + appIDs.Add(appID); } } finally { - BotsSemaphore.Release(); - } - - await PluginsCore.OnBotInit(bot).ConfigureAwait(false); - - HashSet? customHandlers = await PluginsCore.OnBotSteamHandlersInit(bot).ConfigureAwait(false); - - if (customHandlers?.Count > 0) { - foreach (ClientMsgHandler customHandler in customHandlers) { - bot.SteamClient.AddHandler(customHandler); - } - } - - await PluginsCore.OnBotSteamCallbacksInit(bot, bot.CallbackManager).ConfigureAwait(false); - - await bot.InitModules().ConfigureAwait(false); - - bot.InitStart(); - } - - internal (bool Success, string? Message) RemoveAuthenticator() { - MobileAuthenticator? authenticator = BotDatabase.MobileAuthenticator; - - if (authenticator == null) { - return (false, Strings.BotNoASFAuthenticator); - } - - BotDatabase.MobileAuthenticator = null; - authenticator.Dispose(); - - return (true, null); - } - - internal async Task Rename(string newBotName) { - if (string.IsNullOrEmpty(newBotName)) { - throw new ArgumentNullException(nameof(newBotName)); - } - - if (Bots == null) { - throw new InvalidOperationException(nameof(Bots)); - } - - if (!ASF.IsValidBotName(newBotName) || Bots.ContainsKey(newBotName)) { - return false; - } - - if (KeepRunning) { - Stop(true); - } - - await BotDatabase.MakeReadOnly().ConfigureAwait(false); - - // We handle the config file last as it'll trigger new bot creation - foreach ((string filePath, EFileType fileType) in RelatedFiles.Where(static file => File.Exists(file.FilePath)).OrderByDescending(static file => file.FileType != EFileType.Config)) { - string newFilePath = GetFilePath(newBotName, fileType); - - if (string.IsNullOrEmpty(newFilePath)) { - ArchiLogger.LogNullError(nameof(newFilePath)); - - return false; - } - - try { - File.Move(filePath, newFilePath); - } catch (Exception e) { - ArchiLogger.LogGenericException(e); - - return false; - } - } - - return true; - } - - internal void RequestPersonaStateUpdate() { - if (!IsConnectedAndLoggedOn) { - return; - } - - SteamFriends.RequestFriendInfo(SteamID, EClientPersonaStateFlag.PlayerName | EClientPersonaStateFlag.Presence); - } - - internal async Task SendTypingMessage(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!IsConnectedAndLoggedOn) { - return false; - } - - return await ArchiHandler.SendTypingStatus(steamID).ConfigureAwait(false) == EResult.OK; - } - - internal async Task Start() { - if (KeepRunning) { - return; - } - - KeepRunning = true; - Utilities.InBackground(HandleCallbacks, true); - ArchiLogger.LogGenericInfo(Strings.Starting); - - // Support and convert 2FA files - if (!HasMobileAuthenticator) { - string mobileAuthenticatorFilePath = GetFilePath(EFileType.MobileAuthenticator); - - if (string.IsNullOrEmpty(mobileAuthenticatorFilePath)) { - ArchiLogger.LogNullError(nameof(mobileAuthenticatorFilePath)); - - return; - } - - if (File.Exists(mobileAuthenticatorFilePath)) { - await ImportAuthenticatorFromFile(mobileAuthenticatorFilePath).ConfigureAwait(false); - } - } - - string keysToRedeemFilePath = GetFilePath(EFileType.KeysToRedeem); - - if (string.IsNullOrEmpty(keysToRedeemFilePath)) { - ArchiLogger.LogNullError(nameof(keysToRedeemFilePath)); - - return; - } - - if (File.Exists(keysToRedeemFilePath)) { - await ImportKeysToRedeem(keysToRedeemFilePath).ConfigureAwait(false); - } - - await Connect().ConfigureAwait(false); - } - - internal void Stop(bool skipShutdownEvent = false) { - if (!KeepRunning) { - return; - } - - KeepRunning = false; - ArchiLogger.LogGenericInfo(Strings.BotStopping); - - if (SteamClient.IsConnected) { - Disconnect(); - } - - if (!skipShutdownEvent) { - Utilities.InBackground(Events.OnBotShutdown); + result[productInfo.ID] = (changeNumber, appIDs?.ToImmutableHashSet()); } } - internal bool TryImportAuthenticator(MobileAuthenticator authenticator) { - if (authenticator == null) { - throw new ArgumentNullException(nameof(authenticator)); - } + return result; + } - if (HasMobileAuthenticator) { - return false; - } + internal async Task<(Dictionary? UnusedKeys, Dictionary? UsedKeys)> GetUsedAndUnusedKeys() { + string[] files = { GetFilePath(EFileType.KeysToRedeemUnused), GetFilePath(EFileType.KeysToRedeemUsed) }; - authenticator.Init(this); - BotDatabase.MobileAuthenticator = authenticator; + IList?> results = await Utilities.InParallel(files.Select(GetKeysFromFile)).ConfigureAwait(false); - ArchiLogger.LogGenericInfo(Strings.BotAuthenticatorImportFinished); + return (results[0], results[1]); + } - return true; + internal async Task HasPublicInventory() { + if (!IsConnectedAndLoggedOn) { + return null; } - internal static IOrderedDictionary ValidateGamesToRedeemInBackground(IOrderedDictionary gamesToRedeemInBackground) { - if ((gamesToRedeemInBackground == null) || (gamesToRedeemInBackground.Count == 0)) { - throw new ArgumentNullException(nameof(gamesToRedeemInBackground)); - } + CPrivacySettings? privacySettings = await ArchiHandler.GetPrivacySettings().ConfigureAwait(false); - HashSet invalidKeys = new(); + if (privacySettings == null) { + ArchiLogger.LogGenericWarning(Strings.WarningFailed); - foreach (DictionaryEntry game in gamesToRedeemInBackground) { - bool invalid = false; - - string? key = game.Key as string; - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (string.IsNullOrEmpty(key)) { - invalid = true; - ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(key))); - } else if (!Utilities.IsValidCdKey(key!)) { - invalid = true; - ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, key)); - } - - string? name = game.Value as string; - - if (string.IsNullOrEmpty(name)) { - invalid = true; - ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(name))); - } - - if (invalid && (key != null)) { - invalidKeys.Add(key); - } - } - - if (invalidKeys.Count > 0) { - foreach (string invalidKey in invalidKeys) { - gamesToRedeemInBackground.Remove(invalidKey); - } - } - - return gamesToRedeemInBackground; + return null; } - private async Task Connect(bool force = false) { - if (!force && (!KeepRunning || SteamClient.IsConnected)) { - return; - } + return ((ArchiHandler.EPrivacySetting) privacySettings.privacy_state == ArchiHandler.EPrivacySetting.Public) && ((ArchiHandler.EPrivacySetting) privacySettings.privacy_state_inventory == ArchiHandler.EPrivacySetting.Public); + } - await LimitLoginRequestsAsync().ConfigureAwait(false); - - if (!force && (!KeepRunning || SteamClient.IsConnected)) { - return; - } - - ArchiLogger.LogGenericInfo(Strings.BotConnecting); - InitConnectionFailureTimer(); - SteamClient.Connect(); + internal async Task IdleGame(Game game) { + if (game == null) { + throw new ArgumentNullException(nameof(game)); } - private async Task Destroy(bool force = false) { - if (Bots == null) { - throw new InvalidOperationException(nameof(Bots)); - } + string? gameName = null; - if (KeepRunning) { - if (!force) { - Stop(); - } else { - // Stop() will most likely block due to connection freeze, don't wait for it - Utilities.InBackground(() => Stop()); - } - } - - Bots.TryRemove(BotName, out _); - await PluginsCore.OnBotDestroy(this).ConfigureAwait(false); + if (!string.IsNullOrEmpty(BotConfig.CustomGamePlayedWhileFarming)) { + gameName = string.Format(CultureInfo.CurrentCulture, BotConfig.CustomGamePlayedWhileFarming!, game.AppID, game.GameName); } - private void Disconnect() { - StopConnectionFailureTimer(); - SteamClient.Disconnect(); + await ArchiHandler.PlayGames(new HashSet(1) { game.PlayableAppID }, gameName).ConfigureAwait(false); + } + + internal async Task IdleGames(IReadOnlyCollection games) { + if ((games == null) || (games.Count == 0)) { + throw new ArgumentNullException(nameof(games)); } - private async Task?> GetKeysFromFile(string filePath) { - if (string.IsNullOrEmpty(filePath)) { - throw new ArgumentNullException(nameof(filePath)); - } + string? gameName = null; - if (!File.Exists(filePath)) { - return new Dictionary(0, StringComparer.Ordinal); - } + if (!string.IsNullOrEmpty(BotConfig.CustomGamePlayedWhileFarming)) { + gameName = string.Format(CultureInfo.CurrentCulture, BotConfig.CustomGamePlayedWhileFarming!, string.Join(", ", games.Select(static game => game.AppID)), string.Join(", ", games.Select(static game => game.GameName))); + } - Dictionary keys = new(StringComparer.Ordinal); + await ArchiHandler.PlayGames(games.Select(static game => game.PlayableAppID).ToHashSet(), gameName).ConfigureAwait(false); + } - try { - using StreamReader reader = new(filePath); + internal async Task ImportKeysToRedeem(string filePath) { + if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) { + throw new ArgumentNullException(nameof(filePath)); + } + try { + OrderedDictionary gamesToRedeemInBackground = new(); + + using (StreamReader reader = new(filePath)) { string? line; while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) { @@ -1812,1167 +1301,929 @@ namespace ArchiSteamFarm.Steam { continue; } + // Valid formats: + // Key (name will be the same as key and replaced from redemption result, if possible) + // Name + Key (user provides both, if name is equal to key, above logic is used, otherwise name is kept) + // Name + + Key (BGR output format, we include extra properties in the middle, those are ignored during import) string[] parsedArgs = line.Split(DefaultBackgroundKeysRedeemerSeparator, StringSplitOptions.RemoveEmptyEntries); - if (parsedArgs.Length < 3) { + if (parsedArgs.Length < 1) { ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, line)); continue; } + string name = parsedArgs[0]; string key = parsedArgs[^1]; - if (!Utilities.IsValidCdKey(key)) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, key)); - - continue; - } - - string name = parsedArgs[0]; - keys[key] = name; + gamesToRedeemInBackground[key] = name; } + } + + if (gamesToRedeemInBackground.Count > 0) { + IOrderedDictionary validGamesToRedeemInBackground = ValidateGamesToRedeemInBackground(gamesToRedeemInBackground); + + if (validGamesToRedeemInBackground.Count > 0) { + AddGamesToRedeemInBackground(validGamesToRedeemInBackground); + } + } + + File.Delete(filePath); + } catch (Exception e) { + ArchiLogger.LogGenericException(e); + } + } + + internal static void Init(StringComparer botsComparer) { + if (Bots != null) { + throw new InvalidOperationException(nameof(Bots)); + } + + BotsComparer = botsComparer ?? throw new ArgumentNullException(nameof(botsComparer)); + Bots = new ConcurrentDictionary(botsComparer); + } + + internal bool IsBlacklistedFromIdling(uint appID) { + if (appID == 0) { + throw new ArgumentOutOfRangeException(nameof(appID)); + } + + return BotDatabase.IdlingBlacklistedAppIDs.Contains(appID); + } + + internal bool IsBlacklistedFromTrades(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + return BotDatabase.BlacklistedFromTradesSteamIDs.Contains(steamID); + } + + internal bool IsPriorityIdling(uint appID) { + if (appID == 0) { + throw new ArgumentOutOfRangeException(nameof(appID)); + } + + return BotDatabase.IdlingPriorityAppIDs.Contains(appID); + } + + internal async Task OnConfigChanged(bool deleted) { + if (deleted) { + await Destroy().ConfigureAwait(false); + + return; + } + + string configFile = GetFilePath(EFileType.Config); + + if (string.IsNullOrEmpty(configFile)) { + ArchiLogger.LogNullError(nameof(configFile)); + + return; + } + + (BotConfig? botConfig, _) = await BotConfig.Load(configFile).ConfigureAwait(false); + + if (botConfig == null) { + await Destroy().ConfigureAwait(false); + + return; + } + + if (botConfig == BotConfig) { + return; + } + + await InitializationSemaphore.WaitAsync().ConfigureAwait(false); + + try { + if (botConfig == BotConfig) { + return; + } + + Stop(botConfig.Enabled); + BotConfig = botConfig; + + await InitModules().ConfigureAwait(false); + InitStart(); + } finally { + InitializationSemaphore.Release(); + } + } + + internal async Task OnFarmingFinished(bool farmedSomething) { + await OnFarmingStopped().ConfigureAwait(false); + + if (BotConfig.SendOnFarmingFinished && (BotConfig.LootableTypes.Count > 0) && (farmedSomething || !FirstTradeSent)) { + FirstTradeSent = true; + + await Actions.SendInventory(filterFunction: item => BotConfig.LootableTypes.Contains(item.Type)).ConfigureAwait(false); + } + + if (BotConfig.ShutdownOnFarmingFinished) { + Stop(); + } + + await PluginsCore.OnBotFarmingFinished(this, farmedSomething).ConfigureAwait(false); + } + + internal async Task OnFarmingStopped() { + await ResetGamesPlayed().ConfigureAwait(false); + await PluginsCore.OnBotFarmingStopped(this).ConfigureAwait(false); + } + + internal async Task RefreshSession() { + if (!IsConnectedAndLoggedOn) { + return false; + } + + SteamUser.WebAPIUserNonceCallback callback; + + try { + callback = await SteamUser.RequestWebAPIUserNonce().ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + await Connect(true).ConfigureAwait(false); + + return false; + } + + if (string.IsNullOrEmpty(callback.Nonce)) { + await Connect(true).ConfigureAwait(false); + + return false; + } + + if (await ArchiWebHandler.Init(SteamID, SteamClient.Universe, callback.Nonce, SteamParentalActive ? BotConfig.SteamParentalCode : null).ConfigureAwait(false)) { + return true; + } + + await Connect(true).ConfigureAwait(false); + + return false; + } + + internal static async Task RegisterBot(string botName) { + if (string.IsNullOrEmpty(botName)) { + throw new ArgumentNullException(nameof(botName)); + } + + if (Bots == null) { + throw new InvalidOperationException(nameof(Bots)); + } + + if (Bots.ContainsKey(botName)) { + return; + } + + string configFilePath = GetFilePath(botName, EFileType.Config); + + if (string.IsNullOrEmpty(configFilePath)) { + ASF.ArchiLogger.LogNullError(nameof(configFilePath)); + + return; + } + + (BotConfig? botConfig, string? latestJson) = await BotConfig.Load(configFilePath).ConfigureAwait(false); + + if (botConfig == null) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorBotConfigInvalid, configFilePath)); + + return; + } + + if (Debugging.IsDebugConfigured) { + ASF.ArchiLogger.LogGenericDebug($"{configFilePath}: {JsonConvert.SerializeObject(botConfig, Formatting.Indented)}"); + } + + if (!string.IsNullOrEmpty(latestJson)) { + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.AutomaticFileMigration, configFilePath)); + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + await SerializableFile.Write(configFilePath, latestJson!).ConfigureAwait(false); + + ASF.ArchiLogger.LogGenericInfo(Strings.Done); + } + + string databaseFilePath = GetFilePath(botName, EFileType.Database); + + if (string.IsNullOrEmpty(databaseFilePath)) { + ASF.ArchiLogger.LogNullError(nameof(databaseFilePath)); + + return; + } + + BotDatabase? botDatabase = await BotDatabase.CreateOrLoad(databaseFilePath).ConfigureAwait(false); + + if (botDatabase == null) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorDatabaseInvalid, databaseFilePath)); + + return; + } + + if (Debugging.IsDebugConfigured) { + ASF.ArchiLogger.LogGenericDebug($"{databaseFilePath}: {JsonConvert.SerializeObject(botDatabase, Formatting.Indented)}"); + } + + Bot bot; + + await BotsSemaphore.WaitAsync().ConfigureAwait(false); + + try { + if (Bots.ContainsKey(botName)) { + return; + } + + bot = new Bot(botName, botConfig, botDatabase); + + if (!Bots.TryAdd(botName, bot)) { + ASF.ArchiLogger.LogNullError(nameof(bot)); + + await bot.DisposeAsync().ConfigureAwait(false); + + return; + } + } finally { + BotsSemaphore.Release(); + } + + await PluginsCore.OnBotInit(bot).ConfigureAwait(false); + + HashSet? customHandlers = await PluginsCore.OnBotSteamHandlersInit(bot).ConfigureAwait(false); + + if (customHandlers?.Count > 0) { + foreach (ClientMsgHandler customHandler in customHandlers) { + bot.SteamClient.AddHandler(customHandler); + } + } + + await PluginsCore.OnBotSteamCallbacksInit(bot, bot.CallbackManager).ConfigureAwait(false); + + await bot.InitModules().ConfigureAwait(false); + + bot.InitStart(); + } + + internal (bool Success, string? Message) RemoveAuthenticator() { + MobileAuthenticator? authenticator = BotDatabase.MobileAuthenticator; + + if (authenticator == null) { + return (false, Strings.BotNoASFAuthenticator); + } + + BotDatabase.MobileAuthenticator = null; + authenticator.Dispose(); + + return (true, null); + } + + internal async Task Rename(string newBotName) { + if (string.IsNullOrEmpty(newBotName)) { + throw new ArgumentNullException(nameof(newBotName)); + } + + if (Bots == null) { + throw new InvalidOperationException(nameof(Bots)); + } + + if (!ASF.IsValidBotName(newBotName) || Bots.ContainsKey(newBotName)) { + return false; + } + + if (KeepRunning) { + Stop(true); + } + + await BotDatabase.MakeReadOnly().ConfigureAwait(false); + + // We handle the config file last as it'll trigger new bot creation + foreach ((string filePath, EFileType fileType) in RelatedFiles.Where(static file => File.Exists(file.FilePath)).OrderByDescending(static file => file.FileType != EFileType.Config)) { + string newFilePath = GetFilePath(newBotName, fileType); + + if (string.IsNullOrEmpty(newFilePath)) { + ArchiLogger.LogNullError(nameof(newFilePath)); + + return false; + } + + try { + File.Move(filePath, newFilePath); } catch (Exception e) { ArchiLogger.LogGenericException(e); - return null; + return false; } - - return keys; } - private async Task?> GetPossiblyCompletedBadgeAppIDs(byte page) { - if (page == 0) { - throw new ArgumentOutOfRangeException(nameof(page)); + return true; + } + + internal void RequestPersonaStateUpdate() { + if (!IsConnectedAndLoggedOn) { + return; + } + + SteamFriends.RequestFriendInfo(SteamID, EClientPersonaStateFlag.PlayerName | EClientPersonaStateFlag.Presence); + } + + internal async Task SendTypingMessage(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!IsConnectedAndLoggedOn) { + return false; + } + + return await ArchiHandler.SendTypingStatus(steamID).ConfigureAwait(false) == EResult.OK; + } + + internal async Task Start() { + if (KeepRunning) { + return; + } + + KeepRunning = true; + Utilities.InBackground(HandleCallbacks, true); + ArchiLogger.LogGenericInfo(Strings.Starting); + + // Support and convert 2FA files + if (!HasMobileAuthenticator) { + string mobileAuthenticatorFilePath = GetFilePath(EFileType.MobileAuthenticator); + + if (string.IsNullOrEmpty(mobileAuthenticatorFilePath)) { + ArchiLogger.LogNullError(nameof(mobileAuthenticatorFilePath)); + + return; } - using IDocument? badgePage = await ArchiWebHandler.GetBadgePage(page).ConfigureAwait(false); + if (File.Exists(mobileAuthenticatorFilePath)) { + await ImportAuthenticatorFromFile(mobileAuthenticatorFilePath).ConfigureAwait(false); + } + } - if (badgePage == null) { - ArchiLogger.LogGenericWarning(Strings.WarningCouldNotCheckBadges); + string keysToRedeemFilePath = GetFilePath(EFileType.KeysToRedeem); + + if (string.IsNullOrEmpty(keysToRedeemFilePath)) { + ArchiLogger.LogNullError(nameof(keysToRedeemFilePath)); + + return; + } + + if (File.Exists(keysToRedeemFilePath)) { + await ImportKeysToRedeem(keysToRedeemFilePath).ConfigureAwait(false); + } + + await Connect().ConfigureAwait(false); + } + + internal void Stop(bool skipShutdownEvent = false) { + if (!KeepRunning) { + return; + } + + KeepRunning = false; + ArchiLogger.LogGenericInfo(Strings.BotStopping); + + if (SteamClient.IsConnected) { + Disconnect(); + } + + if (!skipShutdownEvent) { + Utilities.InBackground(Events.OnBotShutdown); + } + } + + internal bool TryImportAuthenticator(MobileAuthenticator authenticator) { + if (authenticator == null) { + throw new ArgumentNullException(nameof(authenticator)); + } + + if (HasMobileAuthenticator) { + return false; + } + + authenticator.Init(this); + BotDatabase.MobileAuthenticator = authenticator; + + ArchiLogger.LogGenericInfo(Strings.BotAuthenticatorImportFinished); + + return true; + } + + internal static IOrderedDictionary ValidateGamesToRedeemInBackground(IOrderedDictionary gamesToRedeemInBackground) { + if ((gamesToRedeemInBackground == null) || (gamesToRedeemInBackground.Count == 0)) { + throw new ArgumentNullException(nameof(gamesToRedeemInBackground)); + } + + HashSet invalidKeys = new(); + + foreach (DictionaryEntry game in gamesToRedeemInBackground) { + bool invalid = false; + + string? key = game.Key as string; + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (string.IsNullOrEmpty(key)) { + invalid = true; + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(key))); + } else if (!Utilities.IsValidCdKey(key!)) { + invalid = true; + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, key)); + } + + string? name = game.Value as string; + + if (string.IsNullOrEmpty(name)) { + invalid = true; + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(name))); + } + + if (invalid && (key != null)) { + invalidKeys.Add(key); + } + } + + if (invalidKeys.Count > 0) { + foreach (string invalidKey in invalidKeys) { + gamesToRedeemInBackground.Remove(invalidKey); + } + } + + return gamesToRedeemInBackground; + } + + private async Task Connect(bool force = false) { + if (!force && (!KeepRunning || SteamClient.IsConnected)) { + return; + } + + await LimitLoginRequestsAsync().ConfigureAwait(false); + + if (!force && (!KeepRunning || SteamClient.IsConnected)) { + return; + } + + ArchiLogger.LogGenericInfo(Strings.BotConnecting); + InitConnectionFailureTimer(); + SteamClient.Connect(); + } + + private async Task Destroy(bool force = false) { + if (Bots == null) { + throw new InvalidOperationException(nameof(Bots)); + } + + if (KeepRunning) { + if (!force) { + Stop(); + } else { + // Stop() will most likely block due to connection freeze, don't wait for it + Utilities.InBackground(() => Stop()); + } + } + + Bots.TryRemove(BotName, out _); + await PluginsCore.OnBotDestroy(this).ConfigureAwait(false); + } + + private void Disconnect() { + StopConnectionFailureTimer(); + SteamClient.Disconnect(); + } + + private async Task?> GetKeysFromFile(string filePath) { + if (string.IsNullOrEmpty(filePath)) { + throw new ArgumentNullException(nameof(filePath)); + } + + if (!File.Exists(filePath)) { + return new Dictionary(0, StringComparer.Ordinal); + } + + Dictionary keys = new(StringComparer.Ordinal); + + try { + using StreamReader reader = new(filePath); + + string? line; + + while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) { + if (line.Length == 0) { + continue; + } + + string[] parsedArgs = line.Split(DefaultBackgroundKeysRedeemerSeparator, StringSplitOptions.RemoveEmptyEntries); + + if (parsedArgs.Length < 3) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, line)); + + continue; + } + + string key = parsedArgs[^1]; + + if (!Utilities.IsValidCdKey(key)) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, key)); + + continue; + } + + string name = parsedArgs[0]; + keys[key] = name; + } + } catch (Exception e) { + ArchiLogger.LogGenericException(e); + + return null; + } + + return keys; + } + + private async Task?> GetPossiblyCompletedBadgeAppIDs(byte page) { + if (page == 0) { + throw new ArgumentOutOfRangeException(nameof(page)); + } + + using IDocument? badgePage = await ArchiWebHandler.GetBadgePage(page).ConfigureAwait(false); + + if (badgePage == null) { + ArchiLogger.LogGenericWarning(Strings.WarningCouldNotCheckBadges); + + return null; + } + + return GetPossiblyCompletedBadgeAppIDs(badgePage); + } + + private HashSet? GetPossiblyCompletedBadgeAppIDs(IDocument badgePage) { + if (badgePage == null) { + throw new ArgumentNullException(nameof(badgePage)); + } + + // We select badges that are ready to craft, as well as those that are already crafted to a maximum level, as those will not display with a craft button + // Level 5 is maximum level for card badges according to https://steamcommunity.com/tradingcards/faq + IEnumerable linkElements = badgePage.SelectNodes("//a[@class='badge_craft_button'] | //div[@class='badges_sheet']/div[contains(@class, 'badge_row') and .//div[@class='badge_info_description']/div[contains(text(), 'Level 5')]]/a[@class='badge_row_overlay']"); + + HashSet result = new(); + + foreach (string? badgeUri in linkElements.Select(static htmlNode => htmlNode.GetAttribute("href"))) { + if (string.IsNullOrEmpty(badgeUri)) { + ArchiLogger.LogNullError(nameof(badgeUri)); return null; } - return GetPossiblyCompletedBadgeAppIDs(badgePage); + // URIs to foil badges are the same as for normal badges except they end with "?border=1" + string appIDText = badgeUri.Split('?', StringSplitOptions.RemoveEmptyEntries)[0].Split('/', StringSplitOptions.RemoveEmptyEntries)[^1]; + + if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { + ArchiLogger.LogNullError(nameof(appID)); + + return null; + } + + result.Add(appID); } - private HashSet? GetPossiblyCompletedBadgeAppIDs(IDocument badgePage) { - if (badgePage == null) { - throw new ArgumentNullException(nameof(badgePage)); + return result; + } + + private void HandleCallbacks() { + TimeSpan timeSpan = TimeSpan.FromMilliseconds(CallbackSleep); + + while (KeepRunning || SteamClient.IsConnected) { + if (!CallbackSemaphore.Wait(0)) { + if (Debugging.IsUserDebugging) { + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(CallbackSemaphore))); + } + + return; } - // We select badges that are ready to craft, as well as those that are already crafted to a maximum level, as those will not display with a craft button - // Level 5 is maximum level for card badges according to https://steamcommunity.com/tradingcards/faq - IEnumerable linkElements = badgePage.SelectNodes("//a[@class='badge_craft_button'] | //div[@class='badges_sheet']/div[contains(@class, 'badge_row') and .//div[@class='badge_info_description']/div[contains(text(), 'Level 5')]]/a[@class='badge_row_overlay']"); - - HashSet result = new(); - - foreach (string? badgeUri in linkElements.Select(static htmlNode => htmlNode.GetAttribute("href"))) { - if (string.IsNullOrEmpty(badgeUri)) { - ArchiLogger.LogNullError(nameof(badgeUri)); - - return null; - } - - // URIs to foil badges are the same as for normal badges except they end with "?border=1" - string appIDText = badgeUri.Split('?', StringSplitOptions.RemoveEmptyEntries)[0].Split('/', StringSplitOptions.RemoveEmptyEntries)[^1]; - - if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { - ArchiLogger.LogNullError(nameof(appID)); - - return null; - } - - result.Add(appID); - } - - return result; - } - - private void HandleCallbacks() { - TimeSpan timeSpan = TimeSpan.FromMilliseconds(CallbackSleep); - - while (KeepRunning || SteamClient.IsConnected) { - if (!CallbackSemaphore.Wait(0)) { - if (Debugging.IsUserDebugging) { - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(CallbackSemaphore))); - } - - return; - } - - try { - CallbackManager.RunWaitAllCallbacks(timeSpan); - } catch (Exception e) { - ArchiLogger.LogGenericException(e); - } finally { - CallbackSemaphore.Release(); - } + try { + CallbackManager.RunWaitAllCallbacks(timeSpan); + } catch (Exception e) { + ArchiLogger.LogGenericException(e); + } finally { + CallbackSemaphore.Release(); } } + } - private async void HeartBeat(object? state = null) { - if (ASF.GlobalConfig == null) { - throw new InvalidOperationException(nameof(ASF.GlobalConfig)); + private async void HeartBeat(object? state = null) { + if (ASF.GlobalConfig == null) { + throw new InvalidOperationException(nameof(ASF.GlobalConfig)); + } + + if (!KeepRunning || !IsConnectedAndLoggedOn || (HeartBeatFailures == byte.MaxValue)) { + return; + } + + try { + if (DateTime.UtcNow.Subtract(ArchiHandler.LastPacketReceived).TotalSeconds > ASF.GlobalConfig.ConnectionTimeout) { + await SteamFriends.RequestProfileInfo(SteamID).ToLongRunningTask().ConfigureAwait(false); } + HeartBeatFailures = 0; + + if (Statistics != null) { + Utilities.InBackground(Statistics.OnHeartBeat); + } + } catch (Exception e) { + ArchiLogger.LogGenericDebuggingException(e); + if (!KeepRunning || !IsConnectedAndLoggedOn || (HeartBeatFailures == byte.MaxValue)) { return; } - try { - if (DateTime.UtcNow.Subtract(ArchiHandler.LastPacketReceived).TotalSeconds > ASF.GlobalConfig.ConnectionTimeout) { - await SteamFriends.RequestProfileInfo(SteamID).ToLongRunningTask().ConfigureAwait(false); - } - - HeartBeatFailures = 0; - - if (Statistics != null) { - Utilities.InBackground(Statistics.OnHeartBeat); - } - } catch (Exception e) { - ArchiLogger.LogGenericDebuggingException(e); - - if (!KeepRunning || !IsConnectedAndLoggedOn || (HeartBeatFailures == byte.MaxValue)) { - return; - } - - if (++HeartBeatFailures >= (byte) Math.Ceiling(ASF.GlobalConfig.ConnectionTimeout / 10.0)) { - HeartBeatFailures = byte.MaxValue; - ArchiLogger.LogGenericWarning(Strings.BotConnectionLost); - Utilities.InBackground(() => Connect(true)); - } + if (++HeartBeatFailures >= (byte) Math.Ceiling(ASF.GlobalConfig.ConnectionTimeout / 10.0)) { + HeartBeatFailures = byte.MaxValue; + ArchiLogger.LogGenericWarning(Strings.BotConnectionLost); + Utilities.InBackground(() => Connect(true)); } } + } + + private async Task ImportAuthenticatorFromFile(string maFilePath) { + if (HasMobileAuthenticator || !File.Exists(maFilePath)) { + return; + } + + ArchiLogger.LogGenericInfo(Strings.BotAuthenticatorConverting); + + try { + string json = await File.ReadAllTextAsync(maFilePath).ConfigureAwait(false); + + if (string.IsNullOrEmpty(json)) { + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(json))); - private async Task ImportAuthenticatorFromFile(string maFilePath) { - if (HasMobileAuthenticator || !File.Exists(maFilePath)) { return; } - ArchiLogger.LogGenericInfo(Strings.BotAuthenticatorConverting); + MobileAuthenticator? authenticator = JsonConvert.DeserializeObject(json); - try { - string json = await File.ReadAllTextAsync(maFilePath).ConfigureAwait(false); + if (authenticator == null) { + ArchiLogger.LogNullError(nameof(authenticator)); - if (string.IsNullOrEmpty(json)) { - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(json))); - - return; - } - - MobileAuthenticator? authenticator = JsonConvert.DeserializeObject(json); - - if (authenticator == null) { - ArchiLogger.LogNullError(nameof(authenticator)); - - return; - } - - if (!TryImportAuthenticator(authenticator)) { - return; - } - - File.Delete(maFilePath); - } catch (Exception e) { - ArchiLogger.LogGenericException(e); - } - } - - private void InitConnectionFailureTimer() { - if (ConnectionFailureTimer != null) { return; } - byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; + if (!TryImportAuthenticator(authenticator)) { + return; + } - ConnectionFailureTimer = new Timer( - InitPermanentConnectionFailure, + File.Delete(maFilePath); + } catch (Exception e) { + ArchiLogger.LogGenericException(e); + } + } + + private void InitConnectionFailureTimer() { + if (ConnectionFailureTimer != null) { + return; + } + + byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; + + ConnectionFailureTimer = new Timer( + InitPermanentConnectionFailure, + null, + TimeSpan.FromMinutes(Math.Ceiling(connectionTimeout / 30.0)), // Delay + Timeout.InfiniteTimeSpan // Period + ); + } + + private async Task InitializeFamilySharing() { + HashSet? steamIDs = await ArchiWebHandler.GetFamilySharingSteamIDs().ConfigureAwait(false); + + if (steamIDs == null) { + return; + } + + SteamFamilySharingIDs.ReplaceWith(steamIDs); + } + + private async Task InitLoginAndPassword(bool requiresPassword) { + if (string.IsNullOrEmpty(BotConfig.SteamLogin)) { + RequiredInput = ASF.EUserInputType.Login; + + string? steamLogin = await Logging.GetUserInput(ASF.EUserInputType.Login, BotName).ConfigureAwait(false); + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (string.IsNullOrEmpty(steamLogin) || !SetUserInput(ASF.EUserInputType.Login, steamLogin!)) { + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(steamLogin))); + + return false; + } + } + + if (requiresPassword && string.IsNullOrEmpty(BotConfig.DecryptedSteamPassword)) { + RequiredInput = ASF.EUserInputType.Password; + + string? steamPassword = await Logging.GetUserInput(ASF.EUserInputType.Password, BotName).ConfigureAwait(false); + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (string.IsNullOrEmpty(steamPassword) || !SetUserInput(ASF.EUserInputType.Password, steamPassword!)) { + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(steamPassword))); + + return false; + } + } + + return true; + } + + private async Task InitModules() { + if (Bots == null) { + throw new InvalidOperationException(nameof(Bots)); + } + + AccountFlags = EAccountFlags.NormalUser; + AvatarHash = Nickname = null; + MasterChatGroupID = 0; + RequiredInput = ASF.EUserInputType.None; + WalletBalance = 0; + WalletCurrency = ECurrencyCode.Invalid; + + CardsFarmer.SetInitialState(BotConfig.Paused); + + if (SendItemsTimer != null) { + await SendItemsTimer.DisposeAsync().ConfigureAwait(false); + + SendItemsTimer = null; + } + + if ((BotConfig.SendTradePeriod > 0) && (BotConfig.LootableTypes.Count > 0) && BotConfig.SteamUserPermissions.Values.Any(static permission => permission >= BotConfig.EAccess.Master)) { + SendItemsTimer = new Timer( + OnSendItemsTimer, null, - TimeSpan.FromMinutes(Math.Ceiling(connectionTimeout / 30.0)), // Delay - Timeout.InfiniteTimeSpan // Period + TimeSpan.FromHours(BotConfig.SendTradePeriod) + TimeSpan.FromSeconds(ASF.LoadBalancingDelay * Bots.Count), // Delay + TimeSpan.FromHours(BotConfig.SendTradePeriod) // Period ); } - private async Task InitializeFamilySharing() { - HashSet? steamIDs = await ArchiWebHandler.GetFamilySharingSteamIDs().ConfigureAwait(false); + if (SteamSaleEvent != null) { + await SteamSaleEvent.DisposeAsync().ConfigureAwait(false); - if (steamIDs == null) { - return; - } - - SteamFamilySharingIDs.ReplaceWith(steamIDs); + SteamSaleEvent = null; } - private async Task InitLoginAndPassword(bool requiresPassword) { - if (string.IsNullOrEmpty(BotConfig.SteamLogin)) { - RequiredInput = ASF.EUserInputType.Login; - - string? steamLogin = await Logging.GetUserInput(ASF.EUserInputType.Login, BotName).ConfigureAwait(false); - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (string.IsNullOrEmpty(steamLogin) || !SetUserInput(ASF.EUserInputType.Login, steamLogin!)) { - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(steamLogin))); - - return false; - } - } - - if (requiresPassword && string.IsNullOrEmpty(BotConfig.DecryptedSteamPassword)) { - RequiredInput = ASF.EUserInputType.Password; - - string? steamPassword = await Logging.GetUserInput(ASF.EUserInputType.Password, BotName).ConfigureAwait(false); - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (string.IsNullOrEmpty(steamPassword) || !SetUserInput(ASF.EUserInputType.Password, steamPassword!)) { - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(steamPassword))); - - return false; - } - } - - return true; + if (BotConfig.AutoSteamSaleEvent) { + SteamSaleEvent = new SteamSaleEvent(this); } - private async Task InitModules() { - if (Bots == null) { - throw new InvalidOperationException(nameof(Bots)); - } + await PluginsCore.OnBotInitModules(this, BotConfig.AdditionalProperties).ConfigureAwait(false); + } - AccountFlags = EAccountFlags.NormalUser; - AvatarHash = Nickname = null; - MasterChatGroupID = 0; - RequiredInput = ASF.EUserInputType.None; - WalletBalance = 0; - WalletCurrency = ECurrencyCode.Invalid; - - CardsFarmer.SetInitialState(BotConfig.Paused); - - if (SendItemsTimer != null) { - await SendItemsTimer.DisposeAsync().ConfigureAwait(false); - - SendItemsTimer = null; - } - - if ((BotConfig.SendTradePeriod > 0) && (BotConfig.LootableTypes.Count > 0) && BotConfig.SteamUserPermissions.Values.Any(static permission => permission >= BotConfig.EAccess.Master)) { - SendItemsTimer = new Timer( - OnSendItemsTimer, - null, - TimeSpan.FromHours(BotConfig.SendTradePeriod) + TimeSpan.FromSeconds(ASF.LoadBalancingDelay * Bots.Count), // Delay - TimeSpan.FromHours(BotConfig.SendTradePeriod) // Period - ); - } - - if (SteamSaleEvent != null) { - await SteamSaleEvent.DisposeAsync().ConfigureAwait(false); - - SteamSaleEvent = null; - } - - if (BotConfig.AutoSteamSaleEvent) { - SteamSaleEvent = new SteamSaleEvent(this); - } - - await PluginsCore.OnBotInitModules(this, BotConfig.AdditionalProperties).ConfigureAwait(false); + private async void InitPermanentConnectionFailure(object? state = null) { + if (!KeepRunning) { + return; } - private async void InitPermanentConnectionFailure(object? state = null) { - if (!KeepRunning) { - return; - } + ArchiLogger.LogGenericWarning(Strings.BotHeartBeatFailed); + await Destroy(true).ConfigureAwait(false); + await RegisterBot(BotName).ConfigureAwait(false); + } - ArchiLogger.LogGenericWarning(Strings.BotHeartBeatFailed); - await Destroy(true).ConfigureAwait(false); - await RegisterBot(BotName).ConfigureAwait(false); + private void InitPlayingWasBlockedTimer() { + if (PlayingWasBlockedTimer != null) { + return; } - private void InitPlayingWasBlockedTimer() { - if (PlayingWasBlockedTimer != null) { - return; - } + PlayingWasBlockedTimer = new Timer( + ResetPlayingWasBlockedWithTimer, + null, + TimeSpan.FromSeconds(MinPlayingBlockedTTL), // Delay + Timeout.InfiniteTimeSpan // Period + ); + } - PlayingWasBlockedTimer = new Timer( - ResetPlayingWasBlockedWithTimer, - null, - TimeSpan.FromSeconds(MinPlayingBlockedTTL), // Delay - Timeout.InfiniteTimeSpan // Period - ); + private void InitStart() { + if (!BotConfig.Enabled) { + ArchiLogger.LogGenericInfo(Strings.BotInstanceNotStartingBecauseDisabled); + + return; } - private void InitStart() { - if (!BotConfig.Enabled) { - ArchiLogger.LogGenericInfo(Strings.BotInstanceNotStartingBecauseDisabled); + // Start + Utilities.InBackground(Start); + } - return; - } - - // Start - Utilities.InBackground(Start); + private bool IsMasterClanID(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsClanAccount) { + throw new ArgumentNullException(nameof(steamID)); } - private bool IsMasterClanID(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsClanAccount) { - throw new ArgumentNullException(nameof(steamID)); - } + return steamID == BotConfig.SteamMasterClanID; + } - return steamID == BotConfig.SteamMasterClanID; + private static bool IsRefundable(EPaymentMethod paymentMethod) { + if (paymentMethod == EPaymentMethod.None) { + throw new ArgumentNullException(nameof(paymentMethod)); } - private static bool IsRefundable(EPaymentMethod paymentMethod) { - if (paymentMethod == EPaymentMethod.None) { - throw new ArgumentNullException(nameof(paymentMethod)); - } - #pragma warning disable CA2248 // This is actually a fair warning, EPaymentMethod is not a flags enum on itself, but there is nothing we can do about Steam using it like that here - return paymentMethod switch { - EPaymentMethod.ActivationCode => false, - EPaymentMethod.Complimentary => false, - EPaymentMethod.GuestPass => false, - EPaymentMethod.HardwarePromo => false, - _ => !paymentMethod.HasFlag(EPaymentMethod.Complimentary) // Complimentary can also be a flag - }; + return paymentMethod switch { + EPaymentMethod.ActivationCode => false, + EPaymentMethod.Complimentary => false, + EPaymentMethod.GuestPass => false, + EPaymentMethod.HardwarePromo => false, + _ => !paymentMethod.HasFlag(EPaymentMethod.Complimentary) // Complimentary can also be a flag + }; #pragma warning restore CA2248 // This is actually a fair warning, EPaymentMethod is not a flags enum on itself, but there is nothing we can do about Steam using it like that here + } + + private async Task JoinMasterChatGroupID() { + if ((BotConfig.SteamMasterClanID == 0) || IsAccountLimited) { + return; } - private async Task JoinMasterChatGroupID() { - if ((BotConfig.SteamMasterClanID == 0) || IsAccountLimited) { + if (MasterChatGroupID == 0) { + ulong chatGroupID = await ArchiHandler.GetClanChatGroupID(BotConfig.SteamMasterClanID).ConfigureAwait(false); + + if (chatGroupID == 0) { return; } - if (MasterChatGroupID == 0) { - ulong chatGroupID = await ArchiHandler.GetClanChatGroupID(BotConfig.SteamMasterClanID).ConfigureAwait(false); + MasterChatGroupID = chatGroupID; + } - if (chatGroupID == 0) { - return; + HashSet? chatGroupIDs = await ArchiHandler.GetMyChatGroupIDs().ConfigureAwait(false); + + if (chatGroupIDs?.Contains(MasterChatGroupID) != false) { + return; + } + + if (!await ArchiHandler.JoinChatRoomGroup(MasterChatGroupID).ConfigureAwait(false)) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiHandler.JoinChatRoomGroup))); + } + } + + private static async Task LimitLoginRequestsAsync() { + if ((ASF.LoginSemaphore == null) || (ASF.LoginRateLimitingSemaphore == null)) { + ASF.ArchiLogger.LogNullError($"{nameof(ASF.LoginSemaphore)} || {nameof(ASF.LoginRateLimitingSemaphore)}"); + + return; + } + + byte loginLimiterDelay = ASF.GlobalConfig?.LoginLimiterDelay ?? GlobalConfig.DefaultLoginLimiterDelay; + + if (loginLimiterDelay == 0) { + await ASF.LoginRateLimitingSemaphore.WaitAsync().ConfigureAwait(false); + ASF.LoginRateLimitingSemaphore.Release(); + + return; + } + + await ASF.LoginSemaphore.WaitAsync().ConfigureAwait(false); + + try { + await ASF.LoginRateLimitingSemaphore.WaitAsync().ConfigureAwait(false); + ASF.LoginRateLimitingSemaphore.Release(); + } finally { + Utilities.InBackground( + async () => { + await Task.Delay(loginLimiterDelay * 1000).ConfigureAwait(false); + ASF.LoginSemaphore.Release(); } + ); + } + } - MasterChatGroupID = chatGroupID; - } - - HashSet? chatGroupIDs = await ArchiHandler.GetMyChatGroupIDs().ConfigureAwait(false); - - if (chatGroupIDs?.Contains(MasterChatGroupID) != false) { - return; - } - - if (!await ArchiHandler.JoinChatRoomGroup(MasterChatGroupID).ConfigureAwait(false)) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiHandler.JoinChatRoomGroup))); - } + private async void OnConnected(SteamClient.ConnectedCallback callback) { + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); } - private static async Task LimitLoginRequestsAsync() { - if ((ASF.LoginSemaphore == null) || (ASF.LoginRateLimitingSemaphore == null)) { - ASF.ArchiLogger.LogNullError($"{nameof(ASF.LoginSemaphore)} || {nameof(ASF.LoginRateLimitingSemaphore)}"); + HeartBeatFailures = 0; + ReconnectOnUserInitiated = false; + StopConnectionFailureTimer(); - return; - } + ArchiLogger.LogGenericInfo(Strings.BotConnected); - byte loginLimiterDelay = ASF.GlobalConfig?.LoginLimiterDelay ?? GlobalConfig.DefaultLoginLimiterDelay; + if (!KeepRunning) { + ArchiLogger.LogGenericInfo(Strings.BotDisconnecting); + Disconnect(); - if (loginLimiterDelay == 0) { - await ASF.LoginRateLimitingSemaphore.WaitAsync().ConfigureAwait(false); - ASF.LoginRateLimitingSemaphore.Release(); + return; + } - return; - } + string sentryFilePath = GetFilePath(EFileType.SentryFile); - await ASF.LoginSemaphore.WaitAsync().ConfigureAwait(false); + if (string.IsNullOrEmpty(sentryFilePath)) { + ArchiLogger.LogNullError(nameof(sentryFilePath)); + return; + } + + byte[]? sentryFileHash = null; + + if (File.Exists(sentryFilePath)) { try { - await ASF.LoginRateLimitingSemaphore.WaitAsync().ConfigureAwait(false); - ASF.LoginRateLimitingSemaphore.Release(); - } finally { - Utilities.InBackground( - async () => { - await Task.Delay(loginLimiterDelay * 1000).ConfigureAwait(false); - ASF.LoginSemaphore.Release(); - } - ); - } - } - - private async void OnConnected(SteamClient.ConnectedCallback callback) { - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } - - HeartBeatFailures = 0; - ReconnectOnUserInitiated = false; - StopConnectionFailureTimer(); - - ArchiLogger.LogGenericInfo(Strings.BotConnected); - - if (!KeepRunning) { - ArchiLogger.LogGenericInfo(Strings.BotDisconnecting); - Disconnect(); - - return; - } - - string sentryFilePath = GetFilePath(EFileType.SentryFile); - - if (string.IsNullOrEmpty(sentryFilePath)) { - ArchiLogger.LogNullError(nameof(sentryFilePath)); - - return; - } - - byte[]? sentryFileHash = null; - - if (File.Exists(sentryFilePath)) { - try { - byte[] sentryFileContent = await File.ReadAllBytesAsync(sentryFilePath).ConfigureAwait(false); - sentryFileHash = CryptoHelper.SHAHash(sentryFileContent); - } catch (Exception e) { - ArchiLogger.LogGenericException(e); - - try { - File.Delete(sentryFilePath); - } catch { - // Ignored, we can only try to delete faulted file at best - } - } - } - - string? loginKey = null; - - if (ShouldUseLoginKeys && string.IsNullOrEmpty(AuthCode) && string.IsNullOrEmpty(TwoFactorCode)) { - loginKey = BotDatabase.LoginKey; - - // Decrypt login key if needed - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (!string.IsNullOrEmpty(loginKey) && (loginKey!.Length > 19) && (BotConfig.PasswordFormat != ArchiCryptoHelper.ECryptoMethod.PlainText)) { - loginKey = ArchiCryptoHelper.Decrypt(BotConfig.PasswordFormat, loginKey); - } - } else { - // If we're not using login keys, ensure we don't have any saved - BotDatabase.LoginKey = null; - } - - if (!await InitLoginAndPassword(string.IsNullOrEmpty(loginKey)).ConfigureAwait(false)) { - Stop(); - - return; - } - - if (string.IsNullOrEmpty(BotConfig.SteamLogin)) { - throw new InvalidOperationException(nameof(BotConfig.SteamLogin)); - } - - // Steam login and password fields can contain ASCII characters only, including spaces - const string nonAsciiPattern = @"[^\u0000-\u007F]+"; - - string username = Regex.Replace(BotConfig.SteamLogin!, nonAsciiPattern, "", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); - - if (string.IsNullOrEmpty(username)) { - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(BotConfig.SteamLogin))); - - Stop(); - - return; - } - - string? password = BotConfig.DecryptedSteamPassword; - - if (!string.IsNullOrEmpty(password)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - password = Regex.Replace(password!, nonAsciiPattern, "", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); - - if (string.IsNullOrEmpty(password)) { - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(BotConfig.SteamPassword))); - - Stop(); - - return; - } - } - - ArchiLogger.LogGenericInfo(Strings.BotLoggingIn); - - if (string.IsNullOrEmpty(TwoFactorCode) && (BotDatabase.MobileAuthenticator != null)) { - // We should always include 2FA token, even if it's not required - TwoFactorCode = await BotDatabase.MobileAuthenticator.GenerateToken().ConfigureAwait(false); - } - - InitConnectionFailureTimer(); - - SteamUser.LogOnDetails logOnDetails = new() { - AuthCode = AuthCode, - CellID = ASF.GlobalDatabase?.CellID, - LoginID = LoginID, - LoginKey = loginKey, - Password = password, - SentryFileHash = sentryFileHash, - ShouldRememberPassword = ShouldUseLoginKeys, - TwoFactorCode = TwoFactorCode, - Username = username - }; - - if (OSType == EOSType.Unknown) { - OSType = logOnDetails.ClientOSType; - } - - SteamUser.LogOn(logOnDetails); - } - - private async void OnDisconnected(SteamClient.DisconnectedCallback callback) { - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } - - if (ASF.LoginRateLimitingSemaphore == null) { - throw new InvalidOperationException(nameof(ASF.LoginRateLimitingSemaphore)); - } - - EResult lastLogOnResult = LastLogOnResult; - LastLogOnResult = EResult.Invalid; - HeartBeatFailures = 0; - SteamParentalActive = true; - StopConnectionFailureTimer(); - StopPlayingWasBlockedTimer(); - - ArchiLogger.LogGenericInfo(Strings.BotDisconnected); - - PastNotifications.Clear(); - - Actions.OnDisconnected(); - ArchiWebHandler.OnDisconnected(); - CardsFarmer.OnDisconnected(); - Trading.OnDisconnected(); - - FirstTradeSent = false; - - await PluginsCore.OnBotDisconnected(this, callback.UserInitiated ? EResult.OK : lastLogOnResult).ConfigureAwait(false); - - // If we initiated disconnect, do not attempt to reconnect - if (callback.UserInitiated && !ReconnectOnUserInitiated) { - return; - } - - switch (lastLogOnResult) { - case EResult.AccountDisabled: - // Do not attempt to reconnect, those failures are permanent - return; - case EResult.InvalidPassword when !string.IsNullOrEmpty(BotDatabase.LoginKey): - BotDatabase.LoginKey = null; - ArchiLogger.LogGenericInfo(Strings.BotRemovedExpiredLoginKey); - - break; - case EResult.InvalidPassword: - case EResult.NoConnection: - case EResult.ServiceUnavailable: - case EResult.Timeout: - case EResult.TryAnotherCM: - case EResult.TwoFactorCodeMismatch: - await Task.Delay(5000).ConfigureAwait(false); - - break; - case EResult.RateLimitExceeded: - ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRateLimitExceeded, TimeSpan.FromMinutes(LoginCooldownInMinutes).ToHumanReadable())); - - if (!await ASF.LoginRateLimitingSemaphore.WaitAsync(1000 * WebBrowser.MaxTries).ConfigureAwait(false)) { - break; - } - - try { - await Task.Delay(LoginCooldownInMinutes * 60 * 1000).ConfigureAwait(false); - } finally { - ASF.LoginRateLimitingSemaphore.Release(); - } - - break; - } - - if (!KeepRunning || SteamClient.IsConnected) { - return; - } - - ArchiLogger.LogGenericInfo(Strings.BotReconnecting); - await Connect().ConfigureAwait(false); - } - - private async void OnFriendsList(SteamFriends.FriendsListCallback callback) { - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } - - if (callback.FriendList == null) { - throw new ArgumentNullException(nameof(callback)); - } - - foreach (SteamFriends.FriendsListCallback.Friend friend in callback.FriendList.Where(static friend => friend.Relationship == EFriendRelationship.RequestRecipient)) { - switch (friend.SteamID.AccountType) { - case EAccountType.Clan when IsMasterClanID(friend.SteamID): - ArchiLogger.LogInvite(friend.SteamID, true); - - ArchiHandler.AcknowledgeClanInvite(friend.SteamID, true); - await JoinMasterChatGroupID().ConfigureAwait(false); - - break; - case EAccountType.Clan: - bool acceptGroupRequest = await PluginsCore.OnBotFriendRequest(this, friend.SteamID).ConfigureAwait(false); - - if (acceptGroupRequest) { - ArchiLogger.LogInvite(friend.SteamID, true); - - ArchiHandler.AcknowledgeClanInvite(friend.SteamID, true); - await JoinMasterChatGroupID().ConfigureAwait(false); - - break; - } - - if (BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.RejectInvalidGroupInvites)) { - ArchiLogger.LogInvite(friend.SteamID, false); - - ArchiHandler.AcknowledgeClanInvite(friend.SteamID, false); - - break; - } - - ArchiLogger.LogInvite(friend.SteamID); - - break; - default: - if (HasAccess(friend.SteamID, BotConfig.EAccess.FamilySharing)) { - ArchiLogger.LogInvite(friend.SteamID, true); - - if (!await ArchiHandler.AddFriend(friend.SteamID).ConfigureAwait(false)) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiHandler.AddFriend))); - } - - break; - } - - bool acceptFriendRequest = await PluginsCore.OnBotFriendRequest(this, friend.SteamID).ConfigureAwait(false); - - if (acceptFriendRequest) { - ArchiLogger.LogInvite(friend.SteamID, true); - - if (!await ArchiHandler.AddFriend(friend.SteamID).ConfigureAwait(false)) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiHandler.AddFriend))); - } - - break; - } - - if (BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.RejectInvalidFriendInvites)) { - ArchiLogger.LogInvite(friend.SteamID, false); - - if (!await ArchiHandler.RemoveFriend(friend.SteamID).ConfigureAwait(false)) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiHandler.RemoveFriend))); - } - - break; - } - - ArchiLogger.LogInvite(friend.SteamID); - - break; - } - } - } - - private async void OnGuestPassList(SteamApps.GuestPassListCallback callback) { - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } - - if (callback.GuestPasses == null) { - throw new ArgumentNullException(nameof(callback)); - } - - if ((callback.CountGuestPassesToRedeem == 0) || (callback.GuestPasses.Count == 0) || !BotConfig.AcceptGifts) { - return; - } - - HashSet guestPassIDs = callback.GuestPasses.Select(static guestPass => guestPass["gid"].AsUnsignedLong()).Where(static gid => gid != 0).ToHashSet(); - - if (guestPassIDs.Count == 0) { - return; - } - - await Actions.AcceptGuestPasses(guestPassIDs).ConfigureAwait(false); - } - - private async Task OnIncomingChatMessage(CChatRoom_IncomingChatMessage_Notification notification) { - if (notification == null) { - throw new ArgumentNullException(nameof(notification)); - } - - if ((notification.chat_group_id == 0) || (notification.chat_id == 0) || (notification.steamid_sender == 0)) { - ArchiLogger.LogNullError($"{nameof(notification.chat_group_id)} || {nameof(notification.chat_id)} || {nameof(notification.steamid_sender)}"); - - return; - } - - // Under normal circumstances, timestamp must always be greater than 0, but Steam already proved that it's capable of going against the logic - if ((notification.steamid_sender != SteamID) && (notification.timestamp > 0)) { - if (ShouldAckChatMessage(notification.steamid_sender)) { - Utilities.InBackground(() => ArchiHandler.AckChatMessage(notification.chat_group_id, notification.chat_id, notification.timestamp)); - } - } - - string message; - - // Prefer to use message without bbcode, but only if it's available - if (!string.IsNullOrEmpty(notification.message_no_bbcode)) { - message = notification.message_no_bbcode; - } else if (!string.IsNullOrEmpty(notification.message)) { - message = SteamChatMessage.Unescape(notification.message); - } else { - return; - } - - ArchiLogger.LogChatMessage(false, message, notification.chat_group_id, notification.chat_id, notification.steamid_sender); - - // Steam network broadcasts chat events also when we don't explicitly sign into Steam community - // We'll explicitly ignore those messages when using offline mode, as it was done in the first version of Steam chat when no messages were broadcasted at all before signing in - // Handling messages will still work correctly in invisible mode, which is how it should work in the first place - // This goes in addition to usual logic that ignores irrelevant messages from being parsed further - if ((notification.chat_group_id != MasterChatGroupID) || (BotConfig.OnlineStatus == EPersonaState.Offline)) { - return; - } - - await Commands.HandleMessage(notification.chat_group_id, notification.chat_id, notification.steamid_sender, message).ConfigureAwait(false); - } - - private async Task OnIncomingMessage(CFriendMessages_IncomingMessage_Notification notification) { - if (notification == null) { - throw new ArgumentNullException(nameof(notification)); - } - - if (notification.steamid_friend == 0) { - ArchiLogger.LogNullError(nameof(notification.steamid_friend)); - - return; - } - - if ((EChatEntryType) notification.chat_entry_type != EChatEntryType.ChatMsg) { - return; - } - - // Under normal circumstances, timestamp must always be greater than 0, but Steam already proved that it's capable of going against the logic - if (!notification.local_echo && (notification.rtime32_server_timestamp > 0)) { - if (ShouldAckChatMessage(notification.steamid_friend)) { - Utilities.InBackground(() => ArchiHandler.AckMessage(notification.steamid_friend, notification.rtime32_server_timestamp)); - } - } - - string message; - - // Prefer to use message without bbcode, but only if it's available - if (!string.IsNullOrEmpty(notification.message_no_bbcode)) { - message = notification.message_no_bbcode; - } else if (!string.IsNullOrEmpty(notification.message)) { - message = SteamChatMessage.Unescape(notification.message); - } else { - return; - } - - ArchiLogger.LogChatMessage(notification.local_echo, message, steamID: notification.steamid_friend); - - // Steam network broadcasts chat events also when we don't explicitly sign into Steam community - // We'll explicitly ignore those messages when using offline mode, as it was done in the first version of Steam chat when no messages were broadcasted at all before signing in - // Handling messages will still work correctly in invisible mode, which is how it should work in the first place - // This goes in addition to usual logic that ignores irrelevant messages from being parsed further - if (notification.local_echo || (BotConfig.OnlineStatus == EPersonaState.Offline)) { - return; - } - - await Commands.HandleMessage(notification.steamid_friend, message).ConfigureAwait(false); - } - - private void OnInventoryChanged() { - Utilities.InBackground(CardsFarmer.OnNewItemsNotification); - - if (BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.DismissInventoryNotifications)) { - Utilities.InBackground(ArchiWebHandler.MarkInventory); - } - - if (BotConfig.CompleteTypesToSend.Count > 0) { - Utilities.InBackground(SendCompletedSets); - } - } - - private async void OnLicenseList(SteamApps.LicenseListCallback callback) { - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } - - if (callback.LicenseList == null) { - throw new ArgumentNullException(nameof(callback)); - } - - if (ASF.GlobalDatabase == null) { - throw new InvalidOperationException(nameof(ASF.GlobalDatabase)); - } - - if (callback.LicenseList.Count == 0) { - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(callback.LicenseList))); - - return; - } - - // Wait a short time for eventual LastChangeNumber initialization - for (byte i = 0; (i < WebBrowser.MaxTries) && !SteamPICSChanges.LiveUpdate; i++) { - await Task.Delay(1000).ConfigureAwait(false); - } - - Commands.OnNewLicenseList(); - - Dictionary ownedPackageIDs = new(); - - Dictionary packageAccessTokens = new(); - Dictionary packagesToRefresh = new(); - - foreach (SteamApps.LicenseListCallback.License license in callback.LicenseList.GroupBy(static license => license.PackageID, static (_, licenses) => licenses.OrderByDescending(static license => license.TimeCreated).First())) { - ownedPackageIDs[license.PackageID] = (license.PaymentMethod, license.TimeCreated); - - if (!ASF.GlobalDatabase.PackageAccessTokensReadOnly.TryGetValue(license.PackageID, out ulong packageAccessToken) || (packageAccessToken != license.AccessToken)) { - packageAccessTokens[license.PackageID] = license.AccessToken; - - // Package is always due to refresh with access token change - packagesToRefresh[license.PackageID] = (uint) license.LastChangeNumber; - } else if (!ASF.GlobalDatabase.PackagesDataReadOnly.TryGetValue(license.PackageID, out (uint ChangeNumber, ImmutableHashSet? AppIDs) packageData) || (packageData.ChangeNumber < license.LastChangeNumber)) { - packagesToRefresh[license.PackageID] = (uint) license.LastChangeNumber; - } - } - - OwnedPackageIDs = ownedPackageIDs.ToImmutableDictionary(); - - if (packageAccessTokens.Count > 0) { - ASF.GlobalDatabase.RefreshPackageAccessTokens(packageAccessTokens); - } - - if (packagesToRefresh.Count > 0) { - ArchiLogger.LogGenericTrace(Strings.BotRefreshingPackagesData); - await ASF.GlobalDatabase.RefreshPackages(this, packagesToRefresh).ConfigureAwait(false); - ArchiLogger.LogGenericTrace(Strings.Done); - } - - await CardsFarmer.OnNewGameAdded().ConfigureAwait(false); - } - - private void OnLoggedOff(SteamUser.LoggedOffCallback callback) { - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } - - LastLogOnResult = callback.Result; - - ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotLoggedOff, callback.Result)); - - switch (callback.Result) { - case EResult.LoggedInElsewhere: - // This result directly indicates that playing was blocked when we got (forcefully) disconnected - PlayingWasBlocked = true; - - break; - case EResult.LogonSessionReplaced: - DateTime now = DateTime.UtcNow; - - if (now.Subtract(LastLogonSessionReplaced).TotalHours < 1) { - ArchiLogger.LogGenericError(Strings.BotLogonSessionReplaced); - Stop(); - - return; - } - - LastLogonSessionReplaced = now; - - break; - } - - ReconnectOnUserInitiated = true; - SteamClient.Disconnect(); - } - - private async void OnLoggedOn(SteamUser.LoggedOnCallback callback) { - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } - - // Always reset one-time-only access tokens when we get OnLoggedOn() response - AuthCode = TwoFactorCode = null; - - // Keep LastLogOnResult for OnDisconnected() - LastLogOnResult = callback.Result; - - HeartBeatFailures = 0; - StopConnectionFailureTimer(); - - switch (callback.Result) { - case EResult.AccountDisabled: - // Those failures are permanent, we should Stop() the bot if any of those happen - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.BotUnableToLogin, callback.Result, callback.ExtendedResult)); - Stop(); - - break; - case EResult.AccountLogonDenied: - RequiredInput = ASF.EUserInputType.SteamGuard; - - string? authCode = await Logging.GetUserInput(ASF.EUserInputType.SteamGuard, BotName).ConfigureAwait(false); - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (string.IsNullOrEmpty(authCode) || !SetUserInput(ASF.EUserInputType.SteamGuard, authCode!)) { - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(authCode))); - - Stop(); - } - - break; - case EResult.AccountLoginDeniedNeedTwoFactor: - if (!HasMobileAuthenticator) { - RequiredInput = ASF.EUserInputType.TwoFactorAuthentication; - - string? twoFactorCode = await Logging.GetUserInput(ASF.EUserInputType.TwoFactorAuthentication, BotName).ConfigureAwait(false); - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (string.IsNullOrEmpty(twoFactorCode) || !SetUserInput(ASF.EUserInputType.TwoFactorAuthentication, twoFactorCode!)) { - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(twoFactorCode))); - - Stop(); - } - } - - break; - case EResult.OK: - AccountFlags = callback.AccountFlags; - SteamID = callback.ClientSteamID ?? throw new InvalidOperationException(nameof(callback.ClientSteamID)); - - ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotLoggedOn, SteamID + (!string.IsNullOrEmpty(callback.VanityURL) ? $"/{callback.VanityURL}" : ""))); - - // Old status for these doesn't matter, we'll update them if needed - InvalidPasswordFailures = TwoFactorCodeFailures = 0; - LibraryLocked = PlayingBlocked = false; - - if (PlayingWasBlocked && (PlayingWasBlockedTimer == null)) { - InitPlayingWasBlockedTimer(); - } - - if (IsAccountLimited) { - ArchiLogger.LogGenericWarning(Strings.BotAccountLimited); - } - - if (IsAccountLocked) { - ArchiLogger.LogGenericWarning(Strings.BotAccountLocked); - } - - if ((callback.CellID != 0) && (ASF.GlobalDatabase != null) && (callback.CellID != ASF.GlobalDatabase.CellID)) { - ASF.GlobalDatabase.CellID = callback.CellID; - } - - // Handle steamID-based maFile - if (!HasMobileAuthenticator) { - string maFilePath = Path.Combine(SharedInfo.ConfigDirectory, SteamID + SharedInfo.MobileAuthenticatorExtension); - - if (File.Exists(maFilePath)) { - await ImportAuthenticatorFromFile(maFilePath).ConfigureAwait(false); - } - } - - if (callback.ParentalSettings != null) { - (bool isSteamParentalEnabled, string? steamParentalCode) = ValidateSteamParental(callback.ParentalSettings, BotConfig.SteamParentalCode); - - if (isSteamParentalEnabled) { - SteamParentalActive = true; - - if (!string.IsNullOrEmpty(steamParentalCode)) { - if (BotConfig.SteamParentalCode != steamParentalCode) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (!SetUserInput(ASF.EUserInputType.SteamParentalCode, steamParentalCode!)) { - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(steamParentalCode))); - - Stop(); - - break; - } - } - } else if (string.IsNullOrEmpty(BotConfig.SteamParentalCode) || (BotConfig.SteamParentalCode!.Length != BotConfig.SteamParentalCodeLength)) { - RequiredInput = ASF.EUserInputType.SteamParentalCode; - - steamParentalCode = await Logging.GetUserInput(ASF.EUserInputType.SteamParentalCode, BotName).ConfigureAwait(false); - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (string.IsNullOrEmpty(steamParentalCode) || !SetUserInput(ASF.EUserInputType.SteamParentalCode, steamParentalCode!)) { - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(steamParentalCode))); - - Stop(); - - break; - } - } - } else { - SteamParentalActive = false; - } - } else if (SteamParentalActive && !string.IsNullOrEmpty(BotConfig.SteamParentalCode) && (BotConfig.SteamParentalCode!.Length != BotConfig.SteamParentalCodeLength)) { - RequiredInput = ASF.EUserInputType.SteamParentalCode; - - string? steamParentalCode = await Logging.GetUserInput(ASF.EUserInputType.SteamParentalCode, BotName).ConfigureAwait(false); - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (string.IsNullOrEmpty(steamParentalCode) || !SetUserInput(ASF.EUserInputType.SteamParentalCode, steamParentalCode!)) { - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(steamParentalCode))); - - Stop(); - - break; - } - } - - ArchiWebHandler.OnVanityURLChanged(callback.VanityURL); - - if (!await ArchiWebHandler.Init(SteamID, SteamClient.Universe, callback.WebAPIUserNonce ?? throw new InvalidOperationException(nameof(callback.WebAPIUserNonce)), SteamParentalActive ? BotConfig.SteamParentalCode : null).ConfigureAwait(false)) { - if (!await RefreshSession().ConfigureAwait(false)) { - break; - } - } - - // Pre-fetch API key for future usage if possible - Utilities.InBackground(ArchiWebHandler.HasValidApiKey); - - if ((GamesRedeemerInBackgroundTimer == null) && BotDatabase.HasGamesToRedeemInBackground) { - Utilities.InBackground(() => RedeemGamesInBackground()); - } - - ArchiHandler.SetCurrentMode(BotConfig.UserInterfaceMode); - ArchiHandler.RequestItemAnnouncements(); - - // Sometimes Steam won't send us our own PersonaStateCallback, so request it explicitly - RequestPersonaStateUpdate(); - - Utilities.InBackground(InitializeFamilySharing); - - if (Statistics != null) { - Utilities.InBackground(Statistics.OnLoggedOn); - } - - if (BotConfig.OnlineStatus != EPersonaState.Offline) { - SteamFriends.SetPersonaState(BotConfig.OnlineStatus); - } - - if (BotConfig.SteamMasterClanID != 0) { - Utilities.InBackground( - async () => { - if (!await ArchiWebHandler.JoinGroup(BotConfig.SteamMasterClanID).ConfigureAwait(false)) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiWebHandler.JoinGroup))); - } - - await JoinMasterChatGroupID().ConfigureAwait(false); - } - ); - } - - if (CardsFarmer.Paused) { - // Emit initial game playing status in this case - Utilities.InBackground(ResetGamesPlayed); - } - - SteamPICSChanges.OnBotLoggedOn(); - - await PluginsCore.OnBotLoggedOn(this).ConfigureAwait(false); - - break; - case EResult.InvalidPassword: - case EResult.NoConnection: - case EResult.PasswordRequiredToKickSession: // Not sure about this one, it seems to be just generic "try again"? #694 - case EResult.RateLimitExceeded: - case EResult.ServiceUnavailable: - case EResult.Timeout: - case EResult.TryAnotherCM: - case EResult.TwoFactorCodeMismatch: - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.BotUnableToLogin, callback.Result, callback.ExtendedResult)); - - switch (callback.Result) { - case EResult.InvalidPassword when string.IsNullOrEmpty(BotDatabase.LoginKey) && (++InvalidPasswordFailures >= MaxInvalidPasswordFailures): - InvalidPasswordFailures = 0; - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.BotInvalidPasswordDuringLogin, MaxInvalidPasswordFailures)); - Stop(); - - break; - case EResult.TwoFactorCodeMismatch when HasMobileAuthenticator: - // There is a possibility that our cached time is no longer appropriate, so we should reset the cache in this case in order to fetch it upon the next login attempt - // Yes, this might as well be just invalid 2FA credentials, but we can't be sure about that, and we have MaxTwoFactorCodeFailures designed to verify that for us - await MobileAuthenticator.ResetSteamTimeDifference().ConfigureAwait(false); - - if (++TwoFactorCodeFailures >= MaxTwoFactorCodeFailures) { - TwoFactorCodeFailures = 0; - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.BotInvalidAuthenticatorDuringLogin, MaxTwoFactorCodeFailures)); - Stop(); - } - - break; - } - - break; - default: - // Unexpected result, shutdown immediately - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(callback.Result), callback.Result)); - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.BotUnableToLogin, callback.Result, callback.ExtendedResult)); - Stop(); - - break; - } - } - - private void OnLoginKey(SteamUser.LoginKeyCallback callback) { - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } - - if (string.IsNullOrEmpty(callback.LoginKey)) { - throw new ArgumentNullException(nameof(callback)); - } - - if (!ShouldUseLoginKeys) { - return; - } - - string? loginKey = callback.LoginKey; - - if (BotConfig.PasswordFormat != ArchiCryptoHelper.ECryptoMethod.PlainText) { - loginKey = ArchiCryptoHelper.Encrypt(BotConfig.PasswordFormat, loginKey); - } - - BotDatabase.LoginKey = loginKey; - SteamUser.AcceptNewLoginKey(callback); - } - - private async void OnMachineAuth(SteamUser.UpdateMachineAuthCallback callback) { - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } - - string sentryFilePath = GetFilePath(EFileType.SentryFile); - - if (string.IsNullOrEmpty(sentryFilePath)) { - ArchiLogger.LogNullError(nameof(sentryFilePath)); - - return; - } - - long fileSize; - byte[] sentryHash; - - try { -#pragma warning disable CA2000 // False positive, we're actually wrapping it in the using clause below exactly for that purpose - FileStream fileStream = File.Open(sentryFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite); -#pragma warning restore CA2000 // False positive, we're actually wrapping it in the using clause below exactly for that purpose - - await using (fileStream.ConfigureAwait(false)) { - fileStream.Seek(callback.Offset, SeekOrigin.Begin); - - await fileStream.WriteAsync(callback.Data.AsMemory(0, callback.BytesToWrite)).ConfigureAwait(false); - - fileSize = fileStream.Length; - fileStream.Seek(0, SeekOrigin.Begin); - -#pragma warning disable CA5350 // This is actually a fair warning, but there is nothing we can do about Steam using weak cryptographic algorithms - using SHA1 hashingAlgorithm = SHA1.Create(); - - sentryHash = await hashingAlgorithm.ComputeHashAsync(fileStream).ConfigureAwait(false); -#pragma warning restore CA5350 // This is actually a fair warning, but there is nothing we can do about Steam using weak cryptographic algorithms - } + byte[] sentryFileContent = await File.ReadAllBytesAsync(sentryFilePath).ConfigureAwait(false); + sentryFileHash = CryptoHelper.SHAHash(sentryFileContent); } catch (Exception e) { ArchiLogger.LogGenericException(e); @@ -2981,555 +2232,1304 @@ namespace ArchiSteamFarm.Steam { } catch { // Ignored, we can only try to delete faulted file at best } + } + } + + string? loginKey = null; + + if (ShouldUseLoginKeys && string.IsNullOrEmpty(AuthCode) && string.IsNullOrEmpty(TwoFactorCode)) { + loginKey = BotDatabase.LoginKey; + + // Decrypt login key if needed + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (!string.IsNullOrEmpty(loginKey) && (loginKey!.Length > 19) && (BotConfig.PasswordFormat != ArchiCryptoHelper.ECryptoMethod.PlainText)) { + loginKey = ArchiCryptoHelper.Decrypt(BotConfig.PasswordFormat, loginKey); + } + } else { + // If we're not using login keys, ensure we don't have any saved + BotDatabase.LoginKey = null; + } + + if (!await InitLoginAndPassword(string.IsNullOrEmpty(loginKey)).ConfigureAwait(false)) { + Stop(); + + return; + } + + if (string.IsNullOrEmpty(BotConfig.SteamLogin)) { + throw new InvalidOperationException(nameof(BotConfig.SteamLogin)); + } + + // Steam login and password fields can contain ASCII characters only, including spaces + const string nonAsciiPattern = @"[^\u0000-\u007F]+"; + + string username = Regex.Replace(BotConfig.SteamLogin!, nonAsciiPattern, "", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + if (string.IsNullOrEmpty(username)) { + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(BotConfig.SteamLogin))); + + Stop(); + + return; + } + + string? password = BotConfig.DecryptedSteamPassword; + + if (!string.IsNullOrEmpty(password)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + password = Regex.Replace(password!, nonAsciiPattern, "", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + if (string.IsNullOrEmpty(password)) { + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(BotConfig.SteamPassword))); + + Stop(); return; } - - // Inform the steam servers that we're accepting this sentry file - SteamUser.SendMachineAuthResponse( - new SteamUser.MachineAuthDetails { - BytesWritten = callback.BytesToWrite, - FileName = callback.FileName, - FileSize = (int) fileSize, - JobID = callback.JobID, - LastError = 0, - Offset = callback.Offset, - OneTimePassword = callback.OneTimePassword, - Result = EResult.OK, - SentryFileHash = sentryHash - } - ); } - private void OnPersonaState(SteamFriends.PersonaStateCallback callback) { - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } + ArchiLogger.LogGenericInfo(Strings.BotLoggingIn); - if (callback.FriendID != SteamID) { + if (string.IsNullOrEmpty(TwoFactorCode) && (BotDatabase.MobileAuthenticator != null)) { + // We should always include 2FA token, even if it's not required + TwoFactorCode = await BotDatabase.MobileAuthenticator.GenerateToken().ConfigureAwait(false); + } + + InitConnectionFailureTimer(); + + SteamUser.LogOnDetails logOnDetails = new() { + AuthCode = AuthCode, + CellID = ASF.GlobalDatabase?.CellID, + LoginID = LoginID, + LoginKey = loginKey, + Password = password, + SentryFileHash = sentryFileHash, + ShouldRememberPassword = ShouldUseLoginKeys, + TwoFactorCode = TwoFactorCode, + Username = username + }; + + if (OSType == EOSType.Unknown) { + OSType = logOnDetails.ClientOSType; + } + + SteamUser.LogOn(logOnDetails); + } + + private async void OnDisconnected(SteamClient.DisconnectedCallback callback) { + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); + } + + if (ASF.LoginRateLimitingSemaphore == null) { + throw new InvalidOperationException(nameof(ASF.LoginRateLimitingSemaphore)); + } + + EResult lastLogOnResult = LastLogOnResult; + LastLogOnResult = EResult.Invalid; + HeartBeatFailures = 0; + SteamParentalActive = true; + StopConnectionFailureTimer(); + StopPlayingWasBlockedTimer(); + + ArchiLogger.LogGenericInfo(Strings.BotDisconnected); + + PastNotifications.Clear(); + + Actions.OnDisconnected(); + ArchiWebHandler.OnDisconnected(); + CardsFarmer.OnDisconnected(); + Trading.OnDisconnected(); + + FirstTradeSent = false; + + await PluginsCore.OnBotDisconnected(this, callback.UserInitiated ? EResult.OK : lastLogOnResult).ConfigureAwait(false); + + // If we initiated disconnect, do not attempt to reconnect + if (callback.UserInitiated && !ReconnectOnUserInitiated) { + return; + } + + switch (lastLogOnResult) { + case EResult.AccountDisabled: + // Do not attempt to reconnect, those failures are permanent return; - } + case EResult.InvalidPassword when !string.IsNullOrEmpty(BotDatabase.LoginKey): + BotDatabase.LoginKey = null; + ArchiLogger.LogGenericInfo(Strings.BotRemovedExpiredLoginKey); - string? avatarHash = null; + break; + case EResult.InvalidPassword: + case EResult.NoConnection: + case EResult.ServiceUnavailable: + case EResult.Timeout: + case EResult.TryAnotherCM: + case EResult.TwoFactorCodeMismatch: + await Task.Delay(5000).ConfigureAwait(false); - if ((callback.AvatarHash.Length > 0) && callback.AvatarHash.Any(static singleByte => singleByte != 0)) { -#pragma warning disable CA1308 // False positive, we're intentionally converting this part to lowercase and it's not used for any security decisions based on the result of the normalization - avatarHash = BitConverter.ToString(callback.AvatarHash).Replace("-", "", StringComparison.Ordinal).ToLowerInvariant(); -#pragma warning restore CA1308 // False positive, we're intentionally converting this part to lowercase and it's not used for any security decisions based on the result of the normalization + break; + case EResult.RateLimitExceeded: + ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRateLimitExceeded, TimeSpan.FromMinutes(LoginCooldownInMinutes).ToHumanReadable())); - if (string.IsNullOrEmpty(avatarHash) || avatarHash.All(static singleChar => singleChar == '0')) { - avatarHash = null; + if (!await ASF.LoginRateLimitingSemaphore.WaitAsync(1000 * WebBrowser.MaxTries).ConfigureAwait(false)) { + break; } - } - AvatarHash = avatarHash; - Nickname = callback.Name; + try { + await Task.Delay(LoginCooldownInMinutes * 60 * 1000).ConfigureAwait(false); + } finally { + ASF.LoginRateLimitingSemaphore.Release(); + } - if (Statistics != null) { - Utilities.InBackground(() => Statistics.OnPersonaState(callback.Name, avatarHash)); - } + break; } - private async void OnPlayingSessionState(ArchiHandler.PlayingSessionStateCallback callback) { - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } - - if (callback.PlayingBlocked == PlayingBlocked) { - return; // No status update, we're not interested - } - - PlayingBlocked = callback.PlayingBlocked; - await CheckOccupationStatus().ConfigureAwait(false); + if (!KeepRunning || SteamClient.IsConnected) { + return; } - private async void OnSendItemsTimer(object? state = null) => await Actions.SendInventory(filterFunction: item => BotConfig.LootableTypes.Contains(item.Type)).ConfigureAwait(false); + ArchiLogger.LogGenericInfo(Strings.BotReconnecting); + await Connect().ConfigureAwait(false); + } - private async void OnServiceMethod(SteamUnifiedMessages.ServiceMethodNotification notification) { - if (notification == null) { - throw new ArgumentNullException(nameof(notification)); - } + private async void OnFriendsList(SteamFriends.FriendsListCallback callback) { + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); + } - switch (notification.MethodName) { - case "ChatRoomClient.NotifyIncomingChatMessage#1": - await OnIncomingChatMessage((CChatRoom_IncomingChatMessage_Notification) notification.Body).ConfigureAwait(false); + if (callback.FriendList == null) { + throw new ArgumentNullException(nameof(callback)); + } + + foreach (SteamFriends.FriendsListCallback.Friend friend in callback.FriendList.Where(static friend => friend.Relationship == EFriendRelationship.RequestRecipient)) { + switch (friend.SteamID.AccountType) { + case EAccountType.Clan when IsMasterClanID(friend.SteamID): + ArchiLogger.LogInvite(friend.SteamID, true); + + ArchiHandler.AcknowledgeClanInvite(friend.SteamID, true); + await JoinMasterChatGroupID().ConfigureAwait(false); break; - case "FriendMessagesClient.IncomingMessage#1": - await OnIncomingMessage((CFriendMessages_IncomingMessage_Notification) notification.Body).ConfigureAwait(false); + case EAccountType.Clan: + bool acceptGroupRequest = await PluginsCore.OnBotFriendRequest(this, friend.SteamID).ConfigureAwait(false); - break; - } - } + if (acceptGroupRequest) { + ArchiLogger.LogInvite(friend.SteamID, true); - private async void OnSharedLibraryLockStatus(ArchiHandler.SharedLibraryLockStatusCallback callback) { - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } - - // Ignore no status updates - if (LibraryLocked) { - if ((callback.LibraryLockedBySteamID != 0) && (callback.LibraryLockedBySteamID != SteamID)) { - return; - } - - LibraryLocked = false; - } else { - if ((callback.LibraryLockedBySteamID == 0) || (callback.LibraryLockedBySteamID == SteamID)) { - return; - } - - LibraryLocked = true; - } - - await CheckOccupationStatus().ConfigureAwait(false); - } - - private void OnUserNotifications(UserNotificationsCallback callback) { - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } - - if (callback.Notifications == null) { - throw new ArgumentNullException(nameof(callback)); - } - - if (callback.Notifications.Count == 0) { - return; - } - - HashSet newPluginNotifications = new(); - - foreach ((UserNotificationsCallback.EUserNotification notification, uint count) in callback.Notifications) { - bool newNotification; - - if (count > 0) { - newNotification = !PastNotifications.TryGetValue(notification, out uint previousCount) || (count > previousCount); - PastNotifications[notification] = count; - - if (newNotification) { - newPluginNotifications.Add(notification); - } - } else { - newNotification = false; - PastNotifications.TryRemove(notification, out _); - } - - ArchiLogger.LogGenericTrace($"{notification} = {count}"); - - switch (notification) { - case UserNotificationsCallback.EUserNotification.Gifts when newNotification && BotConfig.AcceptGifts: - Utilities.InBackground(Actions.AcceptDigitalGiftCards); - - break; - case UserNotificationsCallback.EUserNotification.Items when newNotification: - OnInventoryChanged(); - - break; - case UserNotificationsCallback.EUserNotification.Trading when newNotification: - Utilities.InBackground(Trading.OnNewTrade); - - break; - } - } - - if (newPluginNotifications.Count > 0) { - Utilities.InBackground(() => PluginsCore.OnBotUserNotifications(this, newPluginNotifications)); - } - } - - private void OnVanityURLChangedCallback(ArchiHandler.VanityURLChangedCallback callback) { - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } - - ArchiWebHandler.OnVanityURLChanged(callback.VanityURL); - } - - private void OnWalletUpdate(SteamUser.WalletInfoCallback callback) { - if (callback == null) { - throw new ArgumentNullException(nameof(callback)); - } - - WalletBalance = callback.LongBalance; - WalletCurrency = callback.Currency; - } - - private async void RedeemGamesInBackground(object? state = null) { - if (!await GamesRedeemerInBackgroundSemaphore.WaitAsync(0).ConfigureAwait(false)) { - return; - } - - try { - if (GamesRedeemerInBackgroundTimer != null) { - await GamesRedeemerInBackgroundTimer.DisposeAsync().ConfigureAwait(false); - - GamesRedeemerInBackgroundTimer = null; - } - - ArchiLogger.LogGenericInfo(Strings.Starting); - - bool assumeWalletKeyOnBadActivationCode = BotConfig.RedeemingPreferences.HasFlag(BotConfig.ERedeemingPreferences.AssumeWalletKeyOnBadActivationCode); - - while (IsConnectedAndLoggedOn && BotDatabase.HasGamesToRedeemInBackground) { - (string? key, string? name) = BotDatabase.GetGameToRedeemInBackground(); - - if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(name)) { - ArchiLogger.LogNullError($"{nameof(key)} || {nameof(name)}"); + ArchiHandler.AcknowledgeClanInvite(friend.SteamID, true); + await JoinMasterChatGroupID().ConfigureAwait(false); break; } - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - PurchaseResponseCallback? result = await Actions.RedeemKey(key!).ConfigureAwait(false); + if (BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.RejectInvalidGroupInvites)) { + ArchiLogger.LogInvite(friend.SteamID, false); - if (result == null) { - continue; - } + ArchiHandler.AcknowledgeClanInvite(friend.SteamID, false); - if (((result.PurchaseResultDetail == EPurchaseResultDetail.CannotRedeemCodeFromClient) || ((result.PurchaseResultDetail == EPurchaseResultDetail.BadActivationCode) && assumeWalletKeyOnBadActivationCode)) && (WalletCurrency != ECurrencyCode.Invalid)) { - // If it's a wallet code, we try to redeem it first, then handle the inner result as our primary one - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - (EResult Result, EPurchaseResultDetail? PurchaseResult)? walletResult = await ArchiWebHandler.RedeemWalletKey(key!).ConfigureAwait(false); - - if (walletResult != null) { - result.Result = walletResult.Value.Result; - result.PurchaseResultDetail = walletResult.Value.PurchaseResult.GetValueOrDefault(walletResult.Value.Result == EResult.OK ? EPurchaseResultDetail.NoDetail : EPurchaseResultDetail.BadActivationCode); // BadActivationCode is our smart guess in this case - } else { - result.Result = EResult.Timeout; - result.PurchaseResultDetail = EPurchaseResultDetail.Timeout; - } - } - - ArchiLogger.LogGenericDebug(result.Items?.Count > 0 ? string.Format(CultureInfo.CurrentCulture, Strings.BotRedeemWithItems, key, $"{result.Result}/{result.PurchaseResultDetail}", string.Join(", ", result.Items)) : string.Format(CultureInfo.CurrentCulture, Strings.BotRedeem, key, $"{result.Result}/{result.PurchaseResultDetail}")); - - bool rateLimited = false; - bool redeemed = false; - - switch (result.PurchaseResultDetail) { - case EPurchaseResultDetail.AccountLocked: - case EPurchaseResultDetail.AlreadyPurchased: - case EPurchaseResultDetail.CannotRedeemCodeFromClient: - case EPurchaseResultDetail.DoesNotOwnRequiredApp: - case EPurchaseResultDetail.RestrictedCountry: - case EPurchaseResultDetail.Timeout: - break; - case EPurchaseResultDetail.BadActivationCode: - case EPurchaseResultDetail.DuplicateActivationCode: - case EPurchaseResultDetail.NoDetail: // OK - redeemed = true; - - break; - case EPurchaseResultDetail.RateLimited: - rateLimited = true; - - break; - default: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(result.PurchaseResultDetail), result.PurchaseResultDetail)); - - break; - } - - if (rateLimited) { break; } - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - BotDatabase.RemoveGameToRedeemInBackground(key!); - - // If user omitted the name or intentionally provided the same name as key, replace it with the Steam result - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (name!.Equals(key, StringComparison.OrdinalIgnoreCase) && (result.Items?.Count > 0)) { - name = string.Join(", ", result.Items.Values); - } - - string logEntry = $"{name}{DefaultBackgroundKeysRedeemerSeparator}[{result.PurchaseResultDetail}]{(result.Items?.Count > 0 ? DefaultBackgroundKeysRedeemerSeparator + string.Join(", ", result.Items) : "")}{DefaultBackgroundKeysRedeemerSeparator}{key}"; - - string filePath = GetFilePath(redeemed ? EFileType.KeysToRedeemUsed : EFileType.KeysToRedeemUnused); - - if (string.IsNullOrEmpty(filePath)) { - ArchiLogger.LogNullError(nameof(filePath)); - - return; - } - - try { - await File.AppendAllTextAsync(filePath, logEntry + Environment.NewLine).ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericException(e); - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.Content, logEntry)); - - break; - } - } - - if (IsConnectedAndLoggedOn && BotDatabase.HasGamesToRedeemInBackground) { - ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRateLimitExceeded, TimeSpan.FromHours(RedeemCooldownInHours).ToHumanReadable())); - - GamesRedeemerInBackgroundTimer = new Timer( - RedeemGamesInBackground, - null, - TimeSpan.FromHours(RedeemCooldownInHours), // Delay - Timeout.InfiniteTimeSpan // Period - ); - } - - ArchiLogger.LogGenericInfo(Strings.Done); - } finally { - GamesRedeemerInBackgroundSemaphore.Release(); - } - } - - private async Task ResetGamesPlayed() { - if (CardsFarmer.NowFarming) { - return; - } - - if (BotConfig.GamesPlayedWhileIdle.Count > 0) { - if (!IsPlayingPossible) { - return; - } - - // This function might be executed before PlayingSessionStateCallback/SharedLibraryLockStatusCallback, ensure proper delay in this case - await Task.Delay(2000).ConfigureAwait(false); - - if (CardsFarmer.NowFarming || !IsPlayingPossible) { - return; - } - - ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotIdlingSelectedGames, nameof(BotConfig.GamesPlayedWhileIdle), string.Join(", ", BotConfig.GamesPlayedWhileIdle))); - } - - await ArchiHandler.PlayGames(BotConfig.GamesPlayedWhileIdle, BotConfig.CustomGamePlayedWhileIdle).ConfigureAwait(false); - } - - private void ResetPlayingWasBlockedWithTimer(object? state = null) { - PlayingWasBlocked = false; - StopPlayingWasBlockedTimer(); - } - - private async Task SendCompletedSets() { - // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that - lock (SendCompleteTypesSemaphore) { - if (SendCompleteTypesScheduled) { - return; - } - - SendCompleteTypesScheduled = true; - } - - await SendCompleteTypesSemaphore.WaitAsync().ConfigureAwait(false); - - try { - using (await Actions.GetTradingLock().ConfigureAwait(false)) { - // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that - lock (SendCompleteTypesSemaphore) { - SendCompleteTypesScheduled = false; - } - - HashSet? appIDs = await GetPossiblyCompletedBadgeAppIDs().ConfigureAwait(false); - - if ((appIDs == null) || (appIDs.Count == 0)) { - return; - } - - HashSet inventory; - - try { - inventory = await ArchiWebHandler.GetInventoryAsync() - .Where(item => item.Tradable && appIDs.Contains(item.RealAppID) && BotConfig.CompleteTypesToSend.Contains(item.Type)) - .ToHashSetAsync() - .ConfigureAwait(false); - } catch (HttpRequestException e) { - ArchiLogger.LogGenericWarningException(e); - - return; - } catch (Exception e) { - ArchiLogger.LogGenericException(e); - - return; - } - - if (inventory.Count == 0) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(inventory))); - - return; - } - - Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), List> inventorySets = Trading.GetInventorySets(inventory); - appIDs.IntersectWith(inventorySets.Where(static kv => kv.Value.Count >= MinCardsPerBadge).Select(static kv => kv.Key.RealAppID)); - - if (appIDs.Count == 0) { - return; - } - - Dictionary? cardCountPerAppID = await LoadCardsPerSet(appIDs).ConfigureAwait(false); - - if ((cardCountPerAppID == null) || (cardCountPerAppID.Count == 0)) { - return; - } - - Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), (uint Sets, byte CardsPerSet)> itemsToTakePerInventorySet = inventorySets.Where(kv => appIDs.Contains(kv.Key.RealAppID)).ToDictionary(static kv => kv.Key, kv => (kv.Value[0], cardCountPerAppID[kv.Key.RealAppID])); - - if (itemsToTakePerInventorySet.Values.All(static value => value.Sets == 0)) { - return; - } - - HashSet result = GetItemsForFullSets(inventory, itemsToTakePerInventorySet); - - if (result.Count > 0) { - await Actions.SendInventory(result).ConfigureAwait(false); - } - } - } finally { - SendCompleteTypesSemaphore.Release(); - } - } - - private async Task SendMessagePart(ulong steamID, string messagePart, ulong chatGroupID = 0) { - if ((steamID == 0) || ((chatGroupID == 0) && !new SteamID(steamID).IsIndividualAccount)) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(messagePart)) { - throw new ArgumentNullException(nameof(messagePart)); - } - - if (!IsConnectedAndLoggedOn) { - return false; - } - - await MessagingSemaphore.WaitAsync().ConfigureAwait(false); - - try { - for (byte i = 0; (i < WebBrowser.MaxTries) && IsConnectedAndLoggedOn; i++) { - EResult result; - - if (chatGroupID == 0) { - result = await ArchiHandler.SendMessage(steamID, messagePart).ConfigureAwait(false); - } else { - result = await ArchiHandler.SendMessage(chatGroupID, steamID, messagePart).ConfigureAwait(false); - } - - switch (result) { - case EResult.Busy: - case EResult.Fail: - case EResult.LimitExceeded: - case EResult.RateLimitExceeded: - case EResult.ServiceUnavailable: - case EResult.Timeout: - await Task.Delay(5000).ConfigureAwait(false); - - continue; - case EResult.OK: - return true; - default: - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(result), result)); - - return false; - } - } - - return false; - } finally { - MessagingSemaphore.Release(); - } - } - - private bool ShouldAckChatMessage(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (Bots == null) { - throw new InvalidOperationException(nameof(Bots)); - } - - if (BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.MarkReceivedMessagesAsRead)) { - return true; - } - - return BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.MarkBotMessagesAsRead) && Bots.Values.Any(bot => bot.SteamID == steamID); - } - - private void StopConnectionFailureTimer() { - if (ConnectionFailureTimer == null) { - return; - } - - ConnectionFailureTimer.Dispose(); - ConnectionFailureTimer = null; - } - - private void StopPlayingWasBlockedTimer() { - if (PlayingWasBlockedTimer == null) { - return; - } - - PlayingWasBlockedTimer.Dispose(); - PlayingWasBlockedTimer = null; - } - - private (bool IsSteamParentalEnabled, string? SteamParentalCode) ValidateSteamParental(ParentalSettings settings, string? steamParentalCode = null) { - if (settings == null) { - throw new ArgumentNullException(nameof(settings)); - } - - if (!settings.is_enabled) { - return (false, null); - } - - ArchiCryptoHelper.EHashingMethod steamParentalHashingMethod; - - switch (settings.passwordhashtype) { - case 4: - steamParentalHashingMethod = ArchiCryptoHelper.EHashingMethod.Pbkdf2; - - break; - case 6: - steamParentalHashingMethod = ArchiCryptoHelper.EHashingMethod.SCrypt; + ArchiLogger.LogInvite(friend.SteamID); break; default: - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(settings.passwordhashtype), settings.passwordhashtype)); + if (HasAccess(friend.SteamID, BotConfig.EAccess.FamilySharing)) { + ArchiLogger.LogInvite(friend.SteamID, true); - return (true, null); - } + if (!await ArchiHandler.AddFriend(friend.SteamID).ConfigureAwait(false)) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiHandler.AddFriend))); + } - if (steamParentalCode?.Length == BotConfig.SteamParentalCodeLength) { - byte i = 0; - byte[] password = new byte[steamParentalCode.Length]; - - foreach (char character in steamParentalCode.TakeWhile(static character => character is >= '0' and <= '9')) { - password[i++] = (byte) character; - } - - if (i >= steamParentalCode.Length) { - byte[] passwordHash = ArchiCryptoHelper.Hash(password, settings.salt, (byte) settings.passwordhash.Length, steamParentalHashingMethod); - - if (passwordHash.SequenceEqual(settings.passwordhash)) { - return (true, steamParentalCode); + break; } - } + + bool acceptFriendRequest = await PluginsCore.OnBotFriendRequest(this, friend.SteamID).ConfigureAwait(false); + + if (acceptFriendRequest) { + ArchiLogger.LogInvite(friend.SteamID, true); + + if (!await ArchiHandler.AddFriend(friend.SteamID).ConfigureAwait(false)) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiHandler.AddFriend))); + } + + break; + } + + if (BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.RejectInvalidFriendInvites)) { + ArchiLogger.LogInvite(friend.SteamID, false); + + if (!await ArchiHandler.RemoveFriend(friend.SteamID).ConfigureAwait(false)) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiHandler.RemoveFriend))); + } + + break; + } + + ArchiLogger.LogInvite(friend.SteamID); + + break; } - - ArchiLogger.LogGenericInfo(Strings.BotGeneratingSteamParentalCode); - - steamParentalCode = ArchiCryptoHelper.RecoverSteamParentalCode(settings.passwordhash, settings.salt, steamParentalHashingMethod); - - ArchiLogger.LogGenericInfo(Strings.Done); - - return (true, steamParentalCode); - } - - public enum EFileType : byte { - Config, - Database, - KeysToRedeem, - KeysToRedeemUnused, - KeysToRedeemUsed, - MobileAuthenticator, - SentryFile } } + + private async void OnGuestPassList(SteamApps.GuestPassListCallback callback) { + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); + } + + if (callback.GuestPasses == null) { + throw new ArgumentNullException(nameof(callback)); + } + + if ((callback.CountGuestPassesToRedeem == 0) || (callback.GuestPasses.Count == 0) || !BotConfig.AcceptGifts) { + return; + } + + HashSet guestPassIDs = callback.GuestPasses.Select(static guestPass => guestPass["gid"].AsUnsignedLong()).Where(static gid => gid != 0).ToHashSet(); + + if (guestPassIDs.Count == 0) { + return; + } + + await Actions.AcceptGuestPasses(guestPassIDs).ConfigureAwait(false); + } + + private async Task OnIncomingChatMessage(CChatRoom_IncomingChatMessage_Notification notification) { + if (notification == null) { + throw new ArgumentNullException(nameof(notification)); + } + + if ((notification.chat_group_id == 0) || (notification.chat_id == 0) || (notification.steamid_sender == 0)) { + ArchiLogger.LogNullError($"{nameof(notification.chat_group_id)} || {nameof(notification.chat_id)} || {nameof(notification.steamid_sender)}"); + + return; + } + + // Under normal circumstances, timestamp must always be greater than 0, but Steam already proved that it's capable of going against the logic + if ((notification.steamid_sender != SteamID) && (notification.timestamp > 0)) { + if (ShouldAckChatMessage(notification.steamid_sender)) { + Utilities.InBackground(() => ArchiHandler.AckChatMessage(notification.chat_group_id, notification.chat_id, notification.timestamp)); + } + } + + string message; + + // Prefer to use message without bbcode, but only if it's available + if (!string.IsNullOrEmpty(notification.message_no_bbcode)) { + message = notification.message_no_bbcode; + } else if (!string.IsNullOrEmpty(notification.message)) { + message = SteamChatMessage.Unescape(notification.message); + } else { + return; + } + + ArchiLogger.LogChatMessage(false, message, notification.chat_group_id, notification.chat_id, notification.steamid_sender); + + // Steam network broadcasts chat events also when we don't explicitly sign into Steam community + // We'll explicitly ignore those messages when using offline mode, as it was done in the first version of Steam chat when no messages were broadcasted at all before signing in + // Handling messages will still work correctly in invisible mode, which is how it should work in the first place + // This goes in addition to usual logic that ignores irrelevant messages from being parsed further + if ((notification.chat_group_id != MasterChatGroupID) || (BotConfig.OnlineStatus == EPersonaState.Offline)) { + return; + } + + await Commands.HandleMessage(notification.chat_group_id, notification.chat_id, notification.steamid_sender, message).ConfigureAwait(false); + } + + private async Task OnIncomingMessage(CFriendMessages_IncomingMessage_Notification notification) { + if (notification == null) { + throw new ArgumentNullException(nameof(notification)); + } + + if (notification.steamid_friend == 0) { + ArchiLogger.LogNullError(nameof(notification.steamid_friend)); + + return; + } + + if ((EChatEntryType) notification.chat_entry_type != EChatEntryType.ChatMsg) { + return; + } + + // Under normal circumstances, timestamp must always be greater than 0, but Steam already proved that it's capable of going against the logic + if (!notification.local_echo && (notification.rtime32_server_timestamp > 0)) { + if (ShouldAckChatMessage(notification.steamid_friend)) { + Utilities.InBackground(() => ArchiHandler.AckMessage(notification.steamid_friend, notification.rtime32_server_timestamp)); + } + } + + string message; + + // Prefer to use message without bbcode, but only if it's available + if (!string.IsNullOrEmpty(notification.message_no_bbcode)) { + message = notification.message_no_bbcode; + } else if (!string.IsNullOrEmpty(notification.message)) { + message = SteamChatMessage.Unescape(notification.message); + } else { + return; + } + + ArchiLogger.LogChatMessage(notification.local_echo, message, steamID: notification.steamid_friend); + + // Steam network broadcasts chat events also when we don't explicitly sign into Steam community + // We'll explicitly ignore those messages when using offline mode, as it was done in the first version of Steam chat when no messages were broadcasted at all before signing in + // Handling messages will still work correctly in invisible mode, which is how it should work in the first place + // This goes in addition to usual logic that ignores irrelevant messages from being parsed further + if (notification.local_echo || (BotConfig.OnlineStatus == EPersonaState.Offline)) { + return; + } + + await Commands.HandleMessage(notification.steamid_friend, message).ConfigureAwait(false); + } + + private void OnInventoryChanged() { + Utilities.InBackground(CardsFarmer.OnNewItemsNotification); + + if (BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.DismissInventoryNotifications)) { + Utilities.InBackground(ArchiWebHandler.MarkInventory); + } + + if (BotConfig.CompleteTypesToSend.Count > 0) { + Utilities.InBackground(SendCompletedSets); + } + } + + private async void OnLicenseList(SteamApps.LicenseListCallback callback) { + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); + } + + if (callback.LicenseList == null) { + throw new ArgumentNullException(nameof(callback)); + } + + if (ASF.GlobalDatabase == null) { + throw new InvalidOperationException(nameof(ASF.GlobalDatabase)); + } + + if (callback.LicenseList.Count == 0) { + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(callback.LicenseList))); + + return; + } + + // Wait a short time for eventual LastChangeNumber initialization + for (byte i = 0; (i < WebBrowser.MaxTries) && !SteamPICSChanges.LiveUpdate; i++) { + await Task.Delay(1000).ConfigureAwait(false); + } + + Commands.OnNewLicenseList(); + + Dictionary ownedPackageIDs = new(); + + Dictionary packageAccessTokens = new(); + Dictionary packagesToRefresh = new(); + + foreach (SteamApps.LicenseListCallback.License license in callback.LicenseList.GroupBy(static license => license.PackageID, static (_, licenses) => licenses.OrderByDescending(static license => license.TimeCreated).First())) { + ownedPackageIDs[license.PackageID] = (license.PaymentMethod, license.TimeCreated); + + if (!ASF.GlobalDatabase.PackageAccessTokensReadOnly.TryGetValue(license.PackageID, out ulong packageAccessToken) || (packageAccessToken != license.AccessToken)) { + packageAccessTokens[license.PackageID] = license.AccessToken; + + // Package is always due to refresh with access token change + packagesToRefresh[license.PackageID] = (uint) license.LastChangeNumber; + } else if (!ASF.GlobalDatabase.PackagesDataReadOnly.TryGetValue(license.PackageID, out (uint ChangeNumber, ImmutableHashSet? AppIDs) packageData) || (packageData.ChangeNumber < license.LastChangeNumber)) { + packagesToRefresh[license.PackageID] = (uint) license.LastChangeNumber; + } + } + + OwnedPackageIDs = ownedPackageIDs.ToImmutableDictionary(); + + if (packageAccessTokens.Count > 0) { + ASF.GlobalDatabase.RefreshPackageAccessTokens(packageAccessTokens); + } + + if (packagesToRefresh.Count > 0) { + ArchiLogger.LogGenericTrace(Strings.BotRefreshingPackagesData); + await ASF.GlobalDatabase.RefreshPackages(this, packagesToRefresh).ConfigureAwait(false); + ArchiLogger.LogGenericTrace(Strings.Done); + } + + await CardsFarmer.OnNewGameAdded().ConfigureAwait(false); + } + + private void OnLoggedOff(SteamUser.LoggedOffCallback callback) { + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); + } + + LastLogOnResult = callback.Result; + + ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotLoggedOff, callback.Result)); + + switch (callback.Result) { + case EResult.LoggedInElsewhere: + // This result directly indicates that playing was blocked when we got (forcefully) disconnected + PlayingWasBlocked = true; + + break; + case EResult.LogonSessionReplaced: + DateTime now = DateTime.UtcNow; + + if (now.Subtract(LastLogonSessionReplaced).TotalHours < 1) { + ArchiLogger.LogGenericError(Strings.BotLogonSessionReplaced); + Stop(); + + return; + } + + LastLogonSessionReplaced = now; + + break; + } + + ReconnectOnUserInitiated = true; + SteamClient.Disconnect(); + } + + private async void OnLoggedOn(SteamUser.LoggedOnCallback callback) { + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); + } + + // Always reset one-time-only access tokens when we get OnLoggedOn() response + AuthCode = TwoFactorCode = null; + + // Keep LastLogOnResult for OnDisconnected() + LastLogOnResult = callback.Result; + + HeartBeatFailures = 0; + StopConnectionFailureTimer(); + + switch (callback.Result) { + case EResult.AccountDisabled: + // Those failures are permanent, we should Stop() the bot if any of those happen + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.BotUnableToLogin, callback.Result, callback.ExtendedResult)); + Stop(); + + break; + case EResult.AccountLogonDenied: + RequiredInput = ASF.EUserInputType.SteamGuard; + + string? authCode = await Logging.GetUserInput(ASF.EUserInputType.SteamGuard, BotName).ConfigureAwait(false); + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (string.IsNullOrEmpty(authCode) || !SetUserInput(ASF.EUserInputType.SteamGuard, authCode!)) { + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(authCode))); + + Stop(); + } + + break; + case EResult.AccountLoginDeniedNeedTwoFactor: + if (!HasMobileAuthenticator) { + RequiredInput = ASF.EUserInputType.TwoFactorAuthentication; + + string? twoFactorCode = await Logging.GetUserInput(ASF.EUserInputType.TwoFactorAuthentication, BotName).ConfigureAwait(false); + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (string.IsNullOrEmpty(twoFactorCode) || !SetUserInput(ASF.EUserInputType.TwoFactorAuthentication, twoFactorCode!)) { + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(twoFactorCode))); + + Stop(); + } + } + + break; + case EResult.OK: + AccountFlags = callback.AccountFlags; + SteamID = callback.ClientSteamID ?? throw new InvalidOperationException(nameof(callback.ClientSteamID)); + + ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotLoggedOn, SteamID + (!string.IsNullOrEmpty(callback.VanityURL) ? $"/{callback.VanityURL}" : ""))); + + // Old status for these doesn't matter, we'll update them if needed + InvalidPasswordFailures = TwoFactorCodeFailures = 0; + LibraryLocked = PlayingBlocked = false; + + if (PlayingWasBlocked && (PlayingWasBlockedTimer == null)) { + InitPlayingWasBlockedTimer(); + } + + if (IsAccountLimited) { + ArchiLogger.LogGenericWarning(Strings.BotAccountLimited); + } + + if (IsAccountLocked) { + ArchiLogger.LogGenericWarning(Strings.BotAccountLocked); + } + + if ((callback.CellID != 0) && (ASF.GlobalDatabase != null) && (callback.CellID != ASF.GlobalDatabase.CellID)) { + ASF.GlobalDatabase.CellID = callback.CellID; + } + + // Handle steamID-based maFile + if (!HasMobileAuthenticator) { + string maFilePath = Path.Combine(SharedInfo.ConfigDirectory, SteamID + SharedInfo.MobileAuthenticatorExtension); + + if (File.Exists(maFilePath)) { + await ImportAuthenticatorFromFile(maFilePath).ConfigureAwait(false); + } + } + + if (callback.ParentalSettings != null) { + (bool isSteamParentalEnabled, string? steamParentalCode) = ValidateSteamParental(callback.ParentalSettings, BotConfig.SteamParentalCode); + + if (isSteamParentalEnabled) { + SteamParentalActive = true; + + if (!string.IsNullOrEmpty(steamParentalCode)) { + if (BotConfig.SteamParentalCode != steamParentalCode) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (!SetUserInput(ASF.EUserInputType.SteamParentalCode, steamParentalCode!)) { + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(steamParentalCode))); + + Stop(); + + break; + } + } + } else if (string.IsNullOrEmpty(BotConfig.SteamParentalCode) || (BotConfig.SteamParentalCode!.Length != BotConfig.SteamParentalCodeLength)) { + RequiredInput = ASF.EUserInputType.SteamParentalCode; + + steamParentalCode = await Logging.GetUserInput(ASF.EUserInputType.SteamParentalCode, BotName).ConfigureAwait(false); + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (string.IsNullOrEmpty(steamParentalCode) || !SetUserInput(ASF.EUserInputType.SteamParentalCode, steamParentalCode!)) { + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(steamParentalCode))); + + Stop(); + + break; + } + } + } else { + SteamParentalActive = false; + } + } else if (SteamParentalActive && !string.IsNullOrEmpty(BotConfig.SteamParentalCode) && (BotConfig.SteamParentalCode!.Length != BotConfig.SteamParentalCodeLength)) { + RequiredInput = ASF.EUserInputType.SteamParentalCode; + + string? steamParentalCode = await Logging.GetUserInput(ASF.EUserInputType.SteamParentalCode, BotName).ConfigureAwait(false); + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (string.IsNullOrEmpty(steamParentalCode) || !SetUserInput(ASF.EUserInputType.SteamParentalCode, steamParentalCode!)) { + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(steamParentalCode))); + + Stop(); + + break; + } + } + + ArchiWebHandler.OnVanityURLChanged(callback.VanityURL); + + if (!await ArchiWebHandler.Init(SteamID, SteamClient.Universe, callback.WebAPIUserNonce ?? throw new InvalidOperationException(nameof(callback.WebAPIUserNonce)), SteamParentalActive ? BotConfig.SteamParentalCode : null).ConfigureAwait(false)) { + if (!await RefreshSession().ConfigureAwait(false)) { + break; + } + } + + // Pre-fetch API key for future usage if possible + Utilities.InBackground(ArchiWebHandler.HasValidApiKey); + + if ((GamesRedeemerInBackgroundTimer == null) && BotDatabase.HasGamesToRedeemInBackground) { + Utilities.InBackground(() => RedeemGamesInBackground()); + } + + ArchiHandler.SetCurrentMode(BotConfig.UserInterfaceMode); + ArchiHandler.RequestItemAnnouncements(); + + // Sometimes Steam won't send us our own PersonaStateCallback, so request it explicitly + RequestPersonaStateUpdate(); + + Utilities.InBackground(InitializeFamilySharing); + + if (Statistics != null) { + Utilities.InBackground(Statistics.OnLoggedOn); + } + + if (BotConfig.OnlineStatus != EPersonaState.Offline) { + SteamFriends.SetPersonaState(BotConfig.OnlineStatus); + } + + if (BotConfig.SteamMasterClanID != 0) { + Utilities.InBackground( + async () => { + if (!await ArchiWebHandler.JoinGroup(BotConfig.SteamMasterClanID).ConfigureAwait(false)) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiWebHandler.JoinGroup))); + } + + await JoinMasterChatGroupID().ConfigureAwait(false); + } + ); + } + + if (CardsFarmer.Paused) { + // Emit initial game playing status in this case + Utilities.InBackground(ResetGamesPlayed); + } + + SteamPICSChanges.OnBotLoggedOn(); + + await PluginsCore.OnBotLoggedOn(this).ConfigureAwait(false); + + break; + case EResult.InvalidPassword: + case EResult.NoConnection: + case EResult.PasswordRequiredToKickSession: // Not sure about this one, it seems to be just generic "try again"? #694 + case EResult.RateLimitExceeded: + case EResult.ServiceUnavailable: + case EResult.Timeout: + case EResult.TryAnotherCM: + case EResult.TwoFactorCodeMismatch: + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.BotUnableToLogin, callback.Result, callback.ExtendedResult)); + + switch (callback.Result) { + case EResult.InvalidPassword when string.IsNullOrEmpty(BotDatabase.LoginKey) && (++InvalidPasswordFailures >= MaxInvalidPasswordFailures): + InvalidPasswordFailures = 0; + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.BotInvalidPasswordDuringLogin, MaxInvalidPasswordFailures)); + Stop(); + + break; + case EResult.TwoFactorCodeMismatch when HasMobileAuthenticator: + // There is a possibility that our cached time is no longer appropriate, so we should reset the cache in this case in order to fetch it upon the next login attempt + // Yes, this might as well be just invalid 2FA credentials, but we can't be sure about that, and we have MaxTwoFactorCodeFailures designed to verify that for us + await MobileAuthenticator.ResetSteamTimeDifference().ConfigureAwait(false); + + if (++TwoFactorCodeFailures >= MaxTwoFactorCodeFailures) { + TwoFactorCodeFailures = 0; + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.BotInvalidAuthenticatorDuringLogin, MaxTwoFactorCodeFailures)); + Stop(); + } + + break; + } + + break; + default: + // Unexpected result, shutdown immediately + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(callback.Result), callback.Result)); + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.BotUnableToLogin, callback.Result, callback.ExtendedResult)); + Stop(); + + break; + } + } + + private void OnLoginKey(SteamUser.LoginKeyCallback callback) { + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); + } + + if (string.IsNullOrEmpty(callback.LoginKey)) { + throw new ArgumentNullException(nameof(callback)); + } + + if (!ShouldUseLoginKeys) { + return; + } + + string? loginKey = callback.LoginKey; + + if (BotConfig.PasswordFormat != ArchiCryptoHelper.ECryptoMethod.PlainText) { + loginKey = ArchiCryptoHelper.Encrypt(BotConfig.PasswordFormat, loginKey); + } + + BotDatabase.LoginKey = loginKey; + SteamUser.AcceptNewLoginKey(callback); + } + + private async void OnMachineAuth(SteamUser.UpdateMachineAuthCallback callback) { + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); + } + + string sentryFilePath = GetFilePath(EFileType.SentryFile); + + if (string.IsNullOrEmpty(sentryFilePath)) { + ArchiLogger.LogNullError(nameof(sentryFilePath)); + + return; + } + + long fileSize; + byte[] sentryHash; + + try { +#pragma warning disable CA2000 // False positive, we're actually wrapping it in the using clause below exactly for that purpose + FileStream fileStream = File.Open(sentryFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite); +#pragma warning restore CA2000 // False positive, we're actually wrapping it in the using clause below exactly for that purpose + + await using (fileStream.ConfigureAwait(false)) { + fileStream.Seek(callback.Offset, SeekOrigin.Begin); + + await fileStream.WriteAsync(callback.Data.AsMemory(0, callback.BytesToWrite)).ConfigureAwait(false); + + fileSize = fileStream.Length; + fileStream.Seek(0, SeekOrigin.Begin); + +#pragma warning disable CA5350 // This is actually a fair warning, but there is nothing we can do about Steam using weak cryptographic algorithms + using SHA1 hashingAlgorithm = SHA1.Create(); + + sentryHash = await hashingAlgorithm.ComputeHashAsync(fileStream).ConfigureAwait(false); +#pragma warning restore CA5350 // This is actually a fair warning, but there is nothing we can do about Steam using weak cryptographic algorithms + } + } catch (Exception e) { + ArchiLogger.LogGenericException(e); + + try { + File.Delete(sentryFilePath); + } catch { + // Ignored, we can only try to delete faulted file at best + } + + return; + } + + // Inform the steam servers that we're accepting this sentry file + SteamUser.SendMachineAuthResponse( + new SteamUser.MachineAuthDetails { + BytesWritten = callback.BytesToWrite, + FileName = callback.FileName, + FileSize = (int) fileSize, + JobID = callback.JobID, + LastError = 0, + Offset = callback.Offset, + OneTimePassword = callback.OneTimePassword, + Result = EResult.OK, + SentryFileHash = sentryHash + } + ); + } + + private void OnPersonaState(SteamFriends.PersonaStateCallback callback) { + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); + } + + if (callback.FriendID != SteamID) { + return; + } + + string? avatarHash = null; + + if ((callback.AvatarHash.Length > 0) && callback.AvatarHash.Any(static singleByte => singleByte != 0)) { +#pragma warning disable CA1308 // False positive, we're intentionally converting this part to lowercase and it's not used for any security decisions based on the result of the normalization + avatarHash = BitConverter.ToString(callback.AvatarHash).Replace("-", "", StringComparison.Ordinal).ToLowerInvariant(); +#pragma warning restore CA1308 // False positive, we're intentionally converting this part to lowercase and it's not used for any security decisions based on the result of the normalization + + if (string.IsNullOrEmpty(avatarHash) || avatarHash.All(static singleChar => singleChar == '0')) { + avatarHash = null; + } + } + + AvatarHash = avatarHash; + Nickname = callback.Name; + + if (Statistics != null) { + Utilities.InBackground(() => Statistics.OnPersonaState(callback.Name, avatarHash)); + } + } + + private async void OnPlayingSessionState(ArchiHandler.PlayingSessionStateCallback callback) { + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); + } + + if (callback.PlayingBlocked == PlayingBlocked) { + return; // No status update, we're not interested + } + + PlayingBlocked = callback.PlayingBlocked; + await CheckOccupationStatus().ConfigureAwait(false); + } + + private async void OnSendItemsTimer(object? state = null) => await Actions.SendInventory(filterFunction: item => BotConfig.LootableTypes.Contains(item.Type)).ConfigureAwait(false); + + private async void OnServiceMethod(SteamUnifiedMessages.ServiceMethodNotification notification) { + if (notification == null) { + throw new ArgumentNullException(nameof(notification)); + } + + switch (notification.MethodName) { + case "ChatRoomClient.NotifyIncomingChatMessage#1": + await OnIncomingChatMessage((CChatRoom_IncomingChatMessage_Notification) notification.Body).ConfigureAwait(false); + + break; + case "FriendMessagesClient.IncomingMessage#1": + await OnIncomingMessage((CFriendMessages_IncomingMessage_Notification) notification.Body).ConfigureAwait(false); + + break; + } + } + + private async void OnSharedLibraryLockStatus(ArchiHandler.SharedLibraryLockStatusCallback callback) { + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); + } + + // Ignore no status updates + if (LibraryLocked) { + if ((callback.LibraryLockedBySteamID != 0) && (callback.LibraryLockedBySteamID != SteamID)) { + return; + } + + LibraryLocked = false; + } else { + if ((callback.LibraryLockedBySteamID == 0) || (callback.LibraryLockedBySteamID == SteamID)) { + return; + } + + LibraryLocked = true; + } + + await CheckOccupationStatus().ConfigureAwait(false); + } + + private void OnUserNotifications(UserNotificationsCallback callback) { + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); + } + + if (callback.Notifications == null) { + throw new ArgumentNullException(nameof(callback)); + } + + if (callback.Notifications.Count == 0) { + return; + } + + HashSet newPluginNotifications = new(); + + foreach ((UserNotificationsCallback.EUserNotification notification, uint count) in callback.Notifications) { + bool newNotification; + + if (count > 0) { + newNotification = !PastNotifications.TryGetValue(notification, out uint previousCount) || (count > previousCount); + PastNotifications[notification] = count; + + if (newNotification) { + newPluginNotifications.Add(notification); + } + } else { + newNotification = false; + PastNotifications.TryRemove(notification, out _); + } + + ArchiLogger.LogGenericTrace($"{notification} = {count}"); + + switch (notification) { + case UserNotificationsCallback.EUserNotification.Gifts when newNotification && BotConfig.AcceptGifts: + Utilities.InBackground(Actions.AcceptDigitalGiftCards); + + break; + case UserNotificationsCallback.EUserNotification.Items when newNotification: + OnInventoryChanged(); + + break; + case UserNotificationsCallback.EUserNotification.Trading when newNotification: + Utilities.InBackground(Trading.OnNewTrade); + + break; + } + } + + if (newPluginNotifications.Count > 0) { + Utilities.InBackground(() => PluginsCore.OnBotUserNotifications(this, newPluginNotifications)); + } + } + + private void OnVanityURLChangedCallback(ArchiHandler.VanityURLChangedCallback callback) { + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); + } + + ArchiWebHandler.OnVanityURLChanged(callback.VanityURL); + } + + private void OnWalletUpdate(SteamUser.WalletInfoCallback callback) { + if (callback == null) { + throw new ArgumentNullException(nameof(callback)); + } + + WalletBalance = callback.LongBalance; + WalletCurrency = callback.Currency; + } + + private async void RedeemGamesInBackground(object? state = null) { + if (!await GamesRedeemerInBackgroundSemaphore.WaitAsync(0).ConfigureAwait(false)) { + return; + } + + try { + if (GamesRedeemerInBackgroundTimer != null) { + await GamesRedeemerInBackgroundTimer.DisposeAsync().ConfigureAwait(false); + + GamesRedeemerInBackgroundTimer = null; + } + + ArchiLogger.LogGenericInfo(Strings.Starting); + + bool assumeWalletKeyOnBadActivationCode = BotConfig.RedeemingPreferences.HasFlag(BotConfig.ERedeemingPreferences.AssumeWalletKeyOnBadActivationCode); + + while (IsConnectedAndLoggedOn && BotDatabase.HasGamesToRedeemInBackground) { + (string? key, string? name) = BotDatabase.GetGameToRedeemInBackground(); + + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(name)) { + ArchiLogger.LogNullError($"{nameof(key)} || {nameof(name)}"); + + break; + } + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + PurchaseResponseCallback? result = await Actions.RedeemKey(key!).ConfigureAwait(false); + + if (result == null) { + continue; + } + + if (((result.PurchaseResultDetail == EPurchaseResultDetail.CannotRedeemCodeFromClient) || ((result.PurchaseResultDetail == EPurchaseResultDetail.BadActivationCode) && assumeWalletKeyOnBadActivationCode)) && (WalletCurrency != ECurrencyCode.Invalid)) { + // If it's a wallet code, we try to redeem it first, then handle the inner result as our primary one + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + (EResult Result, EPurchaseResultDetail? PurchaseResult)? walletResult = await ArchiWebHandler.RedeemWalletKey(key!).ConfigureAwait(false); + + if (walletResult != null) { + result.Result = walletResult.Value.Result; + result.PurchaseResultDetail = walletResult.Value.PurchaseResult.GetValueOrDefault(walletResult.Value.Result == EResult.OK ? EPurchaseResultDetail.NoDetail : EPurchaseResultDetail.BadActivationCode); // BadActivationCode is our smart guess in this case + } else { + result.Result = EResult.Timeout; + result.PurchaseResultDetail = EPurchaseResultDetail.Timeout; + } + } + + ArchiLogger.LogGenericDebug(result.Items?.Count > 0 ? string.Format(CultureInfo.CurrentCulture, Strings.BotRedeemWithItems, key, $"{result.Result}/{result.PurchaseResultDetail}", string.Join(", ", result.Items)) : string.Format(CultureInfo.CurrentCulture, Strings.BotRedeem, key, $"{result.Result}/{result.PurchaseResultDetail}")); + + bool rateLimited = false; + bool redeemed = false; + + switch (result.PurchaseResultDetail) { + case EPurchaseResultDetail.AccountLocked: + case EPurchaseResultDetail.AlreadyPurchased: + case EPurchaseResultDetail.CannotRedeemCodeFromClient: + case EPurchaseResultDetail.DoesNotOwnRequiredApp: + case EPurchaseResultDetail.RestrictedCountry: + case EPurchaseResultDetail.Timeout: + break; + case EPurchaseResultDetail.BadActivationCode: + case EPurchaseResultDetail.DuplicateActivationCode: + case EPurchaseResultDetail.NoDetail: // OK + redeemed = true; + + break; + case EPurchaseResultDetail.RateLimited: + rateLimited = true; + + break; + default: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(result.PurchaseResultDetail), result.PurchaseResultDetail)); + + break; + } + + if (rateLimited) { + break; + } + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + BotDatabase.RemoveGameToRedeemInBackground(key!); + + // If user omitted the name or intentionally provided the same name as key, replace it with the Steam result + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (name!.Equals(key, StringComparison.OrdinalIgnoreCase) && (result.Items?.Count > 0)) { + name = string.Join(", ", result.Items.Values); + } + + string logEntry = $"{name}{DefaultBackgroundKeysRedeemerSeparator}[{result.PurchaseResultDetail}]{(result.Items?.Count > 0 ? DefaultBackgroundKeysRedeemerSeparator + string.Join(", ", result.Items) : "")}{DefaultBackgroundKeysRedeemerSeparator}{key}"; + + string filePath = GetFilePath(redeemed ? EFileType.KeysToRedeemUsed : EFileType.KeysToRedeemUnused); + + if (string.IsNullOrEmpty(filePath)) { + ArchiLogger.LogNullError(nameof(filePath)); + + return; + } + + try { + await File.AppendAllTextAsync(filePath, logEntry + Environment.NewLine).ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericException(e); + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.Content, logEntry)); + + break; + } + } + + if (IsConnectedAndLoggedOn && BotDatabase.HasGamesToRedeemInBackground) { + ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRateLimitExceeded, TimeSpan.FromHours(RedeemCooldownInHours).ToHumanReadable())); + + GamesRedeemerInBackgroundTimer = new Timer( + RedeemGamesInBackground, + null, + TimeSpan.FromHours(RedeemCooldownInHours), // Delay + Timeout.InfiniteTimeSpan // Period + ); + } + + ArchiLogger.LogGenericInfo(Strings.Done); + } finally { + GamesRedeemerInBackgroundSemaphore.Release(); + } + } + + private async Task ResetGamesPlayed() { + if (CardsFarmer.NowFarming) { + return; + } + + if (BotConfig.GamesPlayedWhileIdle.Count > 0) { + if (!IsPlayingPossible) { + return; + } + + // This function might be executed before PlayingSessionStateCallback/SharedLibraryLockStatusCallback, ensure proper delay in this case + await Task.Delay(2000).ConfigureAwait(false); + + if (CardsFarmer.NowFarming || !IsPlayingPossible) { + return; + } + + ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotIdlingSelectedGames, nameof(BotConfig.GamesPlayedWhileIdle), string.Join(", ", BotConfig.GamesPlayedWhileIdle))); + } + + await ArchiHandler.PlayGames(BotConfig.GamesPlayedWhileIdle, BotConfig.CustomGamePlayedWhileIdle).ConfigureAwait(false); + } + + private void ResetPlayingWasBlockedWithTimer(object? state = null) { + PlayingWasBlocked = false; + StopPlayingWasBlockedTimer(); + } + + private async Task SendCompletedSets() { + // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that + lock (SendCompleteTypesSemaphore) { + if (SendCompleteTypesScheduled) { + return; + } + + SendCompleteTypesScheduled = true; + } + + await SendCompleteTypesSemaphore.WaitAsync().ConfigureAwait(false); + + try { + using (await Actions.GetTradingLock().ConfigureAwait(false)) { + // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that + lock (SendCompleteTypesSemaphore) { + SendCompleteTypesScheduled = false; + } + + HashSet? appIDs = await GetPossiblyCompletedBadgeAppIDs().ConfigureAwait(false); + + if ((appIDs == null) || (appIDs.Count == 0)) { + return; + } + + HashSet inventory; + + try { + inventory = await ArchiWebHandler.GetInventoryAsync() + .Where(item => item.Tradable && appIDs.Contains(item.RealAppID) && BotConfig.CompleteTypesToSend.Contains(item.Type)) + .ToHashSetAsync() + .ConfigureAwait(false); + } catch (HttpRequestException e) { + ArchiLogger.LogGenericWarningException(e); + + return; + } catch (Exception e) { + ArchiLogger.LogGenericException(e); + + return; + } + + if (inventory.Count == 0) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(inventory))); + + return; + } + + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), List> inventorySets = Trading.GetInventorySets(inventory); + appIDs.IntersectWith(inventorySets.Where(static kv => kv.Value.Count >= MinCardsPerBadge).Select(static kv => kv.Key.RealAppID)); + + if (appIDs.Count == 0) { + return; + } + + Dictionary? cardCountPerAppID = await LoadCardsPerSet(appIDs).ConfigureAwait(false); + + if ((cardCountPerAppID == null) || (cardCountPerAppID.Count == 0)) { + return; + } + + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), (uint Sets, byte CardsPerSet)> itemsToTakePerInventorySet = inventorySets.Where(kv => appIDs.Contains(kv.Key.RealAppID)).ToDictionary(static kv => kv.Key, kv => (kv.Value[0], cardCountPerAppID[kv.Key.RealAppID])); + + if (itemsToTakePerInventorySet.Values.All(static value => value.Sets == 0)) { + return; + } + + HashSet result = GetItemsForFullSets(inventory, itemsToTakePerInventorySet); + + if (result.Count > 0) { + await Actions.SendInventory(result).ConfigureAwait(false); + } + } + } finally { + SendCompleteTypesSemaphore.Release(); + } + } + + private async Task SendMessagePart(ulong steamID, string messagePart, ulong chatGroupID = 0) { + if ((steamID == 0) || ((chatGroupID == 0) && !new SteamID(steamID).IsIndividualAccount)) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(messagePart)) { + throw new ArgumentNullException(nameof(messagePart)); + } + + if (!IsConnectedAndLoggedOn) { + return false; + } + + await MessagingSemaphore.WaitAsync().ConfigureAwait(false); + + try { + for (byte i = 0; (i < WebBrowser.MaxTries) && IsConnectedAndLoggedOn; i++) { + EResult result; + + if (chatGroupID == 0) { + result = await ArchiHandler.SendMessage(steamID, messagePart).ConfigureAwait(false); + } else { + result = await ArchiHandler.SendMessage(chatGroupID, steamID, messagePart).ConfigureAwait(false); + } + + switch (result) { + case EResult.Busy: + case EResult.Fail: + case EResult.LimitExceeded: + case EResult.RateLimitExceeded: + case EResult.ServiceUnavailable: + case EResult.Timeout: + await Task.Delay(5000).ConfigureAwait(false); + + continue; + case EResult.OK: + return true; + default: + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(result), result)); + + return false; + } + } + + return false; + } finally { + MessagingSemaphore.Release(); + } + } + + private bool ShouldAckChatMessage(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (Bots == null) { + throw new InvalidOperationException(nameof(Bots)); + } + + if (BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.MarkReceivedMessagesAsRead)) { + return true; + } + + return BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.MarkBotMessagesAsRead) && Bots.Values.Any(bot => bot.SteamID == steamID); + } + + private void StopConnectionFailureTimer() { + if (ConnectionFailureTimer == null) { + return; + } + + ConnectionFailureTimer.Dispose(); + ConnectionFailureTimer = null; + } + + private void StopPlayingWasBlockedTimer() { + if (PlayingWasBlockedTimer == null) { + return; + } + + PlayingWasBlockedTimer.Dispose(); + PlayingWasBlockedTimer = null; + } + + private (bool IsSteamParentalEnabled, string? SteamParentalCode) ValidateSteamParental(ParentalSettings settings, string? steamParentalCode = null) { + if (settings == null) { + throw new ArgumentNullException(nameof(settings)); + } + + if (!settings.is_enabled) { + return (false, null); + } + + ArchiCryptoHelper.EHashingMethod steamParentalHashingMethod; + + switch (settings.passwordhashtype) { + case 4: + steamParentalHashingMethod = ArchiCryptoHelper.EHashingMethod.Pbkdf2; + + break; + case 6: + steamParentalHashingMethod = ArchiCryptoHelper.EHashingMethod.SCrypt; + + break; + default: + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(settings.passwordhashtype), settings.passwordhashtype)); + + return (true, null); + } + + if (steamParentalCode?.Length == BotConfig.SteamParentalCodeLength) { + byte i = 0; + byte[] password = new byte[steamParentalCode.Length]; + + foreach (char character in steamParentalCode.TakeWhile(static character => character is >= '0' and <= '9')) { + password[i++] = (byte) character; + } + + if (i >= steamParentalCode.Length) { + byte[] passwordHash = ArchiCryptoHelper.Hash(password, settings.salt, (byte) settings.passwordhash.Length, steamParentalHashingMethod); + + if (passwordHash.SequenceEqual(settings.passwordhash)) { + return (true, steamParentalCode); + } + } + } + + ArchiLogger.LogGenericInfo(Strings.BotGeneratingSteamParentalCode); + + steamParentalCode = ArchiCryptoHelper.RecoverSteamParentalCode(settings.passwordhash, settings.salt, steamParentalHashingMethod); + + ArchiLogger.LogGenericInfo(Strings.Done); + + return (true, steamParentalCode); + } + + public enum EFileType : byte { + Config, + Database, + KeysToRedeem, + KeysToRedeemUnused, + KeysToRedeemUsed, + MobileAuthenticator, + SentryFile + } } diff --git a/ArchiSteamFarm/Steam/Cards/CardsFarmer.cs b/ArchiSteamFarm/Steam/Cards/CardsFarmer.cs index 041e52d71..e1747bb20 100644 --- a/ArchiSteamFarm/Steam/Cards/CardsFarmer.cs +++ b/ArchiSteamFarm/Steam/Cards/CardsFarmer.cs @@ -41,776 +41,721 @@ using JetBrains.Annotations; using Newtonsoft.Json; using SteamKit2; -namespace ArchiSteamFarm.Steam.Cards { - public sealed class CardsFarmer : IAsyncDisposable { - internal const byte DaysForRefund = 14; // In how many days since payment we're allowed to refund - internal const byte HoursForRefund = 2; // Up to how many hours we're allowed to play for refund +namespace ArchiSteamFarm.Steam.Cards; - private const byte ExtraFarmingDelaySeconds = 10; // In seconds, how much time to add on top of FarmingDelay (helps fighting misc time differences of Steam network) - private const byte HoursToIgnore = 1; // How many hours we ignore unreleased appIDs and don't bother checking them again +public sealed class CardsFarmer : IAsyncDisposable { + internal const byte DaysForRefund = 14; // In how many days since payment we're allowed to refund + internal const byte HoursForRefund = 2; // Up to how many hours we're allowed to play for refund - [PublicAPI] - public static readonly ImmutableHashSet SalesBlacklist = ImmutableHashSet.Create(267420, 303700, 335590, 368020, 425280, 480730, 566020, 639900, 762800, 876740, 991980, 1195670, 1343890, 1465680, 1658760); + private const byte ExtraFarmingDelaySeconds = 10; // In seconds, how much time to add on top of FarmingDelay (helps fighting misc time differences of Steam network) + private const byte HoursToIgnore = 1; // How many hours we ignore unreleased appIDs and don't bother checking them again - private static readonly ConcurrentDictionary GloballyIgnoredAppIDs = new(); // Reserved for unreleased games + [PublicAPI] + public static readonly ImmutableHashSet SalesBlacklist = ImmutableHashSet.Create(267420, 303700, 335590, 368020, 425280, 480730, 566020, 639900, 762800, 876740, 991980, 1195670, 1343890, 1465680, 1658760); - // Games that were confirmed to show false status on general badges page - private static readonly ImmutableHashSet UntrustedAppIDs = ImmutableHashSet.Create(440, 570, 730); + private static readonly ConcurrentDictionary GloballyIgnoredAppIDs = new(); // Reserved for unreleased games - [JsonProperty(PropertyName = nameof(CurrentGamesFarming))] - [PublicAPI] - public IReadOnlyCollection CurrentGamesFarmingReadOnly => CurrentGamesFarming; + // Games that were confirmed to show false status on general badges page + private static readonly ImmutableHashSet UntrustedAppIDs = ImmutableHashSet.Create(440, 570, 730); - [JsonProperty(PropertyName = nameof(GamesToFarm))] - [PublicAPI] - public IReadOnlyCollection GamesToFarmReadOnly => GamesToFarm; + [JsonProperty(PropertyName = nameof(CurrentGamesFarming))] + [PublicAPI] + public IReadOnlyCollection CurrentGamesFarmingReadOnly => CurrentGamesFarming; - [JsonProperty] - [PublicAPI] - public TimeSpan TimeRemaining => - new( - Bot.BotConfig.HoursUntilCardDrops > 0 ? (ushort) Math.Ceiling(GamesToFarm.Count / (float) ArchiHandler.MaxGamesPlayedConcurrently) * Bot.BotConfig.HoursUntilCardDrops : 0, - 30 * GamesToFarm.Sum(static game => game.CardsRemaining), - 0 - ); + [JsonProperty(PropertyName = nameof(GamesToFarm))] + [PublicAPI] + public IReadOnlyCollection GamesToFarmReadOnly => GamesToFarm; - private readonly Bot Bot; - private readonly ConcurrentHashSet CurrentGamesFarming = new(); - private readonly SemaphoreSlim EventSemaphore = new(1, 1); - private readonly SemaphoreSlim FarmingInitializationSemaphore = new(1, 1); - private readonly SemaphoreSlim FarmingResetSemaphore = new(0, 1); - private readonly ConcurrentList GamesToFarm = new(); + [JsonProperty] + [PublicAPI] + public TimeSpan TimeRemaining => + new( + Bot.BotConfig.HoursUntilCardDrops > 0 ? (ushort) Math.Ceiling(GamesToFarm.Count / (float) ArchiHandler.MaxGamesPlayedConcurrently) * Bot.BotConfig.HoursUntilCardDrops : 0, + 30 * GamesToFarm.Sum(static game => game.CardsRemaining), + 0 + ); + + private readonly Bot Bot; + private readonly ConcurrentHashSet CurrentGamesFarming = new(); + private readonly SemaphoreSlim EventSemaphore = new(1, 1); + private readonly SemaphoreSlim FarmingInitializationSemaphore = new(1, 1); + private readonly SemaphoreSlim FarmingResetSemaphore = new(0, 1); + private readonly ConcurrentList GamesToFarm = new(); #pragma warning disable CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private readonly Timer? IdleFarmingTimer; + private readonly Timer? IdleFarmingTimer; #pragma warning restore CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private readonly ConcurrentDictionary LocallyIgnoredAppIDs = new(); + private readonly ConcurrentDictionary LocallyIgnoredAppIDs = new(); - private IEnumerable> SourcesOfIgnoredAppIDs { - get { - yield return GloballyIgnoredAppIDs; - yield return LocallyIgnoredAppIDs; - } + private IEnumerable> SourcesOfIgnoredAppIDs { + get { + yield return GloballyIgnoredAppIDs; + yield return LocallyIgnoredAppIDs; + } + } + + [JsonProperty] + [PublicAPI] + public bool Paused { get; private set; } + + internal bool NowFarming { get; private set; } + + private bool KeepFarming; + private bool ParsingScheduled; + private bool PermanentlyPaused; + private bool ShouldResumeFarming = true; + + internal CardsFarmer(Bot bot) { + Bot = bot ?? throw new ArgumentNullException(nameof(bot)); + + byte idleFarmingPeriod = ASF.GlobalConfig?.IdleFarmingPeriod ?? GlobalConfig.DefaultIdleFarmingPeriod; + + if (idleFarmingPeriod > 0) { + IdleFarmingTimer = new Timer( + CheckGamesForFarming, + null, + TimeSpan.FromHours(idleFarmingPeriod) + TimeSpan.FromSeconds(ASF.LoadBalancingDelay * Bot.Bots?.Count ?? 0), // Delay + TimeSpan.FromHours(idleFarmingPeriod) // Period + ); + } + } + + public async ValueTask DisposeAsync() { + // Those are objects that are always being created if constructor doesn't throw exception + EventSemaphore.Dispose(); + FarmingInitializationSemaphore.Dispose(); + FarmingResetSemaphore.Dispose(); + + // Those are objects that might be null and the check should be in-place + if (IdleFarmingTimer != null) { + await IdleFarmingTimer.DisposeAsync().ConfigureAwait(false); + } + } + + internal void OnDisconnected() { + if (!NowFarming) { + return; } - [JsonProperty] - [PublicAPI] - public bool Paused { get; private set; } + Utilities.InBackground(StopFarming); + } - internal bool NowFarming { get; private set; } + internal async Task OnNewGameAdded() { + // This update has a potential to modify local ignores, therefore we need to purge our cache + LocallyIgnoredAppIDs.Clear(); - private bool KeepFarming; - private bool ParsingScheduled; - private bool PermanentlyPaused; - private bool ShouldResumeFarming = true; + // We aim to have a maximum of 2 tasks, one already parsing, and one waiting in the queue + // This way we can call this function as many times as needed e.g. because of Steam events + ShouldResumeFarming = true; - internal CardsFarmer(Bot bot) { - Bot = bot ?? throw new ArgumentNullException(nameof(bot)); - - byte idleFarmingPeriod = ASF.GlobalConfig?.IdleFarmingPeriod ?? GlobalConfig.DefaultIdleFarmingPeriod; - - if (idleFarmingPeriod > 0) { - IdleFarmingTimer = new Timer( - CheckGamesForFarming, - null, - TimeSpan.FromHours(idleFarmingPeriod) + TimeSpan.FromSeconds(ASF.LoadBalancingDelay * Bot.Bots?.Count ?? 0), // Delay - TimeSpan.FromHours(idleFarmingPeriod) // Period - ); - } - } - - public async ValueTask DisposeAsync() { - // Those are objects that are always being created if constructor doesn't throw exception - EventSemaphore.Dispose(); - FarmingInitializationSemaphore.Dispose(); - FarmingResetSemaphore.Dispose(); - - // Those are objects that might be null and the check should be in-place - if (IdleFarmingTimer != null) { - await IdleFarmingTimer.DisposeAsync().ConfigureAwait(false); - } - } - - internal void OnDisconnected() { - if (!NowFarming) { + // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that + lock (EventSemaphore) { + if (ParsingScheduled) { return; } - Utilities.InBackground(StopFarming); + ParsingScheduled = true; } - internal async Task OnNewGameAdded() { - // This update has a potential to modify local ignores, therefore we need to purge our cache - LocallyIgnoredAppIDs.Clear(); - - // We aim to have a maximum of 2 tasks, one already parsing, and one waiting in the queue - // This way we can call this function as many times as needed e.g. because of Steam events - ShouldResumeFarming = true; + await EventSemaphore.WaitAsync().ConfigureAwait(false); + try { // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that lock (EventSemaphore) { - if (ParsingScheduled) { - return; - } - - ParsingScheduled = true; + ParsingScheduled = false; } - await EventSemaphore.WaitAsync().ConfigureAwait(false); - - try { - // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that - lock (EventSemaphore) { - ParsingScheduled = false; - } - - // If we're not farming yet, obviously it's worth it to make a check - if (!NowFarming) { - await StartFarming().ConfigureAwait(false); - - return; - } - - // We should restart the farming if the order or efficiency of the farming could be affected by the newly-activated product - // The order is affected when user uses farming order that isn't independent of the game data (it could alter the order in deterministic way if the game was considered in current queue) - // The efficiency is affected only in complex algorithm (entirely), as it depends on hours order that is not independent (as specified above) - if ((Bot.BotConfig.HoursUntilCardDrops > 0) || ((Bot.BotConfig.FarmingOrders.Count > 0) && Bot.BotConfig.FarmingOrders.Any(static farmingOrder => (farmingOrder != BotConfig.EFarmingOrder.Unordered) && (farmingOrder != BotConfig.EFarmingOrder.Random)))) { - await StopFarming().ConfigureAwait(false); - await StartFarming().ConfigureAwait(false); - } - } finally { - EventSemaphore.Release(); - } - } - - internal async Task OnNewItemsNotification() { - if (NowFarming) { - await FarmingInitializationSemaphore.WaitAsync().ConfigureAwait(false); - - try { - if (NowFarming) { - if (FarmingResetSemaphore.CurrentCount == 0) { - FarmingResetSemaphore.Release(); - } - - return; - } - } finally { - FarmingInitializationSemaphore.Release(); - } - } - - // If we're not farming, and we got new items, it's likely to be a booster pack or likewise - // In this case, perform a loot if user wants to do so - if (Bot.BotConfig.SendOnFarmingFinished && (Bot.BotConfig.LootableTypes.Count > 0)) { - await Bot.Actions.SendInventory(filterFunction: item => Bot.BotConfig.LootableTypes.Contains(item.Type)).ConfigureAwait(false); - } - } - - internal async Task Pause(bool permanent) { - if (permanent) { - PermanentlyPaused = true; - } - - Paused = true; - + // If we're not farming yet, obviously it's worth it to make a check if (!NowFarming) { + await StartFarming().ConfigureAwait(false); + return; } - await StopFarming().ConfigureAwait(false); + // We should restart the farming if the order or efficiency of the farming could be affected by the newly-activated product + // The order is affected when user uses farming order that isn't independent of the game data (it could alter the order in deterministic way if the game was considered in current queue) + // The efficiency is affected only in complex algorithm (entirely), as it depends on hours order that is not independent (as specified above) + if ((Bot.BotConfig.HoursUntilCardDrops > 0) || ((Bot.BotConfig.FarmingOrders.Count > 0) && Bot.BotConfig.FarmingOrders.Any(static farmingOrder => (farmingOrder != BotConfig.EFarmingOrder.Unordered) && (farmingOrder != BotConfig.EFarmingOrder.Random)))) { + await StopFarming().ConfigureAwait(false); + await StartFarming().ConfigureAwait(false); + } + } finally { + EventSemaphore.Release(); + } + } + + internal async Task OnNewItemsNotification() { + if (NowFarming) { + await FarmingInitializationSemaphore.WaitAsync().ConfigureAwait(false); + + try { + if (NowFarming) { + if (FarmingResetSemaphore.CurrentCount == 0) { + FarmingResetSemaphore.Release(); + } + + return; + } + } finally { + FarmingInitializationSemaphore.Release(); + } } - internal async Task Resume(bool userAction) { - if (PermanentlyPaused) { - if (!userAction) { - Bot.ArchiLogger.LogGenericInfo(Strings.IgnoredPermanentPauseEnabled); + // If we're not farming, and we got new items, it's likely to be a booster pack or likewise + // In this case, perform a loot if user wants to do so + if (Bot.BotConfig.SendOnFarmingFinished && (Bot.BotConfig.LootableTypes.Count > 0)) { + await Bot.Actions.SendInventory(filterFunction: item => Bot.BotConfig.LootableTypes.Contains(item.Type)).ConfigureAwait(false); + } + } - return false; - } + internal async Task Pause(bool permanent) { + if (permanent) { + PermanentlyPaused = true; + } - PermanentlyPaused = false; - } + Paused = true; - Paused = false; + if (!NowFarming) { + return; + } - if (NowFarming) { - return true; - } + await StopFarming().ConfigureAwait(false); + } + + internal async Task Resume(bool userAction) { + if (PermanentlyPaused) { + if (!userAction) { + Bot.ArchiLogger.LogGenericInfo(Strings.IgnoredPermanentPauseEnabled); - if (!userAction && !ShouldResumeFarming) { return false; } - await StartFarming().ConfigureAwait(false); + PermanentlyPaused = false; + } + Paused = false; + + if (NowFarming) { return true; } - internal void SetInitialState(bool paused) { - PermanentlyPaused = Paused = paused; - ShouldResumeFarming = true; + if (!userAction && !ShouldResumeFarming) { + return false; } - internal async Task StartFarming() { + await StartFarming().ConfigureAwait(false); + + return true; + } + + internal void SetInitialState(bool paused) { + PermanentlyPaused = Paused = paused; + ShouldResumeFarming = true; + } + + internal async Task StartFarming() { + if (NowFarming || Paused || !Bot.IsPlayingPossible) { + return; + } + + if (!Bot.CanReceiveSteamCards || (Bot.BotConfig.FarmPriorityQueueOnly && (Bot.BotDatabase.IdlingPriorityAppIDs.Count == 0))) { + Bot.ArchiLogger.LogGenericInfo(Strings.NothingToIdle); + await Bot.OnFarmingFinished(false).ConfigureAwait(false); + + return; + } + + await FarmingInitializationSemaphore.WaitAsync().ConfigureAwait(false); + + try { if (NowFarming || Paused || !Bot.IsPlayingPossible) { return; } - if (!Bot.CanReceiveSteamCards || (Bot.BotConfig.FarmPriorityQueueOnly && (Bot.BotDatabase.IdlingPriorityAppIDs.Count == 0))) { + bool? isAnythingToFarm = await IsAnythingToFarm().ConfigureAwait(false); + + if (!isAnythingToFarm.HasValue) { + return; + } + + if (!isAnythingToFarm.Value) { Bot.ArchiLogger.LogGenericInfo(Strings.NothingToIdle); await Bot.OnFarmingFinished(false).ConfigureAwait(false); return; } - await FarmingInitializationSemaphore.WaitAsync().ConfigureAwait(false); + if (GamesToFarm.Count == 0) { + Bot.ArchiLogger.LogNullError(nameof(GamesToFarm)); - try { - if (NowFarming || Paused || !Bot.IsPlayingPossible) { - return; + return; + } + + // This is the last moment for final check if we can farm + if (!Bot.IsPlayingPossible) { + Bot.ArchiLogger.LogGenericInfo(Strings.PlayingNotAvailable); + + return; + } + + if (Bot.PlayingWasBlocked) { + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotExtraIdlingCooldown, TimeSpan.FromSeconds(Bot.MinPlayingBlockedTTL).ToHumanReadable())); + + for (byte i = 0; (i < Bot.MinPlayingBlockedTTL) && Bot.IsPlayingPossible && Bot.PlayingWasBlocked; i++) { + await Task.Delay(1000).ConfigureAwait(false); } - bool? isAnythingToFarm = await IsAnythingToFarm().ConfigureAwait(false); - - if (!isAnythingToFarm.HasValue) { - return; - } - - if (!isAnythingToFarm.Value) { - Bot.ArchiLogger.LogGenericInfo(Strings.NothingToIdle); - await Bot.OnFarmingFinished(false).ConfigureAwait(false); - - return; - } - - if (GamesToFarm.Count == 0) { - Bot.ArchiLogger.LogNullError(nameof(GamesToFarm)); - - return; - } - - // This is the last moment for final check if we can farm if (!Bot.IsPlayingPossible) { Bot.ArchiLogger.LogGenericInfo(Strings.PlayingNotAvailable); return; } - - if (Bot.PlayingWasBlocked) { - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotExtraIdlingCooldown, TimeSpan.FromSeconds(Bot.MinPlayingBlockedTTL).ToHumanReadable())); - - for (byte i = 0; (i < Bot.MinPlayingBlockedTTL) && Bot.IsPlayingPossible && Bot.PlayingWasBlocked; i++) { - await Task.Delay(1000).ConfigureAwait(false); - } - - if (!Bot.IsPlayingPossible) { - Bot.ArchiLogger.LogGenericInfo(Strings.PlayingNotAvailable); - - return; - } - } - - KeepFarming = NowFarming = true; - Utilities.InBackground(Farm, true); - - await PluginsCore.OnBotFarmingStarted(Bot).ConfigureAwait(false); - } finally { - FarmingInitializationSemaphore.Release(); } + + KeepFarming = NowFarming = true; + Utilities.InBackground(Farm, true); + + await PluginsCore.OnBotFarmingStarted(Bot).ConfigureAwait(false); + } finally { + FarmingInitializationSemaphore.Release(); + } + } + + internal async Task StopFarming() { + if (!NowFarming) { + return; } - internal async Task StopFarming() { + await FarmingInitializationSemaphore.WaitAsync().ConfigureAwait(false); + + try { if (!NowFarming) { return; } - await FarmingInitializationSemaphore.WaitAsync().ConfigureAwait(false); + KeepFarming = false; - try { - if (!NowFarming) { - return; + for (byte i = 0; (i < byte.MaxValue) && NowFarming; i++) { + if (FarmingResetSemaphore.CurrentCount == 0) { + FarmingResetSemaphore.Release(); } - KeepFarming = false; - - for (byte i = 0; (i < byte.MaxValue) && NowFarming; i++) { - if (FarmingResetSemaphore.CurrentCount == 0) { - FarmingResetSemaphore.Release(); - } - - await Task.Delay(1000).ConfigureAwait(false); - } - - if (NowFarming) { - Bot.ArchiLogger.LogGenericError(Strings.WarningFailed); - NowFarming = false; - } - - Bot.ArchiLogger.LogGenericInfo(Strings.IdlingStopped); - await Bot.OnFarmingStopped().ConfigureAwait(false); - } finally { - FarmingInitializationSemaphore.Release(); + await Task.Delay(1000).ConfigureAwait(false); } + + if (NowFarming) { + Bot.ArchiLogger.LogGenericError(Strings.WarningFailed); + NowFarming = false; + } + + Bot.ArchiLogger.LogGenericInfo(Strings.IdlingStopped); + await Bot.OnFarmingStopped().ConfigureAwait(false); + } finally { + FarmingInitializationSemaphore.Release(); + } + } + + private async Task CheckGame(uint appID, string name, float hours, byte badgeLevel) { + if (appID == 0) { + throw new ArgumentOutOfRangeException(nameof(appID)); } - private async Task CheckGame(uint appID, string name, float hours, byte badgeLevel) { - if (appID == 0) { - throw new ArgumentOutOfRangeException(nameof(appID)); + if (string.IsNullOrEmpty(name)) { + throw new ArgumentNullException(nameof(name)); + } + + if (hours < 0) { + throw new ArgumentOutOfRangeException(nameof(hours)); + } + + ushort? cardsRemaining = await GetCardsRemaining(appID).ConfigureAwait(false); + + switch (cardsRemaining) { + case null: + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningCouldNotCheckCardsStatus, appID, name)); + + return; + case 0: + return; + default: + GamesToFarm.Add(new Game(appID, name, hours, cardsRemaining.Value, badgeLevel)); + + break; + } + } + + private async void CheckGamesForFarming(object? state = null) { + if (NowFarming || Paused || !Bot.IsConnectedAndLoggedOn) { + return; + } + + await StartFarming().ConfigureAwait(false); + } + + private async Task CheckPage(IDocument htmlDocument, ISet parsedAppIDs) { + if (htmlDocument == null) { + throw new ArgumentNullException(nameof(htmlDocument)); + } + + if (parsedAppIDs == null) { + throw new ArgumentNullException(nameof(parsedAppIDs)); + } + + IEnumerable htmlNodes = htmlDocument.SelectNodes("//div[@class='badge_row_inner']"); + + HashSet? backgroundTasks = null; + + foreach (IElement htmlNode in htmlNodes) { + IElement? statsNode = htmlNode.SelectSingleElementNode(".//div[@class='badge_title_stats_content']"); + IElement? appIDNode = statsNode?.SelectSingleElementNode(".//div[@class='card_drop_info_dialog']"); + + if (appIDNode == null) { + // It's just a badge, nothing more + continue; } - if (string.IsNullOrEmpty(name)) { - throw new ArgumentNullException(nameof(name)); + string? appIDText = appIDNode.GetAttribute("id"); + + if (string.IsNullOrEmpty(appIDText)) { + Bot.ArchiLogger.LogNullError(nameof(appIDText)); + + continue; } - if (hours < 0) { - throw new ArgumentOutOfRangeException(nameof(hours)); + string[] appIDSplitted = appIDText.Split('_'); + + if (appIDSplitted.Length < 5) { + Bot.ArchiLogger.LogNullError(nameof(appIDSplitted)); + + continue; } - ushort? cardsRemaining = await GetCardsRemaining(appID).ConfigureAwait(false); + appIDText = appIDSplitted[4]; - switch (cardsRemaining) { - case null: - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningCouldNotCheckCardsStatus, appID, name)); + if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { + Bot.ArchiLogger.LogNullError(nameof(appID)); - return; - case 0: - return; - default: - GamesToFarm.Add(new Game(appID, name, hours, cardsRemaining.Value, badgeLevel)); + continue; + } + + if (!parsedAppIDs.Add(appID)) { + // Another task has already handled this appID + continue; + } + + if (SalesBlacklist.Contains(appID) || (ASF.GlobalConfig?.Blacklist.Contains(appID) == true) || Bot.IsBlacklistedFromIdling(appID) || (Bot.BotConfig.FarmPriorityQueueOnly && !Bot.IsPriorityIdling(appID))) { + // We're configured to ignore this appID, so skip it + continue; + } + + bool ignored = false; + + foreach (ConcurrentDictionary sourceOfIgnoredAppIDs in SourcesOfIgnoredAppIDs) { + if (!sourceOfIgnoredAppIDs.TryGetValue(appID, out DateTime ignoredUntil)) { + continue; + } + + if (ignoredUntil > DateTime.UtcNow) { + // This game is still ignored + ignored = true; break; - } - } + } - private async void CheckGamesForFarming(object? state = null) { - if (NowFarming || Paused || !Bot.IsConnectedAndLoggedOn) { - return; + // This game served its time as being ignored + sourceOfIgnoredAppIDs.TryRemove(appID, out _); } - await StartFarming().ConfigureAwait(false); - } - - private async Task CheckPage(IDocument htmlDocument, ISet parsedAppIDs) { - if (htmlDocument == null) { - throw new ArgumentNullException(nameof(htmlDocument)); + if (ignored) { + continue; } - if (parsedAppIDs == null) { - throw new ArgumentNullException(nameof(parsedAppIDs)); + // Cards + IElement? progressNode = statsNode?.SelectSingleElementNode(".//span[@class='progress_info_bold']"); + + if (progressNode == null) { + Bot.ArchiLogger.LogNullError(nameof(progressNode)); + + continue; } - IEnumerable htmlNodes = htmlDocument.SelectNodes("//div[@class='badge_row_inner']"); + string progressText = progressNode.TextContent; - HashSet? backgroundTasks = null; + if (string.IsNullOrEmpty(progressText)) { + Bot.ArchiLogger.LogNullError(nameof(progressText)); - foreach (IElement htmlNode in htmlNodes) { - IElement? statsNode = htmlNode.SelectSingleElementNode(".//div[@class='badge_title_stats_content']"); - IElement? appIDNode = statsNode?.SelectSingleElementNode(".//div[@class='card_drop_info_dialog']"); + continue; + } - if (appIDNode == null) { - // It's just a badge, nothing more + ushort cardsRemaining = 0; + Match progressMatch = Regex.Match(progressText, @"\d+"); + + // This might fail if we have no card drops remaining, 0 is not printed in this case - that's fine + if (progressMatch.Success) { + if (!ushort.TryParse(progressMatch.Value, out cardsRemaining) || (cardsRemaining == 0)) { + Bot.ArchiLogger.LogNullError(nameof(cardsRemaining)); + + continue; + } + } + + if (cardsRemaining == 0) { + // Normally we'd trust this information and simply skip the rest + // However, Steam is so fucked up that we can't simply assume that it's correct + // It's entirely possible that actual game page has different info, and badge page lied to us + // We can't check every single game though, as this will literally kill people with cards from games they don't own + // Luckily for us, it seems to happen only with some specific games + if (!UntrustedAppIDs.Contains(appID)) { continue; } - string? appIDText = appIDNode.GetAttribute("id"); + // To save us on extra work, check cards earned so far first + IElement? cardsEarnedNode = statsNode?.SelectSingleElementNode(".//div[@class='card_drop_info_header']"); - if (string.IsNullOrEmpty(appIDText)) { - Bot.ArchiLogger.LogNullError(nameof(appIDText)); + if (cardsEarnedNode == null) { + Bot.ArchiLogger.LogNullError(nameof(cardsEarnedNode)); continue; } - string[] appIDSplitted = appIDText.Split('_'); + string cardsEarnedText = cardsEarnedNode.TextContent; - if (appIDSplitted.Length < 5) { - Bot.ArchiLogger.LogNullError(nameof(appIDSplitted)); + if (string.IsNullOrEmpty(cardsEarnedText)) { + Bot.ArchiLogger.LogNullError(nameof(cardsEarnedText)); continue; } - appIDText = appIDSplitted[4]; + Match cardsEarnedMatch = Regex.Match(cardsEarnedText, @"\d+"); - if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { - Bot.ArchiLogger.LogNullError(nameof(appID)); + if (!cardsEarnedMatch.Success) { + Bot.ArchiLogger.LogNullError(nameof(cardsEarnedMatch)); continue; } - if (!parsedAppIDs.Add(appID)) { - // Another task has already handled this appID - continue; - } - - if (SalesBlacklist.Contains(appID) || (ASF.GlobalConfig?.Blacklist.Contains(appID) == true) || Bot.IsBlacklistedFromIdling(appID) || (Bot.BotConfig.FarmPriorityQueueOnly && !Bot.IsPriorityIdling(appID))) { - // We're configured to ignore this appID, so skip it - continue; - } - - bool ignored = false; - - foreach (ConcurrentDictionary sourceOfIgnoredAppIDs in SourcesOfIgnoredAppIDs) { - if (!sourceOfIgnoredAppIDs.TryGetValue(appID, out DateTime ignoredUntil)) { - continue; - } - - if (ignoredUntil > DateTime.UtcNow) { - // This game is still ignored - ignored = true; - - break; - } - - // This game served its time as being ignored - sourceOfIgnoredAppIDs.TryRemove(appID, out _); - } - - if (ignored) { - continue; - } - - // Cards - IElement? progressNode = statsNode?.SelectSingleElementNode(".//span[@class='progress_info_bold']"); - - if (progressNode == null) { - Bot.ArchiLogger.LogNullError(nameof(progressNode)); + if (!ushort.TryParse(cardsEarnedMatch.Value, out ushort cardsEarned)) { + Bot.ArchiLogger.LogNullError(nameof(cardsEarned)); continue; } - string progressText = progressNode.TextContent; - - if (string.IsNullOrEmpty(progressText)) { - Bot.ArchiLogger.LogNullError(nameof(progressText)); - + if (cardsEarned > 0) { + // If we already earned some cards for this game, it's very likely that it's done + // Let's hope that trusting cardsRemaining AND cardsEarned is enough + // If I ever hear that it's not, I'll most likely need a doctor continue; } - ushort cardsRemaining = 0; - Match progressMatch = Regex.Match(progressText, @"\d+"); + // If we have no cardsRemaining and no cardsEarned, it's either: + // - A game we don't own physically, but we have cards from it in inventory + // - F2P game that we didn't spend any money in, but we have cards from it in inventory + // - Steam issue + // As you can guess, we must follow the rest of the logic in case of Steam issue + } - // This might fail if we have no card drops remaining, 0 is not printed in this case - that's fine - if (progressMatch.Success) { - if (!ushort.TryParse(progressMatch.Value, out cardsRemaining) || (cardsRemaining == 0)) { - Bot.ArchiLogger.LogNullError(nameof(cardsRemaining)); + // Hours + IElement? timeNode = statsNode?.SelectSingleElementNode(".//div[@class='badge_title_stats_playtime']"); - continue; - } - } + if (timeNode == null) { + Bot.ArchiLogger.LogNullError(nameof(timeNode)); - if (cardsRemaining == 0) { - // Normally we'd trust this information and simply skip the rest - // However, Steam is so fucked up that we can't simply assume that it's correct - // It's entirely possible that actual game page has different info, and badge page lied to us - // We can't check every single game though, as this will literally kill people with cards from games they don't own - // Luckily for us, it seems to happen only with some specific games - if (!UntrustedAppIDs.Contains(appID)) { - continue; - } + continue; + } - // To save us on extra work, check cards earned so far first - IElement? cardsEarnedNode = statsNode?.SelectSingleElementNode(".//div[@class='card_drop_info_header']"); + string hoursText = timeNode.TextContent; - if (cardsEarnedNode == null) { - Bot.ArchiLogger.LogNullError(nameof(cardsEarnedNode)); + if (string.IsNullOrEmpty(hoursText)) { + Bot.ArchiLogger.LogNullError(nameof(hoursText)); - continue; - } + continue; + } - string cardsEarnedText = cardsEarnedNode.TextContent; + float hours = 0.0F; + Match hoursMatch = Regex.Match(hoursText, @"[0-9\.,]+"); - if (string.IsNullOrEmpty(cardsEarnedText)) { - Bot.ArchiLogger.LogNullError(nameof(cardsEarnedText)); - - continue; - } - - Match cardsEarnedMatch = Regex.Match(cardsEarnedText, @"\d+"); - - if (!cardsEarnedMatch.Success) { - Bot.ArchiLogger.LogNullError(nameof(cardsEarnedMatch)); - - continue; - } - - if (!ushort.TryParse(cardsEarnedMatch.Value, out ushort cardsEarned)) { - Bot.ArchiLogger.LogNullError(nameof(cardsEarned)); - - continue; - } - - if (cardsEarned > 0) { - // If we already earned some cards for this game, it's very likely that it's done - // Let's hope that trusting cardsRemaining AND cardsEarned is enough - // If I ever hear that it's not, I'll most likely need a doctor - continue; - } - - // If we have no cardsRemaining and no cardsEarned, it's either: - // - A game we don't own physically, but we have cards from it in inventory - // - F2P game that we didn't spend any money in, but we have cards from it in inventory - // - Steam issue - // As you can guess, we must follow the rest of the logic in case of Steam issue - } - - // Hours - IElement? timeNode = statsNode?.SelectSingleElementNode(".//div[@class='badge_title_stats_playtime']"); - - if (timeNode == null) { - Bot.ArchiLogger.LogNullError(nameof(timeNode)); + // This might fail if we have exactly 0.0 hours played, as it's not printed in that case - that's fine + if (hoursMatch.Success) { + if (!float.TryParse(hoursMatch.Value, NumberStyles.Number, CultureInfo.InvariantCulture, out hours) || (hours <= 0.0F)) { + Bot.ArchiLogger.LogNullError(nameof(hours)); continue; } + } - string hoursText = timeNode.TextContent; + // Names + IElement? nameNode = statsNode?.SelectSingleElementNode("(.//div[@class='card_drop_info_body'])[last()]"); - if (string.IsNullOrEmpty(hoursText)) { - Bot.ArchiLogger.LogNullError(nameof(hoursText)); + if (nameNode == null) { + Bot.ArchiLogger.LogNullError(nameof(nameNode)); - continue; - } + continue; + } - float hours = 0.0F; - Match hoursMatch = Regex.Match(hoursText, @"[0-9\.,]+"); + string name = nameNode.TextContent; - // This might fail if we have exactly 0.0 hours played, as it's not printed in that case - that's fine - if (hoursMatch.Success) { - if (!float.TryParse(hoursMatch.Value, NumberStyles.Number, CultureInfo.InvariantCulture, out hours) || (hours <= 0.0F)) { - Bot.ArchiLogger.LogNullError(nameof(hours)); + if (string.IsNullOrEmpty(name)) { + Bot.ArchiLogger.LogNullError(nameof(name)); - continue; - } - } + continue; + } - // Names - IElement? nameNode = statsNode?.SelectSingleElementNode("(.//div[@class='card_drop_info_body'])[last()]"); + // We handle two cases here - normal one, and no card drops remaining + int nameStartIndex = name.IndexOf(" by playing ", StringComparison.Ordinal); - if (nameNode == null) { - Bot.ArchiLogger.LogNullError(nameof(nameNode)); - - continue; - } - - string name = nameNode.TextContent; - - if (string.IsNullOrEmpty(name)) { - Bot.ArchiLogger.LogNullError(nameof(name)); - - continue; - } - - // We handle two cases here - normal one, and no card drops remaining - int nameStartIndex = name.IndexOf(" by playing ", StringComparison.Ordinal); + if (nameStartIndex <= 0) { + nameStartIndex = name.IndexOf("You don't have any more drops remaining for ", StringComparison.Ordinal); if (nameStartIndex <= 0) { - nameStartIndex = name.IndexOf("You don't have any more drops remaining for ", StringComparison.Ordinal); - - if (nameStartIndex <= 0) { - Bot.ArchiLogger.LogNullError(nameof(nameStartIndex)); - - continue; - } - - nameStartIndex += 32; // + 12 below - } - - nameStartIndex += 12; - - int nameEndIndex = name.LastIndexOf('.'); - - if (nameEndIndex <= nameStartIndex) { - Bot.ArchiLogger.LogNullError(nameof(nameEndIndex)); + Bot.ArchiLogger.LogNullError(nameof(nameStartIndex)); continue; } - name = WebUtility.HtmlDecode(name[nameStartIndex..nameEndIndex]); + nameStartIndex += 32; // + 12 below + } - if (string.IsNullOrEmpty(name)) { - Bot.ArchiLogger.LogNullError(nameof(name)); + nameStartIndex += 12; + + int nameEndIndex = name.LastIndexOf('.'); + + if (nameEndIndex <= nameStartIndex) { + Bot.ArchiLogger.LogNullError(nameof(nameEndIndex)); + + continue; + } + + name = WebUtility.HtmlDecode(name[nameStartIndex..nameEndIndex]); + + if (string.IsNullOrEmpty(name)) { + Bot.ArchiLogger.LogNullError(nameof(name)); + + continue; + } + + // Levels + byte badgeLevel = 0; + + IElement? levelNode = htmlNode.SelectSingleElementNode(".//div[@class='badge_info_description']/div[2]"); + + if (levelNode != null) { + // There is no levelNode if we didn't craft that badge yet (level 0) + string levelText = levelNode.TextContent; + + if (string.IsNullOrEmpty(levelText)) { + Bot.ArchiLogger.LogNullError(nameof(levelText)); continue; } - // Levels - byte badgeLevel = 0; + int levelStartIndex = levelText.IndexOf("Level ", StringComparison.OrdinalIgnoreCase); - IElement? levelNode = htmlNode.SelectSingleElementNode(".//div[@class='badge_info_description']/div[2]"); + if (levelStartIndex < 0) { + Bot.ArchiLogger.LogNullError(nameof(levelStartIndex)); - if (levelNode != null) { - // There is no levelNode if we didn't craft that badge yet (level 0) - string levelText = levelNode.TextContent; - - if (string.IsNullOrEmpty(levelText)) { - Bot.ArchiLogger.LogNullError(nameof(levelText)); - - continue; - } - - int levelStartIndex = levelText.IndexOf("Level ", StringComparison.OrdinalIgnoreCase); - - if (levelStartIndex < 0) { - Bot.ArchiLogger.LogNullError(nameof(levelStartIndex)); - - continue; - } - - levelStartIndex += 6; - - if (levelText.Length <= levelStartIndex) { - Bot.ArchiLogger.LogNullError(nameof(levelStartIndex)); - - continue; - } - - int levelEndIndex = levelText.IndexOf(',', levelStartIndex); - - if (levelEndIndex <= levelStartIndex) { - Bot.ArchiLogger.LogNullError(nameof(levelEndIndex)); - - continue; - } - - levelText = levelText[levelStartIndex..levelEndIndex]; - - if (!byte.TryParse(levelText, out badgeLevel) || badgeLevel is 0 or > 5) { - Bot.ArchiLogger.LogNullError(nameof(badgeLevel)); - - continue; - } + continue; } - // Done with parsing, we have two possible cases here - // Either we have decent info about appID, name, hours, cardsRemaining (cardsRemaining > 0) and level - // OR we strongly believe that Steam lied to us, in this case we will need to check game individually (cardsRemaining == 0) - if (cardsRemaining > 0) { - GamesToFarm.Add(new Game(appID, name, hours, cardsRemaining, badgeLevel)); - } else { - Task task = CheckGame(appID, name, hours, badgeLevel); + levelStartIndex += 6; - switch (ASF.GlobalConfig?.OptimizationMode) { - case GlobalConfig.EOptimizationMode.MinMemoryUsage: - await task.ConfigureAwait(false); + if (levelText.Length <= levelStartIndex) { + Bot.ArchiLogger.LogNullError(nameof(levelStartIndex)); - break; - default: - backgroundTasks ??= new HashSet(); + continue; + } - backgroundTasks.Add(task); + int levelEndIndex = levelText.IndexOf(',', levelStartIndex); - break; - } + if (levelEndIndex <= levelStartIndex) { + Bot.ArchiLogger.LogNullError(nameof(levelEndIndex)); + + continue; + } + + levelText = levelText[levelStartIndex..levelEndIndex]; + + if (!byte.TryParse(levelText, out badgeLevel) || badgeLevel is 0 or > 5) { + Bot.ArchiLogger.LogNullError(nameof(badgeLevel)); + + continue; } } - // If we have any background tasks, wait for them - if (backgroundTasks?.Count > 0) { - await Task.WhenAll(backgroundTasks).ConfigureAwait(false); + // Done with parsing, we have two possible cases here + // Either we have decent info about appID, name, hours, cardsRemaining (cardsRemaining > 0) and level + // OR we strongly believe that Steam lied to us, in this case we will need to check game individually (cardsRemaining == 0) + if (cardsRemaining > 0) { + GamesToFarm.Add(new Game(appID, name, hours, cardsRemaining, badgeLevel)); + } else { + Task task = CheckGame(appID, name, hours, badgeLevel); + + switch (ASF.GlobalConfig?.OptimizationMode) { + case GlobalConfig.EOptimizationMode.MinMemoryUsage: + await task.ConfigureAwait(false); + + break; + default: + backgroundTasks ??= new HashSet(); + + backgroundTasks.Add(task); + + break; + } } } - private async Task CheckPage(byte page, ISet parsedAppIDs) { - if (page == 0) { - throw new ArgumentOutOfRangeException(nameof(page)); - } + // If we have any background tasks, wait for them + if (backgroundTasks?.Count > 0) { + await Task.WhenAll(backgroundTasks).ConfigureAwait(false); + } + } - if (parsedAppIDs == null) { - throw new ArgumentNullException(nameof(parsedAppIDs)); - } - - using IDocument? htmlDocument = await Bot.ArchiWebHandler.GetBadgePage(page).ConfigureAwait(false); - - if (htmlDocument == null) { - return; - } - - await CheckPage(htmlDocument, parsedAppIDs).ConfigureAwait(false); + private async Task CheckPage(byte page, ISet parsedAppIDs) { + if (page == 0) { + throw new ArgumentOutOfRangeException(nameof(page)); } - private async Task Farm() { - do { - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.GamesToIdle, GamesToFarm.Count, GamesToFarm.Sum(static game => game.CardsRemaining), TimeRemaining.ToHumanReadable())); + if (parsedAppIDs == null) { + throw new ArgumentNullException(nameof(parsedAppIDs)); + } - // Now the algorithm used for farming depends on whether account is restricted or not - if (Bot.BotConfig.HoursUntilCardDrops > 0) { - // If we have restricted card drops, we use complex algorithm - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.ChosenFarmingAlgorithm, "Complex")); + using IDocument? htmlDocument = await Bot.ArchiWebHandler.GetBadgePage(page).ConfigureAwait(false); - while (GamesToFarm.Count > 0) { - // Initially we're going to farm games that passed our HoursUntilCardDrops - // This block is almost identical to Simple algorithm, we just copy appropriate items from GamesToFarm into innerGamesToFarm - HashSet innerGamesToFarm = GamesToFarm.Where(game => game.HoursPlayed >= Bot.BotConfig.HoursUntilCardDrops).ToHashSet(); + if (htmlDocument == null) { + return; + } - while (innerGamesToFarm.Count > 0) { - Game game = innerGamesToFarm.First(); + await CheckPage(htmlDocument, parsedAppIDs).ConfigureAwait(false); + } - if (!await IsPlayableGame(game).ConfigureAwait(false)) { - GamesToFarm.Remove(game); - innerGamesToFarm.Remove(game); + private async Task Farm() { + do { + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.GamesToIdle, GamesToFarm.Count, GamesToFarm.Sum(static game => game.CardsRemaining), TimeRemaining.ToHumanReadable())); - continue; - } + // Now the algorithm used for farming depends on whether account is restricted or not + if (Bot.BotConfig.HoursUntilCardDrops > 0) { + // If we have restricted card drops, we use complex algorithm + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.ChosenFarmingAlgorithm, "Complex")); - if (await FarmSolo(game).ConfigureAwait(false)) { - innerGamesToFarm.Remove(game); + while (GamesToFarm.Count > 0) { + // Initially we're going to farm games that passed our HoursUntilCardDrops + // This block is almost identical to Simple algorithm, we just copy appropriate items from GamesToFarm into innerGamesToFarm + HashSet innerGamesToFarm = GamesToFarm.Where(game => game.HoursPlayed >= Bot.BotConfig.HoursUntilCardDrops).ToHashSet(); - continue; - } - - NowFarming = false; - - return; - } - - // At this point we have no games past HoursUntilCardDrops anymore, so we're going to farm all other ones - // In order to maximize efficiency, we'll take games that are closest to our HoursPlayed first - - // We must call ToList() here as we can't remove items while enumerating - foreach (Game game in GamesToFarm.OrderByDescending(static game => game.HoursPlayed).ToList()) { - if (!await IsPlayableGame(game).ConfigureAwait(false)) { - GamesToFarm.Remove(game); - - continue; - } - - innerGamesToFarm.Add(game); - - // There is no need to check all games at once, allow maximum of MaxGamesPlayedConcurrently in this batch - if (innerGamesToFarm.Count >= ArchiHandler.MaxGamesPlayedConcurrently) { - break; - } - } - - // If we have no playable games to farm, we're done - if (innerGamesToFarm.Count == 0) { - break; - } - - // Otherwise, we farm our innerGamesToFarm batch until any game hits HoursUntilCardDrops - if (await FarmMultiple(innerGamesToFarm).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.IdlingFinishedForGames, string.Join(", ", innerGamesToFarm.Select(static game => game.AppID)))); - } else { - NowFarming = false; - - return; - } - } - } else { - // If we have unrestricted card drops, we use simple algorithm - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.ChosenFarmingAlgorithm, "Simple")); - - while (GamesToFarm.Count > 0) { - // In simple algorithm we're going to farm anything that is playable, regardless of hours - Game game = GamesToFarm.First(); + while (innerGamesToFarm.Count > 0) { + Game game = innerGamesToFarm.First(); if (!await IsPlayableGame(game).ConfigureAwait(false)) { GamesToFarm.Remove(game); + innerGamesToFarm.Remove(game); continue; } if (await FarmSolo(game).ConfigureAwait(false)) { + innerGamesToFarm.Remove(game); + continue; } @@ -818,418 +763,473 @@ namespace ArchiSteamFarm.Steam.Cards { return; } + + // At this point we have no games past HoursUntilCardDrops anymore, so we're going to farm all other ones + // In order to maximize efficiency, we'll take games that are closest to our HoursPlayed first + + // We must call ToList() here as we can't remove items while enumerating + foreach (Game game in GamesToFarm.OrderByDescending(static game => game.HoursPlayed).ToList()) { + if (!await IsPlayableGame(game).ConfigureAwait(false)) { + GamesToFarm.Remove(game); + + continue; + } + + innerGamesToFarm.Add(game); + + // There is no need to check all games at once, allow maximum of MaxGamesPlayedConcurrently in this batch + if (innerGamesToFarm.Count >= ArchiHandler.MaxGamesPlayedConcurrently) { + break; + } + } + + // If we have no playable games to farm, we're done + if (innerGamesToFarm.Count == 0) { + break; + } + + // Otherwise, we farm our innerGamesToFarm batch until any game hits HoursUntilCardDrops + if (await FarmMultiple(innerGamesToFarm).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.IdlingFinishedForGames, string.Join(", ", innerGamesToFarm.Select(static game => game.AppID)))); + } else { + NowFarming = false; + + return; + } } - } while ((await IsAnythingToFarm().ConfigureAwait(false)).GetValueOrDefault()); - - NowFarming = false; - - Bot.ArchiLogger.LogGenericInfo(Strings.IdlingFinished); - await Bot.OnFarmingFinished(true).ConfigureAwait(false); - } - - private async Task FarmCards(Game game) { - if (game == null) { - throw new ArgumentNullException(nameof(game)); - } - - if (game.AppID != game.PlayableAppID) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningIdlingGameMismatch, game.AppID, game.GameName, game.PlayableAppID)); - } - - await Bot.IdleGame(game).ConfigureAwait(false); - - bool success = true; - DateTime endFarmingDate = DateTime.UtcNow.AddHours(ASF.GlobalConfig?.MaxFarmingTime ?? GlobalConfig.DefaultMaxFarmingTime); - - while ((DateTime.UtcNow < endFarmingDate) && (await ShouldFarm(game).ConfigureAwait(false)).GetValueOrDefault(true)) { - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.StillIdling, game.AppID, game.GameName)); - - DateTime startFarmingPeriod = DateTime.UtcNow; - - if (await FarmingResetSemaphore.WaitAsync(((ASF.GlobalConfig?.FarmingDelay ?? GlobalConfig.DefaultFarmingDelay) * 60 * 1000) + (ExtraFarmingDelaySeconds * 1000)).ConfigureAwait(false)) { - success = KeepFarming; - } - - // Don't forget to update our GamesToFarm hours - game.HoursPlayed += (float) DateTime.UtcNow.Subtract(startFarmingPeriod).TotalHours; - - if (!success) { - break; - } - } - - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.StoppedIdling, game.AppID, game.GameName)); - - return success; - } - - private async Task FarmHours(IReadOnlyCollection games) { - if ((games == null) || (games.Count == 0)) { - throw new ArgumentNullException(nameof(games)); - } - - float maxHour = games.Max(static game => game.HoursPlayed); - - if (maxHour < 0) { - Bot.ArchiLogger.LogNullError(nameof(maxHour)); - - return false; - } - - if (maxHour >= Bot.BotConfig.HoursUntilCardDrops) { - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(maxHour))); - - return true; - } - - await Bot.IdleGames(games).ConfigureAwait(false); - - bool success = true; - - while (maxHour < Bot.BotConfig.HoursUntilCardDrops) { - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.StillIdlingList, string.Join(", ", games.Select(static game => game.AppID)))); - - DateTime startFarmingPeriod = DateTime.UtcNow; - - if (await FarmingResetSemaphore.WaitAsync(((ASF.GlobalConfig?.FarmingDelay ?? GlobalConfig.DefaultFarmingDelay) * 60 * 1000) + (ExtraFarmingDelaySeconds * 1000)).ConfigureAwait(false)) { - success = KeepFarming; - } - - // Don't forget to update our GamesToFarm hours - float timePlayed = (float) DateTime.UtcNow.Subtract(startFarmingPeriod).TotalHours; - - foreach (Game game in games) { - game.HoursPlayed += timePlayed; - } - - if (!success) { - break; - } - - maxHour += timePlayed; - } - - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.StoppedIdlingList, string.Join(", ", games.Select(static game => game.AppID)))); - - return success; - } - - private async Task FarmMultiple(IReadOnlyCollection games) { - if ((games == null) || (games.Count == 0)) { - throw new ArgumentNullException(nameof(games)); - } - - CurrentGamesFarming.ReplaceWith(games); - - if (games.Count == 1) { - Game game = games.First(); - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.NowIdling, game.AppID, game.GameName)); } else { - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.NowIdlingList, string.Join(", ", games.Select(static game => game.AppID)))); + // If we have unrestricted card drops, we use simple algorithm + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.ChosenFarmingAlgorithm, "Simple")); + + while (GamesToFarm.Count > 0) { + // In simple algorithm we're going to farm anything that is playable, regardless of hours + Game game = GamesToFarm.First(); + + if (!await IsPlayableGame(game).ConfigureAwait(false)) { + GamesToFarm.Remove(game); + + continue; + } + + if (await FarmSolo(game).ConfigureAwait(false)) { + continue; + } + + NowFarming = false; + + return; + } } + } while ((await IsAnythingToFarm().ConfigureAwait(false)).GetValueOrDefault()); - bool result = await FarmHours(games).ConfigureAwait(false); - CurrentGamesFarming.Clear(); + NowFarming = false; - return result; + Bot.ArchiLogger.LogGenericInfo(Strings.IdlingFinished); + await Bot.OnFarmingFinished(true).ConfigureAwait(false); + } + + private async Task FarmCards(Game game) { + if (game == null) { + throw new ArgumentNullException(nameof(game)); } - private async Task FarmSolo(Game game) { - if (game == null) { - throw new ArgumentNullException(nameof(game)); + if (game.AppID != game.PlayableAppID) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningIdlingGameMismatch, game.AppID, game.GameName, game.PlayableAppID)); + } + + await Bot.IdleGame(game).ConfigureAwait(false); + + bool success = true; + DateTime endFarmingDate = DateTime.UtcNow.AddHours(ASF.GlobalConfig?.MaxFarmingTime ?? GlobalConfig.DefaultMaxFarmingTime); + + while ((DateTime.UtcNow < endFarmingDate) && (await ShouldFarm(game).ConfigureAwait(false)).GetValueOrDefault(true)) { + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.StillIdling, game.AppID, game.GameName)); + + DateTime startFarmingPeriod = DateTime.UtcNow; + + if (await FarmingResetSemaphore.WaitAsync(((ASF.GlobalConfig?.FarmingDelay ?? GlobalConfig.DefaultFarmingDelay) * 60 * 1000) + (ExtraFarmingDelaySeconds * 1000)).ConfigureAwait(false)) { + success = KeepFarming; } - CurrentGamesFarming.Add(game); + // Don't forget to update our GamesToFarm hours + game.HoursPlayed += (float) DateTime.UtcNow.Subtract(startFarmingPeriod).TotalHours; - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.NowIdling, game.AppID, game.GameName)); - - bool result = await FarmCards(game).ConfigureAwait(false); - CurrentGamesFarming.Clear(); - - if (!result) { - return false; + if (!success) { + break; } + } - GamesToFarm.Remove(game); + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.StoppedIdling, game.AppID, game.GameName)); - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.IdlingFinishedForGame, game.AppID, game.GameName, TimeSpan.FromHours(game.HoursPlayed).ToHumanReadable())); + return success; + } + + private async Task FarmHours(IReadOnlyCollection games) { + if ((games == null) || (games.Count == 0)) { + throw new ArgumentNullException(nameof(games)); + } + + float maxHour = games.Max(static game => game.HoursPlayed); + + if (maxHour < 0) { + Bot.ArchiLogger.LogNullError(nameof(maxHour)); + + return false; + } + + if (maxHour >= Bot.BotConfig.HoursUntilCardDrops) { + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(maxHour))); return true; } - private async Task GetCardsRemaining(uint appID) { - if (appID == 0) { - throw new ArgumentOutOfRangeException(nameof(appID)); + await Bot.IdleGames(games).ConfigureAwait(false); + + bool success = true; + + while (maxHour < Bot.BotConfig.HoursUntilCardDrops) { + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.StillIdlingList, string.Join(", ", games.Select(static game => game.AppID)))); + + DateTime startFarmingPeriod = DateTime.UtcNow; + + if (await FarmingResetSemaphore.WaitAsync(((ASF.GlobalConfig?.FarmingDelay ?? GlobalConfig.DefaultFarmingDelay) * 60 * 1000) + (ExtraFarmingDelaySeconds * 1000)).ConfigureAwait(false)) { + success = KeepFarming; } - using IDocument? htmlDocument = await Bot.ArchiWebHandler.GetGameCardsPage(appID).ConfigureAwait(false); + // Don't forget to update our GamesToFarm hours + float timePlayed = (float) DateTime.UtcNow.Subtract(startFarmingPeriod).TotalHours; - IElement? progressNode = htmlDocument?.SelectSingleNode("//span[@class='progress_info_bold']"); - - if (progressNode == null) { - return null; + foreach (Game game in games) { + game.HoursPlayed += timePlayed; } - string progress = progressNode.TextContent; - - if (string.IsNullOrEmpty(progress)) { - Bot.ArchiLogger.LogNullError(nameof(progress)); - - return null; + if (!success) { + break; } - Match match = Regex.Match(progress, @"\d+"); - - if (!match.Success) { - return 0; - } - - if (!ushort.TryParse(match.Value, out ushort cardsRemaining) || (cardsRemaining == 0)) { - Bot.ArchiLogger.LogNullError(nameof(cardsRemaining)); - - return null; - } - - return cardsRemaining; + maxHour += timePlayed; } - private async Task IsAnythingToFarm() { - // Find the number of badge pages - Bot.ArchiLogger.LogGenericInfo(Strings.CheckingFirstBadgePage); + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.StoppedIdlingList, string.Join(", ", games.Select(static game => game.AppID)))); - using IDocument? htmlDocument = await Bot.ArchiWebHandler.GetBadgePage(1).ConfigureAwait(false); + return success; + } - if (htmlDocument == null) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningCouldNotCheckBadges); + private async Task FarmMultiple(IReadOnlyCollection games) { + if ((games == null) || (games.Count == 0)) { + throw new ArgumentNullException(nameof(games)); + } + + CurrentGamesFarming.ReplaceWith(games); + + if (games.Count == 1) { + Game game = games.First(); + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.NowIdling, game.AppID, game.GameName)); + } else { + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.NowIdlingList, string.Join(", ", games.Select(static game => game.AppID)))); + } + + bool result = await FarmHours(games).ConfigureAwait(false); + CurrentGamesFarming.Clear(); + + return result; + } + + private async Task FarmSolo(Game game) { + if (game == null) { + throw new ArgumentNullException(nameof(game)); + } + + CurrentGamesFarming.Add(game); + + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.NowIdling, game.AppID, game.GameName)); + + bool result = await FarmCards(game).ConfigureAwait(false); + CurrentGamesFarming.Clear(); + + if (!result) { + return false; + } + + GamesToFarm.Remove(game); + + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.IdlingFinishedForGame, game.AppID, game.GameName, TimeSpan.FromHours(game.HoursPlayed).ToHumanReadable())); + + return true; + } + + private async Task GetCardsRemaining(uint appID) { + if (appID == 0) { + throw new ArgumentOutOfRangeException(nameof(appID)); + } + + using IDocument? htmlDocument = await Bot.ArchiWebHandler.GetGameCardsPage(appID).ConfigureAwait(false); + + IElement? progressNode = htmlDocument?.SelectSingleNode("//span[@class='progress_info_bold']"); + + if (progressNode == null) { + return null; + } + + string progress = progressNode.TextContent; + + if (string.IsNullOrEmpty(progress)) { + Bot.ArchiLogger.LogNullError(nameof(progress)); + + return null; + } + + Match match = Regex.Match(progress, @"\d+"); + + if (!match.Success) { + return 0; + } + + if (!ushort.TryParse(match.Value, out ushort cardsRemaining) || (cardsRemaining == 0)) { + Bot.ArchiLogger.LogNullError(nameof(cardsRemaining)); + + return null; + } + + return cardsRemaining; + } + + private async Task IsAnythingToFarm() { + // Find the number of badge pages + Bot.ArchiLogger.LogGenericInfo(Strings.CheckingFirstBadgePage); + + using IDocument? htmlDocument = await Bot.ArchiWebHandler.GetBadgePage(1).ConfigureAwait(false); + + if (htmlDocument == null) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningCouldNotCheckBadges); + + return null; + } + + byte maxPages = 1; + + IElement? htmlNode = htmlDocument.SelectSingleNode("(//a[@class='pagelink'])[last()]"); + + if (htmlNode != null) { + string lastPage = htmlNode.TextContent; + + if (string.IsNullOrEmpty(lastPage)) { + Bot.ArchiLogger.LogNullError(nameof(lastPage)); return null; } - byte maxPages = 1; + if (!byte.TryParse(lastPage, out maxPages) || (maxPages == 0)) { + Bot.ArchiLogger.LogNullError(nameof(maxPages)); - IElement? htmlNode = htmlDocument.SelectSingleNode("(//a[@class='pagelink'])[last()]"); - - if (htmlNode != null) { - string lastPage = htmlNode.TextContent; - - if (string.IsNullOrEmpty(lastPage)) { - Bot.ArchiLogger.LogNullError(nameof(lastPage)); - - return null; - } - - if (!byte.TryParse(lastPage, out maxPages) || (maxPages == 0)) { - Bot.ArchiLogger.LogNullError(nameof(maxPages)); - - return null; - } + return null; } + } - GamesToFarm.Clear(); + GamesToFarm.Clear(); - ConcurrentHashSet parsedAppIDs = new(); + ConcurrentHashSet parsedAppIDs = new(); - Task mainTask = CheckPage(htmlDocument, parsedAppIDs); + Task mainTask = CheckPage(htmlDocument, parsedAppIDs); - switch (ASF.GlobalConfig?.OptimizationMode) { - case GlobalConfig.EOptimizationMode.MinMemoryUsage: - await mainTask.ConfigureAwait(false); + switch (ASF.GlobalConfig?.OptimizationMode) { + case GlobalConfig.EOptimizationMode.MinMemoryUsage: + await mainTask.ConfigureAwait(false); - if (maxPages > 1) { - Bot.ArchiLogger.LogGenericInfo(Strings.CheckingOtherBadgePages); + if (maxPages > 1) { + Bot.ArchiLogger.LogGenericInfo(Strings.CheckingOtherBadgePages); - for (byte page = 2; page <= maxPages; page++) { - await CheckPage(page, parsedAppIDs).ConfigureAwait(false); - } + for (byte page = 2; page <= maxPages; page++) { + await CheckPage(page, parsedAppIDs).ConfigureAwait(false); } + } + + break; + default: + HashSet tasks = new(maxPages) { mainTask }; + + if (maxPages > 1) { + Bot.ArchiLogger.LogGenericInfo(Strings.CheckingOtherBadgePages); + + for (byte page = 2; page <= maxPages; page++) { + // ReSharper disable once InlineTemporaryVariable - we need a copy of variable being passed when in for loops, as loop will proceed before our task is launched + byte currentPage = page; + tasks.Add(CheckPage(currentPage, parsedAppIDs)); + } + } + + await Task.WhenAll(tasks).ConfigureAwait(false); + + break; + } + + if (GamesToFarm.Count == 0) { + ShouldResumeFarming = false; + + return false; + } + + ShouldResumeFarming = true; + await SortGamesToFarm().ConfigureAwait(false); + + return true; + } + + private async Task IsPlayableGame(Game game) { + if (game == null) { + throw new ArgumentNullException(nameof(game)); + } + + (uint playableAppID, DateTime ignoredUntil, bool ignoredGlobally) = await Bot.GetAppDataForIdling(game.AppID, game.HoursPlayed).ConfigureAwait(false); + + if (playableAppID == 0) { + ConcurrentDictionary ignoredAppIDs = ignoredGlobally ? GloballyIgnoredAppIDs : LocallyIgnoredAppIDs; + + ignoredAppIDs[game.AppID] = (ignoredUntil > DateTime.MinValue) && (ignoredUntil < DateTime.MaxValue) ? ignoredUntil : DateTime.UtcNow.AddHours(HoursToIgnore); + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.IdlingGameNotPossible, game.AppID, game.GameName)); + + return false; + } + + game.PlayableAppID = playableAppID; + + return true; + } + + private async Task ShouldFarm(Game game) { + if (game == null) { + throw new ArgumentNullException(nameof(game)); + } + + ushort? cardsRemaining = await GetCardsRemaining(game.AppID).ConfigureAwait(false); + + if (!cardsRemaining.HasValue) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningCouldNotCheckCardsStatus, game.AppID, game.GameName)); + + return null; + } + + game.CardsRemaining = cardsRemaining.Value; + + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.IdlingStatusForGame, game.AppID, game.GameName, game.CardsRemaining)); + + return game.CardsRemaining > 0; + } + + private async Task SortGamesToFarm() { + // Put priority idling appIDs on top + IOrderedEnumerable orderedGamesToFarm = GamesToFarm.OrderByDescending(game => Bot.IsPriorityIdling(game.AppID)); + + foreach (BotConfig.EFarmingOrder farmingOrder in Bot.BotConfig.FarmingOrders) { + switch (farmingOrder) { + case BotConfig.EFarmingOrder.Unordered: + break; + case BotConfig.EFarmingOrder.AppIDsAscending: + orderedGamesToFarm = orderedGamesToFarm.ThenBy(static game => game.AppID); + + break; + case BotConfig.EFarmingOrder.AppIDsDescending: + orderedGamesToFarm = orderedGamesToFarm.ThenByDescending(static game => game.AppID); + + break; + case BotConfig.EFarmingOrder.BadgeLevelsAscending: + orderedGamesToFarm = orderedGamesToFarm.ThenBy(static game => game.BadgeLevel); + + break; + case BotConfig.EFarmingOrder.BadgeLevelsDescending: + orderedGamesToFarm = orderedGamesToFarm.ThenByDescending(static game => game.BadgeLevel); + + break; + case BotConfig.EFarmingOrder.CardDropsAscending: + orderedGamesToFarm = orderedGamesToFarm.ThenBy(static game => game.CardsRemaining); + + break; + case BotConfig.EFarmingOrder.CardDropsDescending: + orderedGamesToFarm = orderedGamesToFarm.ThenByDescending(static game => game.CardsRemaining); + + break; + case BotConfig.EFarmingOrder.MarketableAscending: + case BotConfig.EFarmingOrder.MarketableDescending: + HashSet? marketableAppIDs = await Bot.GetMarketableAppIDs().ConfigureAwait(false); + + if (marketableAppIDs?.Count > 0) { + ImmutableHashSet immutableMarketableAppIDs = marketableAppIDs.ToImmutableHashSet(); + + orderedGamesToFarm = farmingOrder switch { + BotConfig.EFarmingOrder.MarketableAscending => orderedGamesToFarm.ThenBy(game => immutableMarketableAppIDs.Contains(game.AppID)), + BotConfig.EFarmingOrder.MarketableDescending => orderedGamesToFarm.ThenByDescending(game => immutableMarketableAppIDs.Contains(game.AppID)), + _ => throw new InvalidOperationException(nameof(farmingOrder)) + }; + } + + break; + case BotConfig.EFarmingOrder.HoursAscending: + orderedGamesToFarm = orderedGamesToFarm.ThenBy(static game => game.HoursPlayed); + + break; + case BotConfig.EFarmingOrder.HoursDescending: + orderedGamesToFarm = orderedGamesToFarm.ThenByDescending(static game => game.HoursPlayed); + + break; + case BotConfig.EFarmingOrder.NamesAscending: + orderedGamesToFarm = orderedGamesToFarm.ThenBy(static game => game.GameName); + + break; + case BotConfig.EFarmingOrder.NamesDescending: + orderedGamesToFarm = orderedGamesToFarm.ThenByDescending(static game => game.GameName); + + break; + case BotConfig.EFarmingOrder.Random: + orderedGamesToFarm = orderedGamesToFarm.ThenBy(static _ => Utilities.RandomNext()); + + break; + case BotConfig.EFarmingOrder.RedeemDateTimesAscending: + case BotConfig.EFarmingOrder.RedeemDateTimesDescending: + Dictionary redeemDates = new(GamesToFarm.Count); + + foreach (Game game in GamesToFarm) { + DateTime redeemDate = DateTime.MinValue; + HashSet? packageIDs = ASF.GlobalDatabase?.GetPackageIDs(game.AppID, Bot.OwnedPackageIDs.Keys); + + if (packageIDs != null) { + foreach (uint packageID in packageIDs) { + if (!Bot.OwnedPackageIDs.TryGetValue(packageID, out (EPaymentMethod PaymentMethod, DateTime TimeCreated) packageData)) { + Bot.ArchiLogger.LogNullError(nameof(packageData)); + + return; + } + + if (packageData.TimeCreated > redeemDate) { + redeemDate = packageData.TimeCreated; + } + } + } + + redeemDates[game.AppID] = redeemDate; + } + + ImmutableDictionary immutableRedeemDates = redeemDates.ToImmutableDictionary(); + + orderedGamesToFarm = farmingOrder switch { + // ReSharper disable once AccessToModifiedClosure - you're wrong + BotConfig.EFarmingOrder.RedeemDateTimesAscending => orderedGamesToFarm.ThenBy(game => immutableRedeemDates[game.AppID]), + + // ReSharper disable once AccessToModifiedClosure - you're wrong + BotConfig.EFarmingOrder.RedeemDateTimesDescending => orderedGamesToFarm.ThenByDescending(game => immutableRedeemDates[game.AppID]), + + _ => throw new InvalidOperationException(nameof(farmingOrder)) + }; break; default: - HashSet tasks = new(maxPages) { mainTask }; + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(farmingOrder), farmingOrder)); - if (maxPages > 1) { - Bot.ArchiLogger.LogGenericInfo(Strings.CheckingOtherBadgePages); - - for (byte page = 2; page <= maxPages; page++) { - // ReSharper disable once InlineTemporaryVariable - we need a copy of variable being passed when in for loops, as loop will proceed before our task is launched - byte currentPage = page; - tasks.Add(CheckPage(currentPage, parsedAppIDs)); - } - } - - await Task.WhenAll(tasks).ConfigureAwait(false); - - break; + return; } - - if (GamesToFarm.Count == 0) { - ShouldResumeFarming = false; - - return false; - } - - ShouldResumeFarming = true; - await SortGamesToFarm().ConfigureAwait(false); - - return true; } - private async Task IsPlayableGame(Game game) { - if (game == null) { - throw new ArgumentNullException(nameof(game)); - } - - (uint playableAppID, DateTime ignoredUntil, bool ignoredGlobally) = await Bot.GetAppDataForIdling(game.AppID, game.HoursPlayed).ConfigureAwait(false); - - if (playableAppID == 0) { - ConcurrentDictionary ignoredAppIDs = ignoredGlobally ? GloballyIgnoredAppIDs : LocallyIgnoredAppIDs; - - ignoredAppIDs[game.AppID] = (ignoredUntil > DateTime.MinValue) && (ignoredUntil < DateTime.MaxValue) ? ignoredUntil : DateTime.UtcNow.AddHours(HoursToIgnore); - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.IdlingGameNotPossible, game.AppID, game.GameName)); - - return false; - } - - game.PlayableAppID = playableAppID; - - return true; - } - - private async Task ShouldFarm(Game game) { - if (game == null) { - throw new ArgumentNullException(nameof(game)); - } - - ushort? cardsRemaining = await GetCardsRemaining(game.AppID).ConfigureAwait(false); - - if (!cardsRemaining.HasValue) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningCouldNotCheckCardsStatus, game.AppID, game.GameName)); - - return null; - } - - game.CardsRemaining = cardsRemaining.Value; - - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.IdlingStatusForGame, game.AppID, game.GameName, game.CardsRemaining)); - - return game.CardsRemaining > 0; - } - - private async Task SortGamesToFarm() { - // Put priority idling appIDs on top - IOrderedEnumerable orderedGamesToFarm = GamesToFarm.OrderByDescending(game => Bot.IsPriorityIdling(game.AppID)); - - foreach (BotConfig.EFarmingOrder farmingOrder in Bot.BotConfig.FarmingOrders) { - switch (farmingOrder) { - case BotConfig.EFarmingOrder.Unordered: - break; - case BotConfig.EFarmingOrder.AppIDsAscending: - orderedGamesToFarm = orderedGamesToFarm.ThenBy(static game => game.AppID); - - break; - case BotConfig.EFarmingOrder.AppIDsDescending: - orderedGamesToFarm = orderedGamesToFarm.ThenByDescending(static game => game.AppID); - - break; - case BotConfig.EFarmingOrder.BadgeLevelsAscending: - orderedGamesToFarm = orderedGamesToFarm.ThenBy(static game => game.BadgeLevel); - - break; - case BotConfig.EFarmingOrder.BadgeLevelsDescending: - orderedGamesToFarm = orderedGamesToFarm.ThenByDescending(static game => game.BadgeLevel); - - break; - case BotConfig.EFarmingOrder.CardDropsAscending: - orderedGamesToFarm = orderedGamesToFarm.ThenBy(static game => game.CardsRemaining); - - break; - case BotConfig.EFarmingOrder.CardDropsDescending: - orderedGamesToFarm = orderedGamesToFarm.ThenByDescending(static game => game.CardsRemaining); - - break; - case BotConfig.EFarmingOrder.MarketableAscending: - case BotConfig.EFarmingOrder.MarketableDescending: - HashSet? marketableAppIDs = await Bot.GetMarketableAppIDs().ConfigureAwait(false); - - if (marketableAppIDs?.Count > 0) { - ImmutableHashSet immutableMarketableAppIDs = marketableAppIDs.ToImmutableHashSet(); - - orderedGamesToFarm = farmingOrder switch { - BotConfig.EFarmingOrder.MarketableAscending => orderedGamesToFarm.ThenBy(game => immutableMarketableAppIDs.Contains(game.AppID)), - BotConfig.EFarmingOrder.MarketableDescending => orderedGamesToFarm.ThenByDescending(game => immutableMarketableAppIDs.Contains(game.AppID)), - _ => throw new InvalidOperationException(nameof(farmingOrder)) - }; - } - - break; - case BotConfig.EFarmingOrder.HoursAscending: - orderedGamesToFarm = orderedGamesToFarm.ThenBy(static game => game.HoursPlayed); - - break; - case BotConfig.EFarmingOrder.HoursDescending: - orderedGamesToFarm = orderedGamesToFarm.ThenByDescending(static game => game.HoursPlayed); - - break; - case BotConfig.EFarmingOrder.NamesAscending: - orderedGamesToFarm = orderedGamesToFarm.ThenBy(static game => game.GameName); - - break; - case BotConfig.EFarmingOrder.NamesDescending: - orderedGamesToFarm = orderedGamesToFarm.ThenByDescending(static game => game.GameName); - - break; - case BotConfig.EFarmingOrder.Random: - orderedGamesToFarm = orderedGamesToFarm.ThenBy(static _ => Utilities.RandomNext()); - - break; - case BotConfig.EFarmingOrder.RedeemDateTimesAscending: - case BotConfig.EFarmingOrder.RedeemDateTimesDescending: - Dictionary redeemDates = new(GamesToFarm.Count); - - foreach (Game game in GamesToFarm) { - DateTime redeemDate = DateTime.MinValue; - HashSet? packageIDs = ASF.GlobalDatabase?.GetPackageIDs(game.AppID, Bot.OwnedPackageIDs.Keys); - - if (packageIDs != null) { - foreach (uint packageID in packageIDs) { - if (!Bot.OwnedPackageIDs.TryGetValue(packageID, out (EPaymentMethod PaymentMethod, DateTime TimeCreated) packageData)) { - Bot.ArchiLogger.LogNullError(nameof(packageData)); - - return; - } - - if (packageData.TimeCreated > redeemDate) { - redeemDate = packageData.TimeCreated; - } - } - } - - redeemDates[game.AppID] = redeemDate; - } - - ImmutableDictionary immutableRedeemDates = redeemDates.ToImmutableDictionary(); - - orderedGamesToFarm = farmingOrder switch { - // ReSharper disable once AccessToModifiedClosure - you're wrong - BotConfig.EFarmingOrder.RedeemDateTimesAscending => orderedGamesToFarm.ThenBy(game => immutableRedeemDates[game.AppID]), - - // ReSharper disable once AccessToModifiedClosure - you're wrong - BotConfig.EFarmingOrder.RedeemDateTimesDescending => orderedGamesToFarm.ThenByDescending(game => immutableRedeemDates[game.AppID]), - - _ => throw new InvalidOperationException(nameof(farmingOrder)) - }; - - break; - default: - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(farmingOrder), farmingOrder)); - - return; - } - } - - // We must call ToList() here as we can't do in-place replace - List gamesToFarm = orderedGamesToFarm.ToList(); - GamesToFarm.ReplaceWith(gamesToFarm); - } + // We must call ToList() here as we can't do in-place replace + List gamesToFarm = orderedGamesToFarm.ToList(); + GamesToFarm.ReplaceWith(gamesToFarm); } } diff --git a/ArchiSteamFarm/Steam/Cards/Game.cs b/ArchiSteamFarm/Steam/Cards/Game.cs index 3fbb57309..794d2591a 100644 --- a/ArchiSteamFarm/Steam/Cards/Game.cs +++ b/ArchiSteamFarm/Steam/Cards/Game.cs @@ -22,36 +22,36 @@ using System; using Newtonsoft.Json; -namespace ArchiSteamFarm.Steam.Cards { - public sealed class Game : IEquatable { - [JsonProperty] - public uint AppID { get; } +namespace ArchiSteamFarm.Steam.Cards; - [JsonProperty] - public string GameName { get; } +public sealed class Game : IEquatable { + [JsonProperty] + public uint AppID { get; } - internal readonly byte BadgeLevel; + [JsonProperty] + public string GameName { get; } - [JsonProperty] - public ushort CardsRemaining { get; internal set; } + internal readonly byte BadgeLevel; - [JsonProperty] - public float HoursPlayed { get; internal set; } + [JsonProperty] + public ushort CardsRemaining { get; internal set; } - internal uint PlayableAppID { get; set; } + [JsonProperty] + public float HoursPlayed { get; internal set; } - internal Game(uint appID, string gameName, float hoursPlayed, ushort cardsRemaining, byte badgeLevel) { - AppID = appID > 0 ? appID : throw new ArgumentOutOfRangeException(nameof(appID)); - GameName = !string.IsNullOrEmpty(gameName) ? gameName : throw new ArgumentNullException(nameof(gameName)); - HoursPlayed = hoursPlayed >= 0 ? hoursPlayed : throw new ArgumentOutOfRangeException(nameof(hoursPlayed)); - CardsRemaining = cardsRemaining > 0 ? cardsRemaining : throw new ArgumentOutOfRangeException(nameof(cardsRemaining)); - BadgeLevel = badgeLevel; + internal uint PlayableAppID { get; set; } - PlayableAppID = appID; - } + internal Game(uint appID, string gameName, float hoursPlayed, ushort cardsRemaining, byte badgeLevel) { + AppID = appID > 0 ? appID : throw new ArgumentOutOfRangeException(nameof(appID)); + GameName = !string.IsNullOrEmpty(gameName) ? gameName : throw new ArgumentNullException(nameof(gameName)); + HoursPlayed = hoursPlayed >= 0 ? hoursPlayed : throw new ArgumentOutOfRangeException(nameof(hoursPlayed)); + CardsRemaining = cardsRemaining > 0 ? cardsRemaining : throw new ArgumentOutOfRangeException(nameof(cardsRemaining)); + BadgeLevel = badgeLevel; - public bool Equals(Game? other) => (other != null) && (ReferenceEquals(other, this) || ((AppID == other.AppID) && (BadgeLevel == other.BadgeLevel) && (GameName == other.GameName))); - public override bool Equals(object? obj) => (obj != null) && ((obj == this) || (obj is Game game && Equals(game))); - public override int GetHashCode() => HashCode.Combine(AppID, BadgeLevel, GameName); + PlayableAppID = appID; } + + public bool Equals(Game? other) => (other != null) && (ReferenceEquals(other, this) || ((AppID == other.AppID) && (BadgeLevel == other.BadgeLevel) && (GameName == other.GameName))); + public override bool Equals(object? obj) => (obj != null) && ((obj == this) || (obj is Game game && Equals(game))); + public override int GetHashCode() => HashCode.Combine(AppID, BadgeLevel, GameName); } diff --git a/ArchiSteamFarm/Steam/Data/AccessTokenResponse.cs b/ArchiSteamFarm/Steam/Data/AccessTokenResponse.cs index f36f30ed1..1dff084c7 100644 --- a/ArchiSteamFarm/Steam/Data/AccessTokenResponse.cs +++ b/ArchiSteamFarm/Steam/Data/AccessTokenResponse.cs @@ -22,22 +22,22 @@ using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; -namespace ArchiSteamFarm.Steam.Data { +namespace ArchiSteamFarm.Steam.Data; + +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +internal sealed class AccessTokenResponse : ResultResponse { + [JsonProperty(PropertyName = "data", Required = Required.Always)] + internal readonly AccessTokenData Data = new(); + + [JsonConstructor] + private AccessTokenResponse() { } + [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - internal sealed class AccessTokenResponse : ResultResponse { - [JsonProperty(PropertyName = "data", Required = Required.Always)] - internal readonly AccessTokenData Data = new(); + internal sealed class AccessTokenData { + [JsonProperty(PropertyName = "webapi_token", Required = Required.Always)] + internal readonly string WebAPIToken = ""; [JsonConstructor] - private AccessTokenResponse() { } - - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - internal sealed class AccessTokenData { - [JsonProperty(PropertyName = "webapi_token", Required = Required.Always)] - internal readonly string WebAPIToken = ""; - - [JsonConstructor] - internal AccessTokenData() { } - } + internal AccessTokenData() { } } } diff --git a/ArchiSteamFarm/Steam/Data/Asset.cs b/ArchiSteamFarm/Steam/Data/Asset.cs index 5508c0ed0..592559e88 100644 --- a/ArchiSteamFarm/Steam/Data/Asset.cs +++ b/ArchiSteamFarm/Steam/Data/Asset.cs @@ -28,234 +28,234 @@ using JetBrains.Annotations; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace ArchiSteamFarm.Steam.Data { - // REF: https://developer.valvesoftware.com/wiki/Steam_Web_API/IEconService#CEcon_Asset - public sealed class Asset { - [PublicAPI] - public const uint SteamAppID = 753; +namespace ArchiSteamFarm.Steam.Data; - [PublicAPI] - public const ulong SteamCommunityContextID = 6; +// REF: https://developer.valvesoftware.com/wiki/Steam_Web_API/IEconService#CEcon_Asset +public sealed class Asset { + [PublicAPI] + public const uint SteamAppID = 753; - [JsonIgnore] - [PublicAPI] - public IReadOnlyDictionary? AdditionalPropertiesReadOnly => AdditionalProperties; + [PublicAPI] + public const ulong SteamCommunityContextID = 6; - [JsonIgnore] - [PublicAPI] - public uint Amount { get; internal set; } + [JsonIgnore] + [PublicAPI] + public IReadOnlyDictionary? AdditionalPropertiesReadOnly => AdditionalProperties; - [JsonProperty(PropertyName = "appid", Required = Required.DisallowNull)] - public uint AppID { get; private set; } + [JsonIgnore] + [PublicAPI] + public uint Amount { get; internal set; } - [JsonIgnore] - [PublicAPI] - public ulong AssetID { get; private set; } + [JsonProperty(PropertyName = "appid", Required = Required.DisallowNull)] + public uint AppID { get; private set; } - [JsonIgnore] - [PublicAPI] - public ulong ClassID { get; private set; } + [JsonIgnore] + [PublicAPI] + public ulong AssetID { get; private set; } - [JsonIgnore] - [PublicAPI] - public ulong ContextID { get; private set; } + [JsonIgnore] + [PublicAPI] + public ulong ClassID { get; private set; } - [JsonIgnore] - [PublicAPI] - public ulong InstanceID { get; private set; } + [JsonIgnore] + [PublicAPI] + public ulong ContextID { get; private set; } - [JsonIgnore] - [PublicAPI] - public bool Marketable { get; internal set; } + [JsonIgnore] + [PublicAPI] + public ulong InstanceID { get; private set; } - [JsonIgnore] - [PublicAPI] - public ERarity Rarity { get; internal set; } + [JsonIgnore] + [PublicAPI] + public bool Marketable { get; internal set; } - [JsonIgnore] - [PublicAPI] - public uint RealAppID { get; internal set; } + [JsonIgnore] + [PublicAPI] + public ERarity Rarity { get; internal set; } - [JsonIgnore] - [PublicAPI] - public ImmutableHashSet? Tags { get; internal set; } + [JsonIgnore] + [PublicAPI] + public uint RealAppID { get; internal set; } - [JsonIgnore] - [PublicAPI] - public bool Tradable { get; internal set; } + [JsonIgnore] + [PublicAPI] + public ImmutableHashSet? Tags { get; internal set; } - [JsonIgnore] - [PublicAPI] - public EType Type { get; internal set; } + [JsonIgnore] + [PublicAPI] + public bool Tradable { get; internal set; } - [JsonExtensionData(WriteData = false)] - internal Dictionary? AdditionalProperties { private get; set; } + [JsonIgnore] + [PublicAPI] + public EType Type { get; internal set; } - [JsonProperty(PropertyName = "amount", Required = Required.Always)] - private string AmountText { - get => Amount.ToString(CultureInfo.InvariantCulture); + [JsonExtensionData(WriteData = false)] + internal Dictionary? AdditionalProperties { private get; set; } - set { - if (string.IsNullOrEmpty(value)) { - ASF.ArchiLogger.LogNullError(nameof(value)); + [JsonProperty(PropertyName = "amount", Required = Required.Always)] + private string AmountText { + get => Amount.ToString(CultureInfo.InvariantCulture); - return; - } + set { + if (string.IsNullOrEmpty(value)) { + ASF.ArchiLogger.LogNullError(nameof(value)); - if (!uint.TryParse(value, out uint amount) || (amount == 0)) { - ASF.ArchiLogger.LogNullError(nameof(amount)); - - return; - } - - Amount = amount; - } - } - - [JsonProperty(PropertyName = "assetid", Required = Required.DisallowNull)] - private string AssetIDText { - get => AssetID.ToString(CultureInfo.InvariantCulture); - - set { - if (string.IsNullOrEmpty(value)) { - ASF.ArchiLogger.LogNullError(nameof(value)); - - return; - } - - if (!ulong.TryParse(value, out ulong assetID) || (assetID == 0)) { - ASF.ArchiLogger.LogNullError(nameof(assetID)); - - return; - } - - AssetID = assetID; - } - } - - [JsonProperty(PropertyName = "classid", Required = Required.DisallowNull)] - private string ClassIDText { - set { - if (string.IsNullOrEmpty(value)) { - ASF.ArchiLogger.LogNullError(nameof(value)); - - return; - } - - if (!ulong.TryParse(value, out ulong classID) || (classID == 0)) { - return; - } - - ClassID = classID; - } - } - - [JsonProperty(PropertyName = "contextid", Required = Required.DisallowNull)] - private string ContextIDText { - get => ContextID.ToString(CultureInfo.InvariantCulture); - - set { - if (string.IsNullOrEmpty(value)) { - ASF.ArchiLogger.LogNullError(nameof(value)); - - return; - } - - if (!ulong.TryParse(value, out ulong contextID) || (contextID == 0)) { - ASF.ArchiLogger.LogNullError(nameof(contextID)); - - return; - } - - ContextID = contextID; - } - } - - [JsonProperty(PropertyName = "id", Required = Required.DisallowNull)] - private string IDText { - set => AssetIDText = value; - } - - [JsonProperty(PropertyName = "instanceid", Required = Required.DisallowNull)] - private string InstanceIDText { - set { - if (string.IsNullOrEmpty(value)) { - return; - } - - if (!ulong.TryParse(value, out ulong instanceID)) { - ASF.ArchiLogger.LogNullError(nameof(instanceID)); - - return; - } - - InstanceID = instanceID; - } - } - - // Constructed from trades being received or plugins - public Asset(uint appID, ulong contextID, ulong classID, uint amount, ulong instanceID = 0, ulong assetID = 0, bool marketable = true, bool tradable = true, ImmutableHashSet? tags = null, uint realAppID = 0, EType type = EType.Unknown, ERarity rarity = ERarity.Unknown) { - if (appID == 0) { - throw new ArgumentOutOfRangeException(nameof(appID)); + return; } - if (contextID == 0) { - throw new ArgumentOutOfRangeException(nameof(contextID)); + if (!uint.TryParse(value, out uint amount) || (amount == 0)) { + ASF.ArchiLogger.LogNullError(nameof(amount)); + + return; } - if (classID == 0) { - throw new ArgumentOutOfRangeException(nameof(classID)); - } - - if (amount == 0) { - throw new ArgumentOutOfRangeException(nameof(amount)); - } - - AppID = appID; - ContextID = contextID; - ClassID = classID; Amount = amount; - InstanceID = instanceID; - AssetID = assetID; - Marketable = marketable; - Tradable = tradable; - RealAppID = realAppID; - Type = type; - Rarity = rarity; - - if (tags?.Count > 0) { - Tags = tags; - } - } - - [JsonConstructor] - private Asset() { } - - internal Asset CreateShallowCopy() => (Asset) MemberwiseClone(); - - public enum ERarity : byte { - Unknown, - Common, - Uncommon, - Rare - } - - public enum EType : byte { - Unknown, - BoosterPack, - Emoticon, - FoilTradingCard, - ProfileBackground, - TradingCard, - SteamGems, - SaleItem, - Consumable, - ProfileModifier, - Sticker, - ChatEffect, - MiniProfileBackground, - AvatarProfileFrame, - AnimatedAvatar } } + + [JsonProperty(PropertyName = "assetid", Required = Required.DisallowNull)] + private string AssetIDText { + get => AssetID.ToString(CultureInfo.InvariantCulture); + + set { + if (string.IsNullOrEmpty(value)) { + ASF.ArchiLogger.LogNullError(nameof(value)); + + return; + } + + if (!ulong.TryParse(value, out ulong assetID) || (assetID == 0)) { + ASF.ArchiLogger.LogNullError(nameof(assetID)); + + return; + } + + AssetID = assetID; + } + } + + [JsonProperty(PropertyName = "classid", Required = Required.DisallowNull)] + private string ClassIDText { + set { + if (string.IsNullOrEmpty(value)) { + ASF.ArchiLogger.LogNullError(nameof(value)); + + return; + } + + if (!ulong.TryParse(value, out ulong classID) || (classID == 0)) { + return; + } + + ClassID = classID; + } + } + + [JsonProperty(PropertyName = "contextid", Required = Required.DisallowNull)] + private string ContextIDText { + get => ContextID.ToString(CultureInfo.InvariantCulture); + + set { + if (string.IsNullOrEmpty(value)) { + ASF.ArchiLogger.LogNullError(nameof(value)); + + return; + } + + if (!ulong.TryParse(value, out ulong contextID) || (contextID == 0)) { + ASF.ArchiLogger.LogNullError(nameof(contextID)); + + return; + } + + ContextID = contextID; + } + } + + [JsonProperty(PropertyName = "id", Required = Required.DisallowNull)] + private string IDText { + set => AssetIDText = value; + } + + [JsonProperty(PropertyName = "instanceid", Required = Required.DisallowNull)] + private string InstanceIDText { + set { + if (string.IsNullOrEmpty(value)) { + return; + } + + if (!ulong.TryParse(value, out ulong instanceID)) { + ASF.ArchiLogger.LogNullError(nameof(instanceID)); + + return; + } + + InstanceID = instanceID; + } + } + + // Constructed from trades being received or plugins + public Asset(uint appID, ulong contextID, ulong classID, uint amount, ulong instanceID = 0, ulong assetID = 0, bool marketable = true, bool tradable = true, ImmutableHashSet? tags = null, uint realAppID = 0, EType type = EType.Unknown, ERarity rarity = ERarity.Unknown) { + if (appID == 0) { + throw new ArgumentOutOfRangeException(nameof(appID)); + } + + if (contextID == 0) { + throw new ArgumentOutOfRangeException(nameof(contextID)); + } + + if (classID == 0) { + throw new ArgumentOutOfRangeException(nameof(classID)); + } + + if (amount == 0) { + throw new ArgumentOutOfRangeException(nameof(amount)); + } + + AppID = appID; + ContextID = contextID; + ClassID = classID; + Amount = amount; + InstanceID = instanceID; + AssetID = assetID; + Marketable = marketable; + Tradable = tradable; + RealAppID = realAppID; + Type = type; + Rarity = rarity; + + if (tags?.Count > 0) { + Tags = tags; + } + } + + [JsonConstructor] + private Asset() { } + + internal Asset CreateShallowCopy() => (Asset) MemberwiseClone(); + + public enum ERarity : byte { + Unknown, + Common, + Uncommon, + Rare + } + + public enum EType : byte { + Unknown, + BoosterPack, + Emoticon, + FoilTradingCard, + ProfileBackground, + TradingCard, + SteamGems, + SaleItem, + Consumable, + ProfileModifier, + Sticker, + ChatEffect, + MiniProfileBackground, + AvatarProfileFrame, + AnimatedAvatar + } } diff --git a/ArchiSteamFarm/Steam/Data/BooleanResponse.cs b/ArchiSteamFarm/Steam/Data/BooleanResponse.cs index 944841b6f..7b3a2d1c2 100644 --- a/ArchiSteamFarm/Steam/Data/BooleanResponse.cs +++ b/ArchiSteamFarm/Steam/Data/BooleanResponse.cs @@ -23,14 +23,14 @@ using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using Newtonsoft.Json; -namespace ArchiSteamFarm.Steam.Data { - [PublicAPI] - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public class BooleanResponse { - [JsonProperty(PropertyName = "success", Required = Required.Always)] - public bool Success { get; private set; } +namespace ArchiSteamFarm.Steam.Data; - [JsonConstructor] - protected BooleanResponse() { } - } +[PublicAPI] +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public class BooleanResponse { + [JsonProperty(PropertyName = "success", Required = Required.Always)] + public bool Success { get; private set; } + + [JsonConstructor] + protected BooleanResponse() { } } diff --git a/ArchiSteamFarm/Steam/Data/InventoryResponse.cs b/ArchiSteamFarm/Steam/Data/InventoryResponse.cs index f6ddd1a7a..7a96dd645 100644 --- a/ArchiSteamFarm/Steam/Data/InventoryResponse.cs +++ b/ArchiSteamFarm/Steam/Data/InventoryResponse.cs @@ -30,26 +30,189 @@ using JetBrains.Annotations; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace ArchiSteamFarm.Steam.Data { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - internal sealed class InventoryResponse : ResultResponse { - [JsonProperty(PropertyName = "assets", Required = Required.DisallowNull)] - internal readonly ImmutableHashSet Assets = ImmutableHashSet.Empty; +namespace ArchiSteamFarm.Steam.Data; - [JsonProperty(PropertyName = "descriptions", Required = Required.DisallowNull)] - internal readonly ImmutableHashSet Descriptions = ImmutableHashSet.Empty; +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +internal sealed class InventoryResponse : ResultResponse { + [JsonProperty(PropertyName = "assets", Required = Required.DisallowNull)] + internal readonly ImmutableHashSet Assets = ImmutableHashSet.Empty; - [JsonProperty(PropertyName = "error", Required = Required.DisallowNull)] - internal readonly string Error = ""; + [JsonProperty(PropertyName = "descriptions", Required = Required.DisallowNull)] + internal readonly ImmutableHashSet Descriptions = ImmutableHashSet.Empty; - [JsonProperty(PropertyName = "total_inventory_count", Required = Required.DisallowNull)] - internal readonly uint TotalInventoryCount; + [JsonProperty(PropertyName = "error", Required = Required.DisallowNull)] + internal readonly string Error = ""; - internal ulong LastAssetID { get; private set; } - internal bool MoreItems { get; private set; } + [JsonProperty(PropertyName = "total_inventory_count", Required = Required.DisallowNull)] + internal readonly uint TotalInventoryCount; - [JsonProperty(PropertyName = "last_assetid", Required = Required.DisallowNull)] - private string LastAssetIDText { + internal ulong LastAssetID { get; private set; } + internal bool MoreItems { get; private set; } + + [JsonProperty(PropertyName = "last_assetid", Required = Required.DisallowNull)] + private string LastAssetIDText { + set { + if (string.IsNullOrEmpty(value)) { + ASF.ArchiLogger.LogNullError(nameof(value)); + + return; + } + + if (!ulong.TryParse(value, out ulong lastAssetID) || (lastAssetID == 0)) { + ASF.ArchiLogger.LogNullError(nameof(lastAssetID)); + + return; + } + + LastAssetID = lastAssetID; + } + } + + [JsonProperty(PropertyName = "more_items", Required = Required.DisallowNull)] + private byte MoreItemsNumber { + set => MoreItems = value > 0; + } + + [JsonConstructor] + private InventoryResponse() { } + + internal sealed class Description { + internal Asset.ERarity Rarity { + get { + foreach (Tag tag in Tags) { + switch (tag.Identifier) { + case "droprate": + switch (tag.Value) { + case "droprate_0": + return Asset.ERarity.Common; + case "droprate_1": + return Asset.ERarity.Uncommon; + case "droprate_2": + return Asset.ERarity.Rare; + default: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); + + break; + } + + break; + } + } + + return Asset.ERarity.Unknown; + } + } + + internal uint RealAppID { + get { + foreach (Tag tag in Tags) { + switch (tag.Identifier) { + case "Game": + if (string.IsNullOrEmpty(tag.Value) || (tag.Value.Length <= 4) || !tag.Value.StartsWith("app_", StringComparison.Ordinal)) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); + + break; + } + + string appIDText = tag.Value[4..]; + + if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { + ASF.ArchiLogger.LogNullError(nameof(appID)); + + break; + } + + return appID; + } + } + + return 0; + } + } + + internal Asset.EType Type { + get { + Asset.EType type = Asset.EType.Unknown; + + foreach (Tag tag in Tags) { + switch (tag.Identifier) { + case "cardborder": + switch (tag.Value) { + case "cardborder_0": + return Asset.EType.TradingCard; + case "cardborder_1": + return Asset.EType.FoilTradingCard; + default: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); + + return Asset.EType.Unknown; + } + case "item_class": + switch (tag.Value) { + case "item_class_2": + if (type == Asset.EType.Unknown) { + // This is a fallback in case we'd have no cardborder available to interpret + type = Asset.EType.TradingCard; + } + + continue; + case "item_class_3": + return Asset.EType.ProfileBackground; + case "item_class_4": + return Asset.EType.Emoticon; + case "item_class_5": + return Asset.EType.BoosterPack; + case "item_class_6": + return Asset.EType.Consumable; + case "item_class_7": + return Asset.EType.SteamGems; + case "item_class_8": + return Asset.EType.ProfileModifier; + case "item_class_10": + return Asset.EType.SaleItem; + case "item_class_11": + return Asset.EType.Sticker; + case "item_class_12": + return Asset.EType.ChatEffect; + case "item_class_13": + return Asset.EType.MiniProfileBackground; + case "item_class_14": + return Asset.EType.AvatarProfileFrame; + case "item_class_15": + return Asset.EType.AnimatedAvatar; + default: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); + + return Asset.EType.Unknown; + } + } + } + + return type; + } + } + + [JsonExtensionData(WriteData = false)] + internal Dictionary? AdditionalProperties { + get; + [UsedImplicitly] + set; + } + + [JsonProperty(PropertyName = "appid", Required = Required.Always)] + internal uint AppID { get; set; } + + internal ulong ClassID { get; set; } + internal ulong InstanceID { get; set; } + internal bool Marketable { get; set; } + + [JsonProperty(PropertyName = "tags", Required = Required.DisallowNull)] + internal ImmutableHashSet Tags { get; set; } = ImmutableHashSet.Empty; + + internal bool Tradable { get; set; } + + [JsonProperty(PropertyName = "classid", Required = Required.Always)] + private string ClassIDText { set { if (string.IsNullOrEmpty(value)) { ASF.ArchiLogger.LogNullError(nameof(value)); @@ -57,207 +220,44 @@ namespace ArchiSteamFarm.Steam.Data { return; } - if (!ulong.TryParse(value, out ulong lastAssetID) || (lastAssetID == 0)) { - ASF.ArchiLogger.LogNullError(nameof(lastAssetID)); + if (!ulong.TryParse(value, out ulong classID) || (classID == 0)) { + ASF.ArchiLogger.LogNullError(nameof(classID)); return; } - LastAssetID = lastAssetID; + ClassID = classID; } } - [JsonProperty(PropertyName = "more_items", Required = Required.DisallowNull)] - private byte MoreItemsNumber { - set => MoreItems = value > 0; + [JsonProperty(PropertyName = "instanceid", Required = Required.DisallowNull)] + private string InstanceIDText { + set { + if (string.IsNullOrEmpty(value)) { + return; + } + + if (!ulong.TryParse(value, out ulong instanceID)) { + ASF.ArchiLogger.LogNullError(nameof(instanceID)); + + return; + } + + InstanceID = instanceID; + } + } + + [JsonProperty(PropertyName = "marketable", Required = Required.Always)] + private byte MarketableNumber { + set => Marketable = value > 0; + } + + [JsonProperty(PropertyName = "tradable", Required = Required.Always)] + private byte TradableNumber { + set => Tradable = value > 0; } [JsonConstructor] - private InventoryResponse() { } - - internal sealed class Description { - internal Asset.ERarity Rarity { - get { - foreach (Tag tag in Tags) { - switch (tag.Identifier) { - case "droprate": - switch (tag.Value) { - case "droprate_0": - return Asset.ERarity.Common; - case "droprate_1": - return Asset.ERarity.Uncommon; - case "droprate_2": - return Asset.ERarity.Rare; - default: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); - - break; - } - - break; - } - } - - return Asset.ERarity.Unknown; - } - } - - internal uint RealAppID { - get { - foreach (Tag tag in Tags) { - switch (tag.Identifier) { - case "Game": - if (string.IsNullOrEmpty(tag.Value) || (tag.Value.Length <= 4) || !tag.Value.StartsWith("app_", StringComparison.Ordinal)) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); - - break; - } - - string appIDText = tag.Value[4..]; - - if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { - ASF.ArchiLogger.LogNullError(nameof(appID)); - - break; - } - - return appID; - } - } - - return 0; - } - } - - internal Asset.EType Type { - get { - Asset.EType type = Asset.EType.Unknown; - - foreach (Tag tag in Tags) { - switch (tag.Identifier) { - case "cardborder": - switch (tag.Value) { - case "cardborder_0": - return Asset.EType.TradingCard; - case "cardborder_1": - return Asset.EType.FoilTradingCard; - default: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); - - return Asset.EType.Unknown; - } - case "item_class": - switch (tag.Value) { - case "item_class_2": - if (type == Asset.EType.Unknown) { - // This is a fallback in case we'd have no cardborder available to interpret - type = Asset.EType.TradingCard; - } - - continue; - case "item_class_3": - return Asset.EType.ProfileBackground; - case "item_class_4": - return Asset.EType.Emoticon; - case "item_class_5": - return Asset.EType.BoosterPack; - case "item_class_6": - return Asset.EType.Consumable; - case "item_class_7": - return Asset.EType.SteamGems; - case "item_class_8": - return Asset.EType.ProfileModifier; - case "item_class_10": - return Asset.EType.SaleItem; - case "item_class_11": - return Asset.EType.Sticker; - case "item_class_12": - return Asset.EType.ChatEffect; - case "item_class_13": - return Asset.EType.MiniProfileBackground; - case "item_class_14": - return Asset.EType.AvatarProfileFrame; - case "item_class_15": - return Asset.EType.AnimatedAvatar; - default: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(tag.Value), tag.Value)); - - return Asset.EType.Unknown; - } - } - } - - return type; - } - } - - [JsonExtensionData(WriteData = false)] - internal Dictionary? AdditionalProperties { - get; - [UsedImplicitly] - set; - } - - [JsonProperty(PropertyName = "appid", Required = Required.Always)] - internal uint AppID { get; set; } - - internal ulong ClassID { get; set; } - internal ulong InstanceID { get; set; } - internal bool Marketable { get; set; } - - [JsonProperty(PropertyName = "tags", Required = Required.DisallowNull)] - internal ImmutableHashSet Tags { get; set; } = ImmutableHashSet.Empty; - - internal bool Tradable { get; set; } - - [JsonProperty(PropertyName = "classid", Required = Required.Always)] - private string ClassIDText { - set { - if (string.IsNullOrEmpty(value)) { - ASF.ArchiLogger.LogNullError(nameof(value)); - - return; - } - - if (!ulong.TryParse(value, out ulong classID) || (classID == 0)) { - ASF.ArchiLogger.LogNullError(nameof(classID)); - - return; - } - - ClassID = classID; - } - } - - [JsonProperty(PropertyName = "instanceid", Required = Required.DisallowNull)] - private string InstanceIDText { - set { - if (string.IsNullOrEmpty(value)) { - return; - } - - if (!ulong.TryParse(value, out ulong instanceID)) { - ASF.ArchiLogger.LogNullError(nameof(instanceID)); - - return; - } - - InstanceID = instanceID; - } - } - - [JsonProperty(PropertyName = "marketable", Required = Required.Always)] - private byte MarketableNumber { - set => Marketable = value > 0; - } - - [JsonProperty(PropertyName = "tradable", Required = Required.Always)] - private byte TradableNumber { - set => Tradable = value > 0; - } - - [JsonConstructor] - internal Description() { } - } + internal Description() { } } } diff --git a/ArchiSteamFarm/Steam/Data/NewDiscoveryQueueResponse.cs b/ArchiSteamFarm/Steam/Data/NewDiscoveryQueueResponse.cs index f17c384b9..ff3ede033 100644 --- a/ArchiSteamFarm/Steam/Data/NewDiscoveryQueueResponse.cs +++ b/ArchiSteamFarm/Steam/Data/NewDiscoveryQueueResponse.cs @@ -23,13 +23,13 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; -namespace ArchiSteamFarm.Steam.Data { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - internal sealed class NewDiscoveryQueueResponse { - [JsonProperty(PropertyName = "queue", Required = Required.Always)] - internal readonly ImmutableHashSet Queue = ImmutableHashSet.Empty; +namespace ArchiSteamFarm.Steam.Data; - [JsonConstructor] - private NewDiscoveryQueueResponse() { } - } +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +internal sealed class NewDiscoveryQueueResponse { + [JsonProperty(PropertyName = "queue", Required = Required.Always)] + internal readonly ImmutableHashSet Queue = ImmutableHashSet.Empty; + + [JsonConstructor] + private NewDiscoveryQueueResponse() { } } diff --git a/ArchiSteamFarm/Steam/Data/RedeemWalletResponse.cs b/ArchiSteamFarm/Steam/Data/RedeemWalletResponse.cs index bd084dec1..69e4a2eb5 100644 --- a/ArchiSteamFarm/Steam/Data/RedeemWalletResponse.cs +++ b/ArchiSteamFarm/Steam/Data/RedeemWalletResponse.cs @@ -23,13 +23,13 @@ using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; using SteamKit2; -namespace ArchiSteamFarm.Steam.Data { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - internal sealed class RedeemWalletResponse : ResultResponse { - [JsonProperty(PropertyName = "detail", Required = Required.DisallowNull)] - internal readonly EPurchaseResultDetail PurchaseResultDetail; +namespace ArchiSteamFarm.Steam.Data; - [JsonConstructor] - private RedeemWalletResponse() { } - } +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +internal sealed class RedeemWalletResponse : ResultResponse { + [JsonProperty(PropertyName = "detail", Required = Required.DisallowNull)] + internal readonly EPurchaseResultDetail PurchaseResultDetail; + + [JsonConstructor] + private RedeemWalletResponse() { } } diff --git a/ArchiSteamFarm/Steam/Data/ResultResponse.cs b/ArchiSteamFarm/Steam/Data/ResultResponse.cs index fb7dbab46..166782f43 100644 --- a/ArchiSteamFarm/Steam/Data/ResultResponse.cs +++ b/ArchiSteamFarm/Steam/Data/ResultResponse.cs @@ -24,14 +24,14 @@ using JetBrains.Annotations; using Newtonsoft.Json; using SteamKit2; -namespace ArchiSteamFarm.Steam.Data { - [PublicAPI] - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public class ResultResponse { - [JsonProperty(PropertyName = "success", Required = Required.Always)] - public EResult Result { get; private set; } +namespace ArchiSteamFarm.Steam.Data; - [JsonConstructor] - protected ResultResponse() { } - } +[PublicAPI] +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public class ResultResponse { + [JsonProperty(PropertyName = "success", Required = Required.Always)] + public EResult Result { get; private set; } + + [JsonConstructor] + protected ResultResponse() { } } diff --git a/ArchiSteamFarm/Steam/Data/Tag.cs b/ArchiSteamFarm/Steam/Data/Tag.cs index 0db5b8df4..b6f8bcb12 100644 --- a/ArchiSteamFarm/Steam/Data/Tag.cs +++ b/ArchiSteamFarm/Steam/Data/Tag.cs @@ -23,22 +23,22 @@ using System; using JetBrains.Annotations; using Newtonsoft.Json; -namespace ArchiSteamFarm.Steam.Data { - public sealed class Tag { - [JsonProperty(PropertyName = "category", Required = Required.Always)] - [PublicAPI] - public string Identifier { get; private set; } = ""; +namespace ArchiSteamFarm.Steam.Data; - [JsonProperty(PropertyName = "internal_name", Required = Required.Always)] - [PublicAPI] - public string Value { get; private set; } = ""; +public sealed class Tag { + [JsonProperty(PropertyName = "category", Required = Required.Always)] + [PublicAPI] + public string Identifier { get; private set; } = ""; - internal Tag(string identifier, string value) { - Identifier = !string.IsNullOrEmpty(identifier) ? identifier : throw new ArgumentNullException(nameof(identifier)); - Value = value ?? throw new ArgumentNullException(nameof(value)); - } + [JsonProperty(PropertyName = "internal_name", Required = Required.Always)] + [PublicAPI] + public string Value { get; private set; } = ""; - [JsonConstructor] - private Tag() { } + internal Tag(string identifier, string value) { + Identifier = !string.IsNullOrEmpty(identifier) ? identifier : throw new ArgumentNullException(nameof(identifier)); + Value = value ?? throw new ArgumentNullException(nameof(value)); } + + [JsonConstructor] + private Tag() { } } diff --git a/ArchiSteamFarm/Steam/Data/TradeOffer.cs b/ArchiSteamFarm/Steam/Data/TradeOffer.cs index 4791e199f..ecc0b0d5d 100644 --- a/ArchiSteamFarm/Steam/Data/TradeOffer.cs +++ b/ArchiSteamFarm/Steam/Data/TradeOffer.cs @@ -26,53 +26,53 @@ using System.Linq; using JetBrains.Annotations; using SteamKit2; -namespace ArchiSteamFarm.Steam.Data { - // REF: https://developer.valvesoftware.com/wiki/Steam_Web_API/IEconService#CEcon_TradeOffer - public sealed class TradeOffer { - [PublicAPI] - public IReadOnlyCollection ItemsToGiveReadOnly => ItemsToGive; +namespace ArchiSteamFarm.Steam.Data; - [PublicAPI] - public IReadOnlyCollection ItemsToReceiveReadOnly => ItemsToReceive; +// REF: https://developer.valvesoftware.com/wiki/Steam_Web_API/IEconService#CEcon_TradeOffer +public sealed class TradeOffer { + [PublicAPI] + public IReadOnlyCollection ItemsToGiveReadOnly => ItemsToGive; - internal readonly HashSet ItemsToGive = new(); - internal readonly HashSet ItemsToReceive = new(); + [PublicAPI] + public IReadOnlyCollection ItemsToReceiveReadOnly => ItemsToReceive; - [PublicAPI] - public ulong OtherSteamID64 { get; private set; } + internal readonly HashSet ItemsToGive = new(); + internal readonly HashSet ItemsToReceive = new(); - [PublicAPI] - public ETradeOfferState State { get; private set; } + [PublicAPI] + public ulong OtherSteamID64 { get; private set; } - [PublicAPI] - public ulong TradeOfferID { get; private set; } + [PublicAPI] + public ETradeOfferState State { get; private set; } - // Constructed from trades being received - internal TradeOffer(ulong tradeOfferID, uint otherSteamID3, ETradeOfferState state) { - if (tradeOfferID == 0) { - throw new ArgumentOutOfRangeException(nameof(tradeOfferID)); - } + [PublicAPI] + public ulong TradeOfferID { get; private set; } - if (otherSteamID3 == 0) { - throw new ArgumentOutOfRangeException(nameof(otherSteamID3)); - } - - if (!Enum.IsDefined(typeof(ETradeOfferState), state)) { - throw new InvalidEnumArgumentException(nameof(state), (int) state, typeof(ETradeOfferState)); - } - - TradeOfferID = tradeOfferID; - OtherSteamID64 = new SteamID(otherSteamID3, EUniverse.Public, EAccountType.Individual); - State = state; + // Constructed from trades being received + internal TradeOffer(ulong tradeOfferID, uint otherSteamID3, ETradeOfferState state) { + if (tradeOfferID == 0) { + throw new ArgumentOutOfRangeException(nameof(tradeOfferID)); } - [PublicAPI] - public bool IsValidSteamItemsRequest(IReadOnlyCollection acceptedTypes) { - if ((acceptedTypes == null) || (acceptedTypes.Count == 0)) { - throw new ArgumentNullException(nameof(acceptedTypes)); - } - - return ItemsToGive.All(item => (item.AppID == Asset.SteamAppID) && (item.ContextID == Asset.SteamCommunityContextID) && acceptedTypes.Contains(item.Type)); + if (otherSteamID3 == 0) { + throw new ArgumentOutOfRangeException(nameof(otherSteamID3)); } + + if (!Enum.IsDefined(typeof(ETradeOfferState), state)) { + throw new InvalidEnumArgumentException(nameof(state), (int) state, typeof(ETradeOfferState)); + } + + TradeOfferID = tradeOfferID; + OtherSteamID64 = new SteamID(otherSteamID3, EUniverse.Public, EAccountType.Individual); + State = state; + } + + [PublicAPI] + public bool IsValidSteamItemsRequest(IReadOnlyCollection acceptedTypes) { + if ((acceptedTypes == null) || (acceptedTypes.Count == 0)) { + throw new ArgumentNullException(nameof(acceptedTypes)); + } + + return ItemsToGive.All(item => (item.AppID == Asset.SteamAppID) && (item.ContextID == Asset.SteamCommunityContextID) && acceptedTypes.Contains(item.Type)); } } diff --git a/ArchiSteamFarm/Steam/Data/TradeOfferAcceptResponse.cs b/ArchiSteamFarm/Steam/Data/TradeOfferAcceptResponse.cs index ed94e3b91..cd8ad225b 100644 --- a/ArchiSteamFarm/Steam/Data/TradeOfferAcceptResponse.cs +++ b/ArchiSteamFarm/Steam/Data/TradeOfferAcceptResponse.cs @@ -22,16 +22,16 @@ using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; -namespace ArchiSteamFarm.Steam.Data { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - internal sealed class TradeOfferAcceptResponse { - [JsonProperty(PropertyName = "strError", Required = Required.DisallowNull)] - internal readonly string ErrorText = ""; +namespace ArchiSteamFarm.Steam.Data; - [JsonProperty(PropertyName = "needs_mobile_confirmation", Required = Required.DisallowNull)] - internal readonly bool RequiresMobileConfirmation; +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +internal sealed class TradeOfferAcceptResponse { + [JsonProperty(PropertyName = "strError", Required = Required.DisallowNull)] + internal readonly string ErrorText = ""; - [JsonConstructor] - private TradeOfferAcceptResponse() { } - } + [JsonProperty(PropertyName = "needs_mobile_confirmation", Required = Required.DisallowNull)] + internal readonly bool RequiresMobileConfirmation; + + [JsonConstructor] + private TradeOfferAcceptResponse() { } } diff --git a/ArchiSteamFarm/Steam/Data/TradeOfferSendRequest.cs b/ArchiSteamFarm/Steam/Data/TradeOfferSendRequest.cs index 2ab8c038e..484cd7da7 100644 --- a/ArchiSteamFarm/Steam/Data/TradeOfferSendRequest.cs +++ b/ArchiSteamFarm/Steam/Data/TradeOfferSendRequest.cs @@ -22,17 +22,17 @@ using System.Collections.Generic; using Newtonsoft.Json; -namespace ArchiSteamFarm.Steam.Data { - internal sealed class TradeOfferSendRequest { - [JsonProperty(PropertyName = "me", Required = Required.Always)] - internal readonly ItemList ItemsToGive = new(); +namespace ArchiSteamFarm.Steam.Data; - [JsonProperty(PropertyName = "them", Required = Required.Always)] - internal readonly ItemList ItemsToReceive = new(); +internal sealed class TradeOfferSendRequest { + [JsonProperty(PropertyName = "me", Required = Required.Always)] + internal readonly ItemList ItemsToGive = new(); - internal sealed class ItemList { - [JsonProperty(PropertyName = "assets", Required = Required.Always)] - internal readonly HashSet Assets = new(); - } + [JsonProperty(PropertyName = "them", Required = Required.Always)] + internal readonly ItemList ItemsToReceive = new(); + + internal sealed class ItemList { + [JsonProperty(PropertyName = "assets", Required = Required.Always)] + internal readonly HashSet Assets = new(); } } diff --git a/ArchiSteamFarm/Steam/Data/TradeOfferSendResponse.cs b/ArchiSteamFarm/Steam/Data/TradeOfferSendResponse.cs index 97013cd38..311f1bd76 100644 --- a/ArchiSteamFarm/Steam/Data/TradeOfferSendResponse.cs +++ b/ArchiSteamFarm/Steam/Data/TradeOfferSendResponse.cs @@ -23,37 +23,37 @@ using System.Diagnostics.CodeAnalysis; using ArchiSteamFarm.Core; using Newtonsoft.Json; -namespace ArchiSteamFarm.Steam.Data { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - internal sealed class TradeOfferSendResponse { - [JsonProperty(PropertyName = "strError", Required = Required.DisallowNull)] - internal readonly string ErrorText = ""; +namespace ArchiSteamFarm.Steam.Data; - [JsonProperty(PropertyName = "needs_mobile_confirmation", Required = Required.DisallowNull)] - internal readonly bool RequiresMobileConfirmation; +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +internal sealed class TradeOfferSendResponse { + [JsonProperty(PropertyName = "strError", Required = Required.DisallowNull)] + internal readonly string ErrorText = ""; - internal ulong TradeOfferID { get; private set; } + [JsonProperty(PropertyName = "needs_mobile_confirmation", Required = Required.DisallowNull)] + internal readonly bool RequiresMobileConfirmation; - [JsonProperty(PropertyName = "tradeofferid", Required = Required.DisallowNull)] - private string TradeOfferIDText { - set { - if (string.IsNullOrEmpty(value)) { - ASF.ArchiLogger.LogNullError(nameof(value)); + internal ulong TradeOfferID { get; private set; } - return; - } + [JsonProperty(PropertyName = "tradeofferid", Required = Required.DisallowNull)] + private string TradeOfferIDText { + set { + if (string.IsNullOrEmpty(value)) { + ASF.ArchiLogger.LogNullError(nameof(value)); - if (!ulong.TryParse(value, out ulong tradeOfferID) || (tradeOfferID == 0)) { - ASF.ArchiLogger.LogNullError(nameof(tradeOfferID)); - - return; - } - - TradeOfferID = tradeOfferID; + return; } - } - [JsonConstructor] - private TradeOfferSendResponse() { } + if (!ulong.TryParse(value, out ulong tradeOfferID) || (tradeOfferID == 0)) { + ASF.ArchiLogger.LogNullError(nameof(tradeOfferID)); + + return; + } + + TradeOfferID = tradeOfferID; + } } + + [JsonConstructor] + private TradeOfferSendResponse() { } } diff --git a/ArchiSteamFarm/Steam/Data/UserPrivacy.cs b/ArchiSteamFarm/Steam/Data/UserPrivacy.cs index b3952faa1..0e4bed52b 100644 --- a/ArchiSteamFarm/Steam/Data/UserPrivacy.cs +++ b/ArchiSteamFarm/Steam/Data/UserPrivacy.cs @@ -25,85 +25,85 @@ using System.Diagnostics.CodeAnalysis; using ArchiSteamFarm.Steam.Integration; using Newtonsoft.Json; -namespace ArchiSteamFarm.Steam.Data { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - internal sealed class UserPrivacy { - [JsonProperty(PropertyName = "eCommentPermission", Required = Required.Always)] - internal readonly ECommentPermission CommentPermission; +namespace ArchiSteamFarm.Steam.Data; - [JsonProperty(PropertyName = "PrivacySettings", Required = Required.Always)] - internal readonly PrivacySettings Settings = new(); +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +internal sealed class UserPrivacy { + [JsonProperty(PropertyName = "eCommentPermission", Required = Required.Always)] + internal readonly ECommentPermission CommentPermission; + + [JsonProperty(PropertyName = "PrivacySettings", Required = Required.Always)] + internal readonly PrivacySettings Settings = new(); + + // Constructed from privacy change request + internal UserPrivacy(PrivacySettings settings, ECommentPermission commentPermission) { + Settings = settings ?? throw new ArgumentNullException(nameof(settings)); + CommentPermission = commentPermission; + } + + [JsonConstructor] + private UserPrivacy() { } + + internal sealed class PrivacySettings { + [JsonProperty(PropertyName = "PrivacyFriendsList", Required = Required.Always)] + internal readonly ArchiHandler.EPrivacySetting FriendsList; + + [JsonProperty(PropertyName = "PrivacyInventory", Required = Required.Always)] + internal readonly ArchiHandler.EPrivacySetting Inventory; + + [JsonProperty(PropertyName = "PrivacyInventoryGifts", Required = Required.Always)] + internal readonly ArchiHandler.EPrivacySetting InventoryGifts; + + [JsonProperty(PropertyName = "PrivacyOwnedGames", Required = Required.Always)] + internal readonly ArchiHandler.EPrivacySetting OwnedGames; + + [JsonProperty(PropertyName = "PrivacyPlaytime", Required = Required.Always)] + internal readonly ArchiHandler.EPrivacySetting Playtime; + + [JsonProperty(PropertyName = "PrivacyProfile", Required = Required.Always)] + internal readonly ArchiHandler.EPrivacySetting Profile; // Constructed from privacy change request - internal UserPrivacy(PrivacySettings settings, ECommentPermission commentPermission) { - Settings = settings ?? throw new ArgumentNullException(nameof(settings)); - CommentPermission = commentPermission; + internal PrivacySettings(ArchiHandler.EPrivacySetting profile, ArchiHandler.EPrivacySetting ownedGames, ArchiHandler.EPrivacySetting playtime, ArchiHandler.EPrivacySetting friendsList, ArchiHandler.EPrivacySetting inventory, ArchiHandler.EPrivacySetting inventoryGifts) { + if ((profile == ArchiHandler.EPrivacySetting.Unknown) || !Enum.IsDefined(typeof(ArchiHandler.EPrivacySetting), profile)) { + throw new InvalidEnumArgumentException(nameof(profile), (int) profile, typeof(ArchiHandler.EPrivacySetting)); + } + + if ((ownedGames == ArchiHandler.EPrivacySetting.Unknown) || !Enum.IsDefined(typeof(ArchiHandler.EPrivacySetting), ownedGames)) { + throw new InvalidEnumArgumentException(nameof(ownedGames), (int) ownedGames, typeof(ArchiHandler.EPrivacySetting)); + } + + if ((playtime == ArchiHandler.EPrivacySetting.Unknown) || !Enum.IsDefined(typeof(ArchiHandler.EPrivacySetting), playtime)) { + throw new InvalidEnumArgumentException(nameof(playtime), (int) playtime, typeof(ArchiHandler.EPrivacySetting)); + } + + if ((friendsList == ArchiHandler.EPrivacySetting.Unknown) || !Enum.IsDefined(typeof(ArchiHandler.EPrivacySetting), friendsList)) { + throw new InvalidEnumArgumentException(nameof(friendsList), (int) friendsList, typeof(ArchiHandler.EPrivacySetting)); + } + + if ((inventory == ArchiHandler.EPrivacySetting.Unknown) || !Enum.IsDefined(typeof(ArchiHandler.EPrivacySetting), inventory)) { + throw new InvalidEnumArgumentException(nameof(inventory), (int) inventory, typeof(ArchiHandler.EPrivacySetting)); + } + + if ((inventoryGifts == ArchiHandler.EPrivacySetting.Unknown) || !Enum.IsDefined(typeof(ArchiHandler.EPrivacySetting), inventoryGifts)) { + throw new InvalidEnumArgumentException(nameof(inventoryGifts), (int) inventoryGifts, typeof(ArchiHandler.EPrivacySetting)); + } + + Profile = profile; + OwnedGames = ownedGames; + Playtime = playtime; + FriendsList = friendsList; + Inventory = inventory; + InventoryGifts = inventoryGifts; } [JsonConstructor] - private UserPrivacy() { } + internal PrivacySettings() { } + } - internal sealed class PrivacySettings { - [JsonProperty(PropertyName = "PrivacyFriendsList", Required = Required.Always)] - internal readonly ArchiHandler.EPrivacySetting FriendsList; - - [JsonProperty(PropertyName = "PrivacyInventory", Required = Required.Always)] - internal readonly ArchiHandler.EPrivacySetting Inventory; - - [JsonProperty(PropertyName = "PrivacyInventoryGifts", Required = Required.Always)] - internal readonly ArchiHandler.EPrivacySetting InventoryGifts; - - [JsonProperty(PropertyName = "PrivacyOwnedGames", Required = Required.Always)] - internal readonly ArchiHandler.EPrivacySetting OwnedGames; - - [JsonProperty(PropertyName = "PrivacyPlaytime", Required = Required.Always)] - internal readonly ArchiHandler.EPrivacySetting Playtime; - - [JsonProperty(PropertyName = "PrivacyProfile", Required = Required.Always)] - internal readonly ArchiHandler.EPrivacySetting Profile; - - // Constructed from privacy change request - internal PrivacySettings(ArchiHandler.EPrivacySetting profile, ArchiHandler.EPrivacySetting ownedGames, ArchiHandler.EPrivacySetting playtime, ArchiHandler.EPrivacySetting friendsList, ArchiHandler.EPrivacySetting inventory, ArchiHandler.EPrivacySetting inventoryGifts) { - if ((profile == ArchiHandler.EPrivacySetting.Unknown) || !Enum.IsDefined(typeof(ArchiHandler.EPrivacySetting), profile)) { - throw new InvalidEnumArgumentException(nameof(profile), (int) profile, typeof(ArchiHandler.EPrivacySetting)); - } - - if ((ownedGames == ArchiHandler.EPrivacySetting.Unknown) || !Enum.IsDefined(typeof(ArchiHandler.EPrivacySetting), ownedGames)) { - throw new InvalidEnumArgumentException(nameof(ownedGames), (int) ownedGames, typeof(ArchiHandler.EPrivacySetting)); - } - - if ((playtime == ArchiHandler.EPrivacySetting.Unknown) || !Enum.IsDefined(typeof(ArchiHandler.EPrivacySetting), playtime)) { - throw new InvalidEnumArgumentException(nameof(playtime), (int) playtime, typeof(ArchiHandler.EPrivacySetting)); - } - - if ((friendsList == ArchiHandler.EPrivacySetting.Unknown) || !Enum.IsDefined(typeof(ArchiHandler.EPrivacySetting), friendsList)) { - throw new InvalidEnumArgumentException(nameof(friendsList), (int) friendsList, typeof(ArchiHandler.EPrivacySetting)); - } - - if ((inventory == ArchiHandler.EPrivacySetting.Unknown) || !Enum.IsDefined(typeof(ArchiHandler.EPrivacySetting), inventory)) { - throw new InvalidEnumArgumentException(nameof(inventory), (int) inventory, typeof(ArchiHandler.EPrivacySetting)); - } - - if ((inventoryGifts == ArchiHandler.EPrivacySetting.Unknown) || !Enum.IsDefined(typeof(ArchiHandler.EPrivacySetting), inventoryGifts)) { - throw new InvalidEnumArgumentException(nameof(inventoryGifts), (int) inventoryGifts, typeof(ArchiHandler.EPrivacySetting)); - } - - Profile = profile; - OwnedGames = ownedGames; - Playtime = playtime; - FriendsList = friendsList; - Inventory = inventory; - InventoryGifts = inventoryGifts; - } - - [JsonConstructor] - internal PrivacySettings() { } - } - - internal enum ECommentPermission : byte { - FriendsOnly, - Public, - Private - } + internal enum ECommentPermission : byte { + FriendsOnly, + Public, + Private } } diff --git a/ArchiSteamFarm/Steam/Exchange/ParseTradeResult.cs b/ArchiSteamFarm/Steam/Exchange/ParseTradeResult.cs index b6fcb3453..5ef48806d 100644 --- a/ArchiSteamFarm/Steam/Exchange/ParseTradeResult.cs +++ b/ArchiSteamFarm/Steam/Exchange/ParseTradeResult.cs @@ -27,40 +27,40 @@ using System.Linq; using ArchiSteamFarm.Steam.Data; using JetBrains.Annotations; -namespace ArchiSteamFarm.Steam.Exchange { - public sealed class ParseTradeResult { - [PublicAPI] - public EResult Result { get; } +namespace ArchiSteamFarm.Steam.Exchange; - [PublicAPI] - public ulong TradeOfferID { get; } +public sealed class ParseTradeResult { + [PublicAPI] + public EResult Result { get; } - internal readonly ImmutableHashSet? ReceivedItemTypes; + [PublicAPI] + public ulong TradeOfferID { get; } - internal ParseTradeResult(ulong tradeOfferID, EResult result, IReadOnlyCollection? itemsToReceive = null) { - if (tradeOfferID == 0) { - throw new ArgumentOutOfRangeException(nameof(tradeOfferID)); - } + internal readonly ImmutableHashSet? ReceivedItemTypes; - if ((result == EResult.Unknown) || !Enum.IsDefined(typeof(EResult), result)) { - throw new InvalidEnumArgumentException(nameof(result), (int) result, typeof(EResult)); - } - - TradeOfferID = tradeOfferID; - Result = result; - - if (itemsToReceive?.Count > 0) { - ReceivedItemTypes = itemsToReceive.Select(static item => item.Type).ToImmutableHashSet(); - } + internal ParseTradeResult(ulong tradeOfferID, EResult result, IReadOnlyCollection? itemsToReceive = null) { + if (tradeOfferID == 0) { + throw new ArgumentOutOfRangeException(nameof(tradeOfferID)); } - public enum EResult : byte { - Unknown, - Accepted, - Blacklisted, - Ignored, - Rejected, - TryAgain + if ((result == EResult.Unknown) || !Enum.IsDefined(typeof(EResult), result)) { + throw new InvalidEnumArgumentException(nameof(result), (int) result, typeof(EResult)); + } + + TradeOfferID = tradeOfferID; + Result = result; + + if (itemsToReceive?.Count > 0) { + ReceivedItemTypes = itemsToReceive.Select(static item => item.Type).ToImmutableHashSet(); } } + + public enum EResult : byte { + Unknown, + Accepted, + Blacklisted, + Ignored, + Rejected, + TryAgain + } } diff --git a/ArchiSteamFarm/Steam/Exchange/Trading.cs b/ArchiSteamFarm/Steam/Exchange/Trading.cs index e8cf29c73..bc0f2a262 100644 --- a/ArchiSteamFarm/Steam/Exchange/Trading.cs +++ b/ArchiSteamFarm/Steam/Exchange/Trading.cs @@ -37,649 +37,649 @@ using ArchiSteamFarm.Steam.Storage; using JetBrains.Annotations; using SteamKit2; -namespace ArchiSteamFarm.Steam.Exchange { - public sealed class Trading : IDisposable { - internal const byte MaxItemsPerTrade = byte.MaxValue; // This is decided upon various factors, mainly stability of Steam servers when dealing with huge trade offers - internal const byte MaxTradesPerAccount = 5; // This is limit introduced by Valve +namespace ArchiSteamFarm.Steam.Exchange; - private readonly Bot Bot; - private readonly ConcurrentHashSet HandledTradeOfferIDs = new(); - private readonly SemaphoreSlim TradesSemaphore = new(1, 1); +public sealed class Trading : IDisposable { + internal const byte MaxItemsPerTrade = byte.MaxValue; // This is decided upon various factors, mainly stability of Steam servers when dealing with huge trade offers + internal const byte MaxTradesPerAccount = 5; // This is limit introduced by Valve - private bool ParsingScheduled; + private readonly Bot Bot; + private readonly ConcurrentHashSet HandledTradeOfferIDs = new(); + private readonly SemaphoreSlim TradesSemaphore = new(1, 1); - internal Trading(Bot bot) => Bot = bot ?? throw new ArgumentNullException(nameof(bot)); + private bool ParsingScheduled; - public void Dispose() => TradesSemaphore.Dispose(); + internal Trading(Bot bot) => Bot = bot ?? throw new ArgumentNullException(nameof(bot)); - [PublicAPI] - public static Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), List> GetInventorySets(IReadOnlyCollection inventory) { - if ((inventory == null) || (inventory.Count == 0)) { - throw new ArgumentNullException(nameof(inventory)); - } + public void Dispose() => TradesSemaphore.Dispose(); - Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> sets = GetInventoryState(inventory); - - return sets.ToDictionary(static set => set.Key, static set => set.Value.Values.OrderBy(static amount => amount).ToList()); + [PublicAPI] + public static Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), List> GetInventorySets(IReadOnlyCollection inventory) { + if ((inventory == null) || (inventory.Count == 0)) { + throw new ArgumentNullException(nameof(inventory)); } - [PublicAPI] - public static bool IsFairExchange(IReadOnlyCollection itemsToGive, IReadOnlyCollection itemsToReceive) { - if ((itemsToGive == null) || (itemsToGive.Count == 0)) { - throw new ArgumentNullException(nameof(itemsToGive)); - } + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> sets = GetInventoryState(inventory); - if ((itemsToReceive == null) || (itemsToReceive.Count == 0)) { - throw new ArgumentNullException(nameof(itemsToReceive)); - } + return sets.ToDictionary(static set => set.Key, static set => set.Value.Values.OrderBy(static amount => amount).ToList()); + } - Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), uint> itemsToGiveAmounts = new(); - - foreach (Asset item in itemsToGive) { - (uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (item.RealAppID, item.Type, item.Rarity); - itemsToGiveAmounts[key] = itemsToGiveAmounts.TryGetValue(key, out uint amount) ? amount + item.Amount : item.Amount; - } - - Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), uint> itemsToReceiveAmounts = new(); - - foreach (Asset item in itemsToReceive) { - (uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (item.RealAppID, item.Type, item.Rarity); - itemsToReceiveAmounts[key] = itemsToReceiveAmounts.TryGetValue(key, out uint amount) ? amount + item.Amount : item.Amount; - } - - // Ensure that amount of items to give is at least amount of items to receive (per all fairness factors) - foreach (((uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key, uint amountToGive) in itemsToGiveAmounts) { - if (!itemsToReceiveAmounts.TryGetValue(key, out uint amountToReceive) || (amountToGive > amountToReceive)) { - return false; - } - } - - return true; + [PublicAPI] + public static bool IsFairExchange(IReadOnlyCollection itemsToGive, IReadOnlyCollection itemsToReceive) { + if ((itemsToGive == null) || (itemsToGive.Count == 0)) { + throw new ArgumentNullException(nameof(itemsToGive)); } - [PublicAPI] - public static bool IsTradeNeutralOrBetter(HashSet inventory, IReadOnlyCollection itemsToGive, IReadOnlyCollection itemsToReceive) { - if ((inventory == null) || (inventory.Count == 0)) { - throw new ArgumentNullException(nameof(inventory)); - } - - if ((itemsToGive == null) || (itemsToGive.Count == 0)) { - throw new ArgumentNullException(nameof(itemsToGive)); - } - - if ((itemsToReceive == null) || (itemsToReceive.Count == 0)) { - throw new ArgumentNullException(nameof(itemsToReceive)); - } - - // Input of this function is items we're expected to give/receive and our inventory (limited to realAppIDs of itemsToGive/itemsToReceive) - // The objective is to determine whether the new state is beneficial (or at least neutral) towards us - // There are a lot of factors involved here - different realAppIDs, different item types, possibility of user overpaying and more - // All of those cases should be verified by our unit tests to ensure that the logic here matches all possible cases, especially those that were incorrectly handled previously - - // Firstly we get initial sets state of our inventory - Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), List> initialSets = GetInventorySets(inventory); - - // Once we have initial state, we remove items that we're supposed to give from our inventory - // This loop is a bit more complex due to the fact that we might have a mix of the same item splitted into different amounts - foreach (Asset itemToGive in itemsToGive) { - uint amountToGive = itemToGive.Amount; - HashSet itemsToRemove = new(); - - // Keep in mind that ClassID is unique only within appID scope - we can do it like this because we're not dealing with non-Steam items here (otherwise we'd need to check appID too) - foreach (Asset item in inventory.Where(item => item.ClassID == itemToGive.ClassID)) { - if (amountToGive >= item.Amount) { - itemsToRemove.Add(item); - amountToGive -= item.Amount; - } else { - item.Amount -= amountToGive; - amountToGive = 0; - } - - if (amountToGive == 0) { - break; - } - } - - if (amountToGive > 0) { - throw new InvalidOperationException(nameof(amountToGive)); - } - - if (itemsToRemove.Count > 0) { - inventory.ExceptWith(itemsToRemove); - } - } - - // Now we can add items that we're supposed to receive, this one doesn't require advanced amounts logic since we can just add items regardless - foreach (Asset itemToReceive in itemsToReceive) { - inventory.Add(itemToReceive); - } - - // Now we can get final sets state of our inventory after the exchange - Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), List> finalSets = GetInventorySets(inventory); - - // Once we have both states, we can check overall fairness - foreach (((uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) set, List beforeAmounts) in initialSets) { - if (!finalSets.TryGetValue(set, out List? afterAmounts)) { - // If we have no info about this set, then it has to be a bad one - return false; - } - - // If amount of unique items in the set decreases, this is always a bad trade (e.g. 1 1 -> 0 2) - if (afterAmounts.Count < beforeAmounts.Count) { - return false; - } - - // If amount of unique items in the set increases, this is always a good trade (e.g. 0 2 -> 1 1) - if (afterAmounts.Count > beforeAmounts.Count) { - continue; - } - - // At this point we're sure that amount of unique items stays the same, so we can evaluate actual sets - // We make use of the fact that our amounts are already sorted in ascending order, so we can just take the first value instead of calculating ourselves - uint beforeSets = beforeAmounts[0]; - uint afterSets = afterAmounts[0]; - - // If amount of our sets for this game decreases, this is always a bad trade (e.g. 2 2 2 -> 3 2 1) - if (afterSets < beforeSets) { - return false; - } - - // If amount of our sets for this game increases, this is always a good trade (e.g. 3 2 1 -> 2 2 2) - if (afterSets > beforeSets) { - continue; - } - - // At this point we're sure that both number of unique items in the set stays the same, as well as number of our actual sets - // We need to ensure set progress here and keep in mind overpaying, so we'll calculate neutrality as a difference in amounts at appropriate indexes - // Neutrality can't reach value below 0 at any single point of calculation, as that would imply a loss of progress even if we'd end up with a positive value by the end - int neutrality = 0; - - for (byte i = 0; i < afterAmounts.Count; i++) { - neutrality += (int) (afterAmounts[i] - beforeAmounts[i]); - - if (neutrality < 0) { - return false; - } - } - } - - // If we didn't find any reason above to reject this trade, it's at least neutral+ for us - it increases our progress towards badge completion - return true; + if ((itemsToReceive == null) || (itemsToReceive.Count == 0)) { + throw new ArgumentNullException(nameof(itemsToReceive)); } - internal static (Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> FullState, Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> TradableState) GetDividedInventoryState(IReadOnlyCollection inventory) { - if ((inventory == null) || (inventory.Count == 0)) { - throw new ArgumentNullException(nameof(inventory)); + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), uint> itemsToGiveAmounts = new(); + + foreach (Asset item in itemsToGive) { + (uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (item.RealAppID, item.Type, item.Rarity); + itemsToGiveAmounts[key] = itemsToGiveAmounts.TryGetValue(key, out uint amount) ? amount + item.Amount : item.Amount; + } + + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), uint> itemsToReceiveAmounts = new(); + + foreach (Asset item in itemsToReceive) { + (uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (item.RealAppID, item.Type, item.Rarity); + itemsToReceiveAmounts[key] = itemsToReceiveAmounts.TryGetValue(key, out uint amount) ? amount + item.Amount : item.Amount; + } + + // Ensure that amount of items to give is at least amount of items to receive (per all fairness factors) + foreach (((uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key, uint amountToGive) in itemsToGiveAmounts) { + if (!itemsToReceiveAmounts.TryGetValue(key, out uint amountToReceive) || (amountToGive > amountToReceive)) { + return false; } + } - Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> fullState = new(); - Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> tradableState = new(); + return true; + } - foreach (Asset item in inventory) { - (uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (item.RealAppID, item.Type, item.Rarity); + [PublicAPI] + public static bool IsTradeNeutralOrBetter(HashSet inventory, IReadOnlyCollection itemsToGive, IReadOnlyCollection itemsToReceive) { + if ((inventory == null) || (inventory.Count == 0)) { + throw new ArgumentNullException(nameof(inventory)); + } - if (fullState.TryGetValue(key, out Dictionary? fullSet)) { - fullSet[item.ClassID] = fullSet.TryGetValue(item.ClassID, out uint amount) ? amount + item.Amount : item.Amount; + if ((itemsToGive == null) || (itemsToGive.Count == 0)) { + throw new ArgumentNullException(nameof(itemsToGive)); + } + + if ((itemsToReceive == null) || (itemsToReceive.Count == 0)) { + throw new ArgumentNullException(nameof(itemsToReceive)); + } + + // Input of this function is items we're expected to give/receive and our inventory (limited to realAppIDs of itemsToGive/itemsToReceive) + // The objective is to determine whether the new state is beneficial (or at least neutral) towards us + // There are a lot of factors involved here - different realAppIDs, different item types, possibility of user overpaying and more + // All of those cases should be verified by our unit tests to ensure that the logic here matches all possible cases, especially those that were incorrectly handled previously + + // Firstly we get initial sets state of our inventory + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), List> initialSets = GetInventorySets(inventory); + + // Once we have initial state, we remove items that we're supposed to give from our inventory + // This loop is a bit more complex due to the fact that we might have a mix of the same item splitted into different amounts + foreach (Asset itemToGive in itemsToGive) { + uint amountToGive = itemToGive.Amount; + HashSet itemsToRemove = new(); + + // Keep in mind that ClassID is unique only within appID scope - we can do it like this because we're not dealing with non-Steam items here (otherwise we'd need to check appID too) + foreach (Asset item in inventory.Where(item => item.ClassID == itemToGive.ClassID)) { + if (amountToGive >= item.Amount) { + itemsToRemove.Add(item); + amountToGive -= item.Amount; } else { - fullState[key] = new Dictionary { { item.ClassID, item.Amount } }; + item.Amount -= amountToGive; + amountToGive = 0; } - if (!item.Tradable) { - continue; - } - - if (tradableState.TryGetValue(key, out Dictionary? tradableSet)) { - tradableSet[item.ClassID] = tradableSet.TryGetValue(item.ClassID, out uint amount) ? amount + item.Amount : item.Amount; - } else { - tradableState[key] = new Dictionary { { item.ClassID, item.Amount } }; + if (amountToGive == 0) { + break; } } - return (fullState, tradableState); - } - - internal static Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> GetTradableInventoryState(IReadOnlyCollection inventory) { - if ((inventory == null) || (inventory.Count == 0)) { - throw new ArgumentNullException(nameof(inventory)); + if (amountToGive > 0) { + throw new InvalidOperationException(nameof(amountToGive)); } - Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> tradableState = new(); - - foreach (Asset item in inventory.Where(static item => item.Tradable)) { - (uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (item.RealAppID, item.Type, item.Rarity); - - if (tradableState.TryGetValue(key, out Dictionary? tradableSet)) { - tradableSet[item.ClassID] = tradableSet.TryGetValue(item.ClassID, out uint amount) ? amount + item.Amount : item.Amount; - } else { - tradableState[key] = new Dictionary { { item.ClassID, item.Amount } }; - } - } - - return tradableState; - } - - internal static HashSet GetTradableItemsFromInventory(IReadOnlyCollection inventory, IDictionary classIDs) { - if ((inventory == null) || (inventory.Count == 0)) { - throw new ArgumentNullException(nameof(inventory)); - } - - if ((classIDs == null) || (classIDs.Count == 0)) { - throw new ArgumentNullException(nameof(classIDs)); - } - - HashSet result = new(); - - foreach (Asset item in inventory.Where(static item => item.Tradable)) { - if (!classIDs.TryGetValue(item.ClassID, out uint amount)) { - continue; - } - - if (amount < item.Amount) { - item.Amount = amount; - } - - result.Add(item); - - if (amount == item.Amount) { - classIDs.Remove(item.ClassID); - } else { - classIDs[item.ClassID] = amount - item.Amount; - } - } - - return result; - } - - internal static bool IsEmptyForMatching(IReadOnlyDictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> fullState, IReadOnlyDictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> tradableState) { - if (fullState == null) { - throw new ArgumentNullException(nameof(fullState)); - } - - if (tradableState == null) { - throw new ArgumentNullException(nameof(tradableState)); - } - - foreach (((uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) set, IReadOnlyDictionary state) in tradableState) { - if (!fullState.TryGetValue(set, out Dictionary? fullSet) || (fullSet.Count == 0)) { - throw new InvalidOperationException(nameof(fullSet)); - } - - if (!IsEmptyForMatching(fullSet, state)) { - return false; - } - } - - // We didn't find any matchable combinations, so this inventory is empty - return true; - } - - internal static bool IsEmptyForMatching(IReadOnlyDictionary fullSet, IReadOnlyDictionary tradableSet) { - if (fullSet == null) { - throw new ArgumentNullException(nameof(fullSet)); - } - - if (tradableSet == null) { - throw new ArgumentNullException(nameof(tradableSet)); - } - - foreach ((ulong classID, uint amount) in tradableSet) { - switch (amount) { - case 0: - // No tradable items, this should never happen, dictionary should not have this key to begin with - throw new InvalidOperationException(nameof(amount)); - case 1: - // Single tradable item, can be matchable or not depending on the rest of the inventory - if (!fullSet.TryGetValue(classID, out uint fullAmount) || (fullAmount == 0)) { - throw new InvalidOperationException(nameof(fullAmount)); - } - - if (fullAmount > 1) { - // If we have a single tradable item but more than 1 in total, this is matchable - return false; - } - - // A single exclusive tradable item is not matchable, continue - continue; - default: - // Any other combination of tradable items is always matchable - return false; - } - } - - // We didn't find any matchable combinations, so this inventory is empty - return true; - } - - internal void OnDisconnected() => HandledTradeOfferIDs.Clear(); - - internal async Task OnNewTrade() { - // We aim to have a maximum of 2 tasks, one already working, and one waiting in the queue - // This way we can call this function as many times as needed e.g. because of Steam events - - // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that - lock (TradesSemaphore) { - if (ParsingScheduled) { - return; - } - - ParsingScheduled = true; - } - - await TradesSemaphore.WaitAsync().ConfigureAwait(false); - - try { - bool lootableTypesReceived; - - using (await Bot.Actions.GetTradingLock().ConfigureAwait(false)) { - // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that - lock (TradesSemaphore) { - ParsingScheduled = false; - } - - lootableTypesReceived = await ParseActiveTrades().ConfigureAwait(false); - } - - if (lootableTypesReceived && Bot.BotConfig.SendOnFarmingFinished && (Bot.BotConfig.LootableTypes.Count > 0)) { - await Bot.Actions.SendInventory(filterFunction: item => Bot.BotConfig.LootableTypes.Contains(item.Type)).ConfigureAwait(false); - } - } finally { - TradesSemaphore.Release(); + if (itemsToRemove.Count > 0) { + inventory.ExceptWith(itemsToRemove); } } - private static Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> GetInventoryState(IReadOnlyCollection inventory) { - if ((inventory == null) || (inventory.Count == 0)) { - throw new ArgumentNullException(nameof(inventory)); - } - - Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> state = new(); - - foreach (Asset item in inventory) { - (uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (item.RealAppID, item.Type, item.Rarity); - - if (state.TryGetValue(key, out Dictionary? set)) { - set[item.ClassID] = set.TryGetValue(item.ClassID, out uint amount) ? amount + item.Amount : item.Amount; - } else { - state[key] = new Dictionary { { item.ClassID, item.Amount } }; - } - } - - return state; + // Now we can add items that we're supposed to receive, this one doesn't require advanced amounts logic since we can just add items regardless + foreach (Asset itemToReceive in itemsToReceive) { + inventory.Add(itemToReceive); } - private async Task ParseActiveTrades() { - HashSet? tradeOffers = await Bot.ArchiWebHandler.GetActiveTradeOffers().ConfigureAwait(false); + // Now we can get final sets state of our inventory after the exchange + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), List> finalSets = GetInventorySets(inventory); - if ((tradeOffers == null) || (tradeOffers.Count == 0)) { + // Once we have both states, we can check overall fairness + foreach (((uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) set, List beforeAmounts) in initialSets) { + if (!finalSets.TryGetValue(set, out List? afterAmounts)) { + // If we have no info about this set, then it has to be a bad one return false; } - if (HandledTradeOfferIDs.Count > 0) { - HandledTradeOfferIDs.IntersectWith(tradeOffers.Select(static tradeOffer => tradeOffer.TradeOfferID)); + // If amount of unique items in the set decreases, this is always a bad trade (e.g. 1 1 -> 0 2) + if (afterAmounts.Count < beforeAmounts.Count) { + return false; } - IEnumerable> tasks = tradeOffers.Where(tradeOffer => !HandledTradeOfferIDs.Contains(tradeOffer.TradeOfferID)).Select(ParseTrade); - IList<(ParseTradeResult? TradeResult, bool RequiresMobileConfirmation)> results = await Utilities.InParallel(tasks).ConfigureAwait(false); + // If amount of unique items in the set increases, this is always a good trade (e.g. 0 2 -> 1 1) + if (afterAmounts.Count > beforeAmounts.Count) { + continue; + } - if (Bot.HasMobileAuthenticator) { - HashSet mobileTradeOfferIDs = results.Where(static result => (result.TradeResult?.Result == ParseTradeResult.EResult.Accepted) && result.RequiresMobileConfirmation).Select(static result => result.TradeResult!.TradeOfferID).ToHashSet(); + // At this point we're sure that amount of unique items stays the same, so we can evaluate actual sets + // We make use of the fact that our amounts are already sorted in ascending order, so we can just take the first value instead of calculating ourselves + uint beforeSets = beforeAmounts[0]; + uint afterSets = afterAmounts[0]; - if (mobileTradeOfferIDs.Count > 0) { - (bool twoFactorSuccess, _, _) = await Bot.Actions.HandleTwoFactorAuthenticationConfirmations(true, Confirmation.EType.Trade, mobileTradeOfferIDs, true).ConfigureAwait(false); + // If amount of our sets for this game decreases, this is always a bad trade (e.g. 2 2 2 -> 3 2 1) + if (afterSets < beforeSets) { + return false; + } - if (!twoFactorSuccess) { - HandledTradeOfferIDs.ExceptWith(mobileTradeOfferIDs); + // If amount of our sets for this game increases, this is always a good trade (e.g. 3 2 1 -> 2 2 2) + if (afterSets > beforeSets) { + continue; + } + // At this point we're sure that both number of unique items in the set stays the same, as well as number of our actual sets + // We need to ensure set progress here and keep in mind overpaying, so we'll calculate neutrality as a difference in amounts at appropriate indexes + // Neutrality can't reach value below 0 at any single point of calculation, as that would imply a loss of progress even if we'd end up with a positive value by the end + int neutrality = 0; + + for (byte i = 0; i < afterAmounts.Count; i++) { + neutrality += (int) (afterAmounts[i] - beforeAmounts[i]); + + if (neutrality < 0) { + return false; + } + } + } + + // If we didn't find any reason above to reject this trade, it's at least neutral+ for us - it increases our progress towards badge completion + return true; + } + + internal static (Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> FullState, Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> TradableState) GetDividedInventoryState(IReadOnlyCollection inventory) { + if ((inventory == null) || (inventory.Count == 0)) { + throw new ArgumentNullException(nameof(inventory)); + } + + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> fullState = new(); + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> tradableState = new(); + + foreach (Asset item in inventory) { + (uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (item.RealAppID, item.Type, item.Rarity); + + if (fullState.TryGetValue(key, out Dictionary? fullSet)) { + fullSet[item.ClassID] = fullSet.TryGetValue(item.ClassID, out uint amount) ? amount + item.Amount : item.Amount; + } else { + fullState[key] = new Dictionary { { item.ClassID, item.Amount } }; + } + + if (!item.Tradable) { + continue; + } + + if (tradableState.TryGetValue(key, out Dictionary? tradableSet)) { + tradableSet[item.ClassID] = tradableSet.TryGetValue(item.ClassID, out uint amount) ? amount + item.Amount : item.Amount; + } else { + tradableState[key] = new Dictionary { { item.ClassID, item.Amount } }; + } + } + + return (fullState, tradableState); + } + + internal static Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> GetTradableInventoryState(IReadOnlyCollection inventory) { + if ((inventory == null) || (inventory.Count == 0)) { + throw new ArgumentNullException(nameof(inventory)); + } + + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> tradableState = new(); + + foreach (Asset item in inventory.Where(static item => item.Tradable)) { + (uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (item.RealAppID, item.Type, item.Rarity); + + if (tradableState.TryGetValue(key, out Dictionary? tradableSet)) { + tradableSet[item.ClassID] = tradableSet.TryGetValue(item.ClassID, out uint amount) ? amount + item.Amount : item.Amount; + } else { + tradableState[key] = new Dictionary { { item.ClassID, item.Amount } }; + } + } + + return tradableState; + } + + internal static HashSet GetTradableItemsFromInventory(IReadOnlyCollection inventory, IDictionary classIDs) { + if ((inventory == null) || (inventory.Count == 0)) { + throw new ArgumentNullException(nameof(inventory)); + } + + if ((classIDs == null) || (classIDs.Count == 0)) { + throw new ArgumentNullException(nameof(classIDs)); + } + + HashSet result = new(); + + foreach (Asset item in inventory.Where(static item => item.Tradable)) { + if (!classIDs.TryGetValue(item.ClassID, out uint amount)) { + continue; + } + + if (amount < item.Amount) { + item.Amount = amount; + } + + result.Add(item); + + if (amount == item.Amount) { + classIDs.Remove(item.ClassID); + } else { + classIDs[item.ClassID] = amount - item.Amount; + } + } + + return result; + } + + internal static bool IsEmptyForMatching(IReadOnlyDictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> fullState, IReadOnlyDictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> tradableState) { + if (fullState == null) { + throw new ArgumentNullException(nameof(fullState)); + } + + if (tradableState == null) { + throw new ArgumentNullException(nameof(tradableState)); + } + + foreach (((uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) set, IReadOnlyDictionary state) in tradableState) { + if (!fullState.TryGetValue(set, out Dictionary? fullSet) || (fullSet.Count == 0)) { + throw new InvalidOperationException(nameof(fullSet)); + } + + if (!IsEmptyForMatching(fullSet, state)) { + return false; + } + } + + // We didn't find any matchable combinations, so this inventory is empty + return true; + } + + internal static bool IsEmptyForMatching(IReadOnlyDictionary fullSet, IReadOnlyDictionary tradableSet) { + if (fullSet == null) { + throw new ArgumentNullException(nameof(fullSet)); + } + + if (tradableSet == null) { + throw new ArgumentNullException(nameof(tradableSet)); + } + + foreach ((ulong classID, uint amount) in tradableSet) { + switch (amount) { + case 0: + // No tradable items, this should never happen, dictionary should not have this key to begin with + throw new InvalidOperationException(nameof(amount)); + case 1: + // Single tradable item, can be matchable or not depending on the rest of the inventory + if (!fullSet.TryGetValue(classID, out uint fullAmount) || (fullAmount == 0)) { + throw new InvalidOperationException(nameof(fullAmount)); + } + + if (fullAmount > 1) { + // If we have a single tradable item but more than 1 in total, this is matchable return false; } - } + + // A single exclusive tradable item is not matchable, continue + continue; + default: + // Any other combination of tradable items is always matchable + return false; } - - HashSet validTradeResults = results.Where(static result => result.TradeResult != null).Select(static result => result.TradeResult!).ToHashSet(); - - if (validTradeResults.Count > 0) { - await PluginsCore.OnBotTradeOfferResults(Bot, validTradeResults).ConfigureAwait(false); - } - - return results.Any(result => (result.TradeResult?.Result == ParseTradeResult.EResult.Accepted) && (!result.RequiresMobileConfirmation || Bot.HasMobileAuthenticator) && (result.TradeResult.ReceivedItemTypes?.Any(receivedItemType => Bot.BotConfig.LootableTypes.Contains(receivedItemType)) == true)); } - private async Task<(ParseTradeResult? TradeResult, bool RequiresMobileConfirmation)> ParseTrade(TradeOffer tradeOffer) { - if (tradeOffer == null) { - throw new ArgumentNullException(nameof(tradeOffer)); + // We didn't find any matchable combinations, so this inventory is empty + return true; + } + + internal void OnDisconnected() => HandledTradeOfferIDs.Clear(); + + internal async Task OnNewTrade() { + // We aim to have a maximum of 2 tasks, one already working, and one waiting in the queue + // This way we can call this function as many times as needed e.g. because of Steam events + + // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that + lock (TradesSemaphore) { + if (ParsingScheduled) { + return; } - if (tradeOffer.State != ETradeOfferState.Active) { - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, tradeOffer.State)); + ParsingScheduled = true; + } + + await TradesSemaphore.WaitAsync().ConfigureAwait(false); + + try { + bool lootableTypesReceived; + + using (await Bot.Actions.GetTradingLock().ConfigureAwait(false)) { + // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that + lock (TradesSemaphore) { + ParsingScheduled = false; + } + + lootableTypesReceived = await ParseActiveTrades().ConfigureAwait(false); + } + + if (lootableTypesReceived && Bot.BotConfig.SendOnFarmingFinished && (Bot.BotConfig.LootableTypes.Count > 0)) { + await Bot.Actions.SendInventory(filterFunction: item => Bot.BotConfig.LootableTypes.Contains(item.Type)).ConfigureAwait(false); + } + } finally { + TradesSemaphore.Release(); + } + } + + private static Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> GetInventoryState(IReadOnlyCollection inventory) { + if ((inventory == null) || (inventory.Count == 0)) { + throw new ArgumentNullException(nameof(inventory)); + } + + Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), Dictionary> state = new(); + + foreach (Asset item in inventory) { + (uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (item.RealAppID, item.Type, item.Rarity); + + if (state.TryGetValue(key, out Dictionary? set)) { + set[item.ClassID] = set.TryGetValue(item.ClassID, out uint amount) ? amount + item.Amount : item.Amount; + } else { + state[key] = new Dictionary { { item.ClassID, item.Amount } }; + } + } + + return state; + } + + private async Task ParseActiveTrades() { + HashSet? tradeOffers = await Bot.ArchiWebHandler.GetActiveTradeOffers().ConfigureAwait(false); + + if ((tradeOffers == null) || (tradeOffers.Count == 0)) { + return false; + } + + if (HandledTradeOfferIDs.Count > 0) { + HandledTradeOfferIDs.IntersectWith(tradeOffers.Select(static tradeOffer => tradeOffer.TradeOfferID)); + } + + IEnumerable> tasks = tradeOffers.Where(tradeOffer => !HandledTradeOfferIDs.Contains(tradeOffer.TradeOfferID)).Select(ParseTrade); + IList<(ParseTradeResult? TradeResult, bool RequiresMobileConfirmation)> results = await Utilities.InParallel(tasks).ConfigureAwait(false); + + if (Bot.HasMobileAuthenticator) { + HashSet mobileTradeOfferIDs = results.Where(static result => (result.TradeResult?.Result == ParseTradeResult.EResult.Accepted) && result.RequiresMobileConfirmation).Select(static result => result.TradeResult!.TradeOfferID).ToHashSet(); + + if (mobileTradeOfferIDs.Count > 0) { + (bool twoFactorSuccess, _, _) = await Bot.Actions.HandleTwoFactorAuthenticationConfirmations(true, Confirmation.EType.Trade, mobileTradeOfferIDs, true).ConfigureAwait(false); + + if (!twoFactorSuccess) { + HandledTradeOfferIDs.ExceptWith(mobileTradeOfferIDs); + + return false; + } + } + } + + HashSet validTradeResults = results.Where(static result => result.TradeResult != null).Select(static result => result.TradeResult!).ToHashSet(); + + if (validTradeResults.Count > 0) { + await PluginsCore.OnBotTradeOfferResults(Bot, validTradeResults).ConfigureAwait(false); + } + + return results.Any(result => (result.TradeResult?.Result == ParseTradeResult.EResult.Accepted) && (!result.RequiresMobileConfirmation || Bot.HasMobileAuthenticator) && (result.TradeResult.ReceivedItemTypes?.Any(receivedItemType => Bot.BotConfig.LootableTypes.Contains(receivedItemType)) == true)); + } + + private async Task<(ParseTradeResult? TradeResult, bool RequiresMobileConfirmation)> ParseTrade(TradeOffer tradeOffer) { + if (tradeOffer == null) { + throw new ArgumentNullException(nameof(tradeOffer)); + } + + if (tradeOffer.State != ETradeOfferState.Active) { + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, tradeOffer.State)); + + return (null, false); + } + + if (!HandledTradeOfferIDs.Add(tradeOffer.TradeOfferID)) { + // We've already seen this trade, this should not happen + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.IgnoringTrade, tradeOffer.TradeOfferID)); + + return (new ParseTradeResult(tradeOffer.TradeOfferID, ParseTradeResult.EResult.Ignored, tradeOffer.ItemsToReceive), false); + } + + ParseTradeResult.EResult result = await ShouldAcceptTrade(tradeOffer).ConfigureAwait(false); + bool tradeRequiresMobileConfirmation = false; + + switch (result) { + case ParseTradeResult.EResult.Ignored: + case ParseTradeResult.EResult.Rejected: + bool accept = await PluginsCore.OnBotTradeOffer(Bot, tradeOffer).ConfigureAwait(false); + + if (accept) { + result = ParseTradeResult.EResult.Accepted; + } + + break; + } + + switch (result) { + case ParseTradeResult.EResult.Accepted: + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.AcceptingTrade, tradeOffer.TradeOfferID)); + + (bool success, bool requiresMobileConfirmation) = await Bot.ArchiWebHandler.AcceptTradeOffer(tradeOffer.TradeOfferID).ConfigureAwait(false); + + if (!success) { + result = ParseTradeResult.EResult.TryAgain; + + goto case ParseTradeResult.EResult.TryAgain; + } + + if (tradeOffer.ItemsToReceive.Sum(static item => item.Amount) > tradeOffer.ItemsToGive.Sum(static item => item.Amount)) { + Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.BotAcceptedDonationTrade, tradeOffer.TradeOfferID)); + } + + tradeRequiresMobileConfirmation = requiresMobileConfirmation; + + break; + case ParseTradeResult.EResult.Blacklisted: + case ParseTradeResult.EResult.Rejected when Bot.BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.RejectInvalidTrades): + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.RejectingTrade, tradeOffer.TradeOfferID)); + + if (!await Bot.ArchiWebHandler.DeclineTradeOffer(tradeOffer.TradeOfferID).ConfigureAwait(false)) { + result = ParseTradeResult.EResult.TryAgain; + + goto case ParseTradeResult.EResult.TryAgain; + } + + break; + case ParseTradeResult.EResult.Ignored: + case ParseTradeResult.EResult.Rejected: + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.IgnoringTrade, tradeOffer.TradeOfferID)); + + break; + case ParseTradeResult.EResult.TryAgain: + HandledTradeOfferIDs.Remove(tradeOffer.TradeOfferID); + + goto case ParseTradeResult.EResult.Ignored; + default: + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(result), result)); return (null, false); - } - - if (!HandledTradeOfferIDs.Add(tradeOffer.TradeOfferID)) { - // We've already seen this trade, this should not happen - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.IgnoringTrade, tradeOffer.TradeOfferID)); - - return (new ParseTradeResult(tradeOffer.TradeOfferID, ParseTradeResult.EResult.Ignored, tradeOffer.ItemsToReceive), false); - } - - ParseTradeResult.EResult result = await ShouldAcceptTrade(tradeOffer).ConfigureAwait(false); - bool tradeRequiresMobileConfirmation = false; - - switch (result) { - case ParseTradeResult.EResult.Ignored: - case ParseTradeResult.EResult.Rejected: - bool accept = await PluginsCore.OnBotTradeOffer(Bot, tradeOffer).ConfigureAwait(false); - - if (accept) { - result = ParseTradeResult.EResult.Accepted; - } - - break; - } - - switch (result) { - case ParseTradeResult.EResult.Accepted: - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.AcceptingTrade, tradeOffer.TradeOfferID)); - - (bool success, bool requiresMobileConfirmation) = await Bot.ArchiWebHandler.AcceptTradeOffer(tradeOffer.TradeOfferID).ConfigureAwait(false); - - if (!success) { - result = ParseTradeResult.EResult.TryAgain; - - goto case ParseTradeResult.EResult.TryAgain; - } - - if (tradeOffer.ItemsToReceive.Sum(static item => item.Amount) > tradeOffer.ItemsToGive.Sum(static item => item.Amount)) { - Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.BotAcceptedDonationTrade, tradeOffer.TradeOfferID)); - } - - tradeRequiresMobileConfirmation = requiresMobileConfirmation; - - break; - case ParseTradeResult.EResult.Blacklisted: - case ParseTradeResult.EResult.Rejected when Bot.BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.RejectInvalidTrades): - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.RejectingTrade, tradeOffer.TradeOfferID)); - - if (!await Bot.ArchiWebHandler.DeclineTradeOffer(tradeOffer.TradeOfferID).ConfigureAwait(false)) { - result = ParseTradeResult.EResult.TryAgain; - - goto case ParseTradeResult.EResult.TryAgain; - } - - break; - case ParseTradeResult.EResult.Ignored: - case ParseTradeResult.EResult.Rejected: - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.IgnoringTrade, tradeOffer.TradeOfferID)); - - break; - case ParseTradeResult.EResult.TryAgain: - HandledTradeOfferIDs.Remove(tradeOffer.TradeOfferID); - - goto case ParseTradeResult.EResult.Ignored; - default: - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(result), result)); - - return (null, false); - } - - return (new ParseTradeResult(tradeOffer.TradeOfferID, result, tradeOffer.ItemsToReceive), tradeRequiresMobileConfirmation); } - private async Task ShouldAcceptTrade(TradeOffer tradeOffer) { - if (tradeOffer == null) { - throw new ArgumentNullException(nameof(tradeOffer)); - } + return (new ParseTradeResult(tradeOffer.TradeOfferID, result, tradeOffer.ItemsToReceive), tradeRequiresMobileConfirmation); + } - if (ASF.GlobalConfig == null) { - throw new InvalidOperationException(nameof(ASF.GlobalConfig)); - } + private async Task ShouldAcceptTrade(TradeOffer tradeOffer) { + if (tradeOffer == null) { + throw new ArgumentNullException(nameof(tradeOffer)); + } - if (Bot.Bots == null) { - throw new InvalidOperationException(nameof(Bot.Bots)); - } + if (ASF.GlobalConfig == null) { + throw new InvalidOperationException(nameof(ASF.GlobalConfig)); + } - if (tradeOffer.OtherSteamID64 != 0) { - // Always accept trades from SteamMasterID - if (Bot.HasAccess(tradeOffer.OtherSteamID64, BotConfig.EAccess.Master)) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Accepted, $"{nameof(tradeOffer.OtherSteamID64)} {tradeOffer.OtherSteamID64}: {BotConfig.EAccess.Master}")); + if (Bot.Bots == null) { + throw new InvalidOperationException(nameof(Bot.Bots)); + } - return ParseTradeResult.EResult.Accepted; - } - - // Always deny trades from blacklisted steamIDs - if (Bot.IsBlacklistedFromTrades(tradeOffer.OtherSteamID64)) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Blacklisted, $"{nameof(tradeOffer.OtherSteamID64)} {tradeOffer.OtherSteamID64}")); - - return ParseTradeResult.EResult.Blacklisted; - } - } - - // Check if it's donation trade - switch (tradeOffer.ItemsToGive.Count) { - case 0 when tradeOffer.ItemsToReceive.Count == 0: - // If it's steam issue, try again later - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.TryAgain, $"{nameof(tradeOffer.ItemsToReceive.Count)} = 0")); - - return ParseTradeResult.EResult.TryAgain; - case 0: - // Otherwise react accordingly, depending on our preference - bool acceptDonations = Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.AcceptDonations); - bool acceptBotTrades = !Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.DontAcceptBotTrades); - - switch (acceptDonations) { - case true when acceptBotTrades: - // If we accept donations and bot trades, accept it right away - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Accepted, $"{nameof(acceptDonations)} = {true} && {nameof(acceptBotTrades)} = {true}")); - - return ParseTradeResult.EResult.Accepted; - - case false when !acceptBotTrades: - // If we don't accept donations, neither bot trades, deny it right away - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Rejected, $"{nameof(acceptDonations)} = {false} && {nameof(acceptBotTrades)} = {false}")); - - return ParseTradeResult.EResult.Rejected; - } - - // Otherwise we either accept donations but not bot trades, or we accept bot trades but not donations - bool isBotTrade = (tradeOffer.OtherSteamID64 != 0) && Bot.Bots.Values.Any(bot => bot.SteamID == tradeOffer.OtherSteamID64); - - ParseTradeResult.EResult result = (acceptDonations && !isBotTrade) || (acceptBotTrades && isBotTrade) ? ParseTradeResult.EResult.Accepted : ParseTradeResult.EResult.Rejected; - - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, result, $"{nameof(acceptDonations)} = {acceptDonations} && {nameof(acceptBotTrades)} = {acceptBotTrades} && {nameof(isBotTrade)} = {isBotTrade}")); - - return result; - } - - // If we don't have SteamTradeMatcher enabled, this is the end for us - if (!Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.SteamTradeMatcher)) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Rejected, $"{nameof(BotConfig.ETradingPreferences.SteamTradeMatcher)} = {false}")); - - return ParseTradeResult.EResult.Rejected; - } - - // Decline trade if we're giving more count-wise, this is a very naive pre-check, it'll be strengthened in more detailed fair types exchange next - if (tradeOffer.ItemsToGive.Count > tradeOffer.ItemsToReceive.Count) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Rejected, $"{nameof(tradeOffer.ItemsToGive.Count)}: {tradeOffer.ItemsToGive.Count} > {tradeOffer.ItemsToReceive.Count}")); - - return ParseTradeResult.EResult.Rejected; - } - - // Decline trade if we're requested to handle any not-accepted item type or if it's not fair games/types exchange - if (!tradeOffer.IsValidSteamItemsRequest(Bot.BotConfig.MatchableTypes) || !IsFairExchange(tradeOffer.ItemsToGive, tradeOffer.ItemsToReceive)) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Rejected, $"{nameof(tradeOffer.IsValidSteamItemsRequest)} || {nameof(IsFairExchange)}")); - - return ParseTradeResult.EResult.Rejected; - } - - // At this point we're sure that STM trade is valid - - // Fetch trade hold duration - byte? holdDuration = await Bot.GetTradeHoldDuration(tradeOffer.OtherSteamID64, tradeOffer.TradeOfferID).ConfigureAwait(false); - - switch (holdDuration) { - case null: - // If we can't get trade hold duration, try again later - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.TryAgain, nameof(holdDuration))); - - return ParseTradeResult.EResult.TryAgain; - - // If user has a trade hold, we add extra logic - // If trade hold duration exceeds our max, or user asks for cards with short lifespan, reject the trade - case > 0 when (holdDuration.Value > ASF.GlobalConfig.MaxTradeHoldDuration) || tradeOffer.ItemsToGive.Any(static item => item.Type is Asset.EType.FoilTradingCard or Asset.EType.TradingCard && CardsFarmer.SalesBlacklist.Contains(item.RealAppID)): - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Rejected, $"{nameof(holdDuration)} > 0: {holdDuration.Value}")); - - return ParseTradeResult.EResult.Rejected; - } - - // If we're matching everything, this is enough for us - if (Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.MatchEverything)) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Accepted, BotConfig.ETradingPreferences.MatchEverything)); + if (tradeOffer.OtherSteamID64 != 0) { + // Always accept trades from SteamMasterID + if (Bot.HasAccess(tradeOffer.OtherSteamID64, BotConfig.EAccess.Master)) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Accepted, $"{nameof(tradeOffer.OtherSteamID64)} {tradeOffer.OtherSteamID64}: {BotConfig.EAccess.Master}")); return ParseTradeResult.EResult.Accepted; } - // Get sets we're interested in - HashSet<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity)> wantedSets = new(); + // Always deny trades from blacklisted steamIDs + if (Bot.IsBlacklistedFromTrades(tradeOffer.OtherSteamID64)) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Blacklisted, $"{nameof(tradeOffer.OtherSteamID64)} {tradeOffer.OtherSteamID64}")); - foreach (Asset item in tradeOffer.ItemsToGive) { - wantedSets.Add((item.RealAppID, item.Type, item.Rarity)); + return ParseTradeResult.EResult.Blacklisted; } - - // Now check if it's worth for us to do the trade - HashSet inventory; - - try { - inventory = await Bot.ArchiWebHandler.GetInventoryAsync().Where(item => wantedSets.Contains((item.RealAppID, item.Type, item.Rarity))).ToHashSetAsync().ConfigureAwait(false); - } catch (HttpRequestException e) { - // If we can't check our inventory when not using MatchEverything, this is a temporary failure, try again later - Bot.ArchiLogger.LogGenericWarningException(e); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.TryAgain, nameof(inventory))); - - return ParseTradeResult.EResult.TryAgain; - } catch (Exception e) { - // If we can't check our inventory when not using MatchEverything, this is a temporary failure, try again later - Bot.ArchiLogger.LogGenericException(e); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.TryAgain, nameof(inventory))); - - return ParseTradeResult.EResult.TryAgain; - } - - if (inventory.Count == 0) { - // If we can't check our inventory when not using MatchEverything, this is a temporary failure, try again later - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(inventory))); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.TryAgain, nameof(inventory))); - - return ParseTradeResult.EResult.TryAgain; - } - - bool accept = IsTradeNeutralOrBetter(inventory, tradeOffer.ItemsToGive.Select(static item => item.CreateShallowCopy()).ToHashSet(), tradeOffer.ItemsToReceive.Select(static item => item.CreateShallowCopy()).ToHashSet()); - - // We're now sure whether the trade is neutral+ for us or not - ParseTradeResult.EResult acceptResult = accept ? ParseTradeResult.EResult.Accepted : ParseTradeResult.EResult.Rejected; - - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, acceptResult, nameof(IsTradeNeutralOrBetter))); - - return acceptResult; } + + // Check if it's donation trade + switch (tradeOffer.ItemsToGive.Count) { + case 0 when tradeOffer.ItemsToReceive.Count == 0: + // If it's steam issue, try again later + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.TryAgain, $"{nameof(tradeOffer.ItemsToReceive.Count)} = 0")); + + return ParseTradeResult.EResult.TryAgain; + case 0: + // Otherwise react accordingly, depending on our preference + bool acceptDonations = Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.AcceptDonations); + bool acceptBotTrades = !Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.DontAcceptBotTrades); + + switch (acceptDonations) { + case true when acceptBotTrades: + // If we accept donations and bot trades, accept it right away + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Accepted, $"{nameof(acceptDonations)} = {true} && {nameof(acceptBotTrades)} = {true}")); + + return ParseTradeResult.EResult.Accepted; + + case false when !acceptBotTrades: + // If we don't accept donations, neither bot trades, deny it right away + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Rejected, $"{nameof(acceptDonations)} = {false} && {nameof(acceptBotTrades)} = {false}")); + + return ParseTradeResult.EResult.Rejected; + } + + // Otherwise we either accept donations but not bot trades, or we accept bot trades but not donations + bool isBotTrade = (tradeOffer.OtherSteamID64 != 0) && Bot.Bots.Values.Any(bot => bot.SteamID == tradeOffer.OtherSteamID64); + + ParseTradeResult.EResult result = (acceptDonations && !isBotTrade) || (acceptBotTrades && isBotTrade) ? ParseTradeResult.EResult.Accepted : ParseTradeResult.EResult.Rejected; + + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, result, $"{nameof(acceptDonations)} = {acceptDonations} && {nameof(acceptBotTrades)} = {acceptBotTrades} && {nameof(isBotTrade)} = {isBotTrade}")); + + return result; + } + + // If we don't have SteamTradeMatcher enabled, this is the end for us + if (!Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.SteamTradeMatcher)) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Rejected, $"{nameof(BotConfig.ETradingPreferences.SteamTradeMatcher)} = {false}")); + + return ParseTradeResult.EResult.Rejected; + } + + // Decline trade if we're giving more count-wise, this is a very naive pre-check, it'll be strengthened in more detailed fair types exchange next + if (tradeOffer.ItemsToGive.Count > tradeOffer.ItemsToReceive.Count) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Rejected, $"{nameof(tradeOffer.ItemsToGive.Count)}: {tradeOffer.ItemsToGive.Count} > {tradeOffer.ItemsToReceive.Count}")); + + return ParseTradeResult.EResult.Rejected; + } + + // Decline trade if we're requested to handle any not-accepted item type or if it's not fair games/types exchange + if (!tradeOffer.IsValidSteamItemsRequest(Bot.BotConfig.MatchableTypes) || !IsFairExchange(tradeOffer.ItemsToGive, tradeOffer.ItemsToReceive)) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Rejected, $"{nameof(tradeOffer.IsValidSteamItemsRequest)} || {nameof(IsFairExchange)}")); + + return ParseTradeResult.EResult.Rejected; + } + + // At this point we're sure that STM trade is valid + + // Fetch trade hold duration + byte? holdDuration = await Bot.GetTradeHoldDuration(tradeOffer.OtherSteamID64, tradeOffer.TradeOfferID).ConfigureAwait(false); + + switch (holdDuration) { + case null: + // If we can't get trade hold duration, try again later + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.TryAgain, nameof(holdDuration))); + + return ParseTradeResult.EResult.TryAgain; + + // If user has a trade hold, we add extra logic + // If trade hold duration exceeds our max, or user asks for cards with short lifespan, reject the trade + case > 0 when (holdDuration.Value > ASF.GlobalConfig.MaxTradeHoldDuration) || tradeOffer.ItemsToGive.Any(static item => item.Type is Asset.EType.FoilTradingCard or Asset.EType.TradingCard && CardsFarmer.SalesBlacklist.Contains(item.RealAppID)): + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Rejected, $"{nameof(holdDuration)} > 0: {holdDuration.Value}")); + + return ParseTradeResult.EResult.Rejected; + } + + // If we're matching everything, this is enough for us + if (Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.MatchEverything)) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.Accepted, BotConfig.ETradingPreferences.MatchEverything)); + + return ParseTradeResult.EResult.Accepted; + } + + // Get sets we're interested in + HashSet<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity)> wantedSets = new(); + + foreach (Asset item in tradeOffer.ItemsToGive) { + wantedSets.Add((item.RealAppID, item.Type, item.Rarity)); + } + + // Now check if it's worth for us to do the trade + HashSet inventory; + + try { + inventory = await Bot.ArchiWebHandler.GetInventoryAsync().Where(item => wantedSets.Contains((item.RealAppID, item.Type, item.Rarity))).ToHashSetAsync().ConfigureAwait(false); + } catch (HttpRequestException e) { + // If we can't check our inventory when not using MatchEverything, this is a temporary failure, try again later + Bot.ArchiLogger.LogGenericWarningException(e); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.TryAgain, nameof(inventory))); + + return ParseTradeResult.EResult.TryAgain; + } catch (Exception e) { + // If we can't check our inventory when not using MatchEverything, this is a temporary failure, try again later + Bot.ArchiLogger.LogGenericException(e); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.TryAgain, nameof(inventory))); + + return ParseTradeResult.EResult.TryAgain; + } + + if (inventory.Count == 0) { + // If we can't check our inventory when not using MatchEverything, this is a temporary failure, try again later + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(inventory))); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, ParseTradeResult.EResult.TryAgain, nameof(inventory))); + + return ParseTradeResult.EResult.TryAgain; + } + + bool accept = IsTradeNeutralOrBetter(inventory, tradeOffer.ItemsToGive.Select(static item => item.CreateShallowCopy()).ToHashSet(), tradeOffer.ItemsToReceive.Select(static item => item.CreateShallowCopy()).ToHashSet()); + + // We're now sure whether the trade is neutral+ for us or not + ParseTradeResult.EResult acceptResult = accept ? ParseTradeResult.EResult.Accepted : ParseTradeResult.EResult.Rejected; + + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.BotTradeOfferResult, tradeOffer.TradeOfferID, acceptResult, nameof(IsTradeNeutralOrBetter))); + + return acceptResult; } } diff --git a/ArchiSteamFarm/Steam/Integration/ArchiHandler.cs b/ArchiSteamFarm/Steam/Integration/ArchiHandler.cs index 27324f742..78d320fdd 100644 --- a/ArchiSteamFarm/Steam/Integration/ArchiHandler.cs +++ b/ArchiSteamFarm/Steam/Integration/ArchiHandler.cs @@ -32,835 +32,835 @@ using JetBrains.Annotations; using SteamKit2; using SteamKit2.Internal; -namespace ArchiSteamFarm.Steam.Integration { - public sealed class ArchiHandler : ClientMsgHandler { - internal const byte MaxGamesPlayedConcurrently = 32; // This is limit introduced by Steam Network +namespace ArchiSteamFarm.Steam.Integration; - private readonly ArchiLogger ArchiLogger; - private readonly SteamUnifiedMessages.UnifiedService UnifiedChatRoomService; - private readonly SteamUnifiedMessages.UnifiedService UnifiedClanChatRoomsService; - private readonly SteamUnifiedMessages.UnifiedService UnifiedEconService; - private readonly SteamUnifiedMessages.UnifiedService UnifiedFriendMessagesService; - private readonly SteamUnifiedMessages.UnifiedService UnifiedPlayerService; - private readonly SteamUnifiedMessages.UnifiedService UnifiedTwoFactorService; +public sealed class ArchiHandler : ClientMsgHandler { + internal const byte MaxGamesPlayedConcurrently = 32; // This is limit introduced by Steam Network - internal DateTime LastPacketReceived { get; private set; } + private readonly ArchiLogger ArchiLogger; + private readonly SteamUnifiedMessages.UnifiedService UnifiedChatRoomService; + private readonly SteamUnifiedMessages.UnifiedService UnifiedClanChatRoomsService; + private readonly SteamUnifiedMessages.UnifiedService UnifiedEconService; + private readonly SteamUnifiedMessages.UnifiedService UnifiedFriendMessagesService; + private readonly SteamUnifiedMessages.UnifiedService UnifiedPlayerService; + private readonly SteamUnifiedMessages.UnifiedService UnifiedTwoFactorService; - internal ArchiHandler(ArchiLogger archiLogger, SteamUnifiedMessages steamUnifiedMessages) { - if (steamUnifiedMessages == null) { - throw new ArgumentNullException(nameof(steamUnifiedMessages)); - } + internal DateTime LastPacketReceived { get; private set; } - ArchiLogger = archiLogger ?? throw new ArgumentNullException(nameof(archiLogger)); - UnifiedChatRoomService = steamUnifiedMessages.CreateService(); - UnifiedClanChatRoomsService = steamUnifiedMessages.CreateService(); - UnifiedEconService = steamUnifiedMessages.CreateService(); - UnifiedFriendMessagesService = steamUnifiedMessages.CreateService(); - UnifiedPlayerService = steamUnifiedMessages.CreateService(); - UnifiedTwoFactorService = steamUnifiedMessages.CreateService(); + internal ArchiHandler(ArchiLogger archiLogger, SteamUnifiedMessages steamUnifiedMessages) { + if (steamUnifiedMessages == null) { + throw new ArgumentNullException(nameof(steamUnifiedMessages)); } - [PublicAPI] - public async Task AddFriend(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + ArchiLogger = archiLogger ?? throw new ArgumentNullException(nameof(archiLogger)); + UnifiedChatRoomService = steamUnifiedMessages.CreateService(); + UnifiedClanChatRoomsService = steamUnifiedMessages.CreateService(); + UnifiedEconService = steamUnifiedMessages.CreateService(); + UnifiedFriendMessagesService = steamUnifiedMessages.CreateService(); + UnifiedPlayerService = steamUnifiedMessages.CreateService(); + UnifiedTwoFactorService = steamUnifiedMessages.CreateService(); + } - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return false; - } - - CPlayer_AddFriend_Request request = new() { steamid = steamID }; - - SteamUnifiedMessages.ServiceMethodResponse response; - - try { - response = await UnifiedPlayerService.SendMessage(x => x.AddFriend(request)).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - - return false; - } - - return response.Result == EResult.OK; + [PublicAPI] + public async Task AddFriend(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - [PublicAPI] - public async Task?> GetOwnedGames(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return null; - } - - CPlayer_GetOwnedGames_Request request = new() { - steamid = steamID, - include_appinfo = true, - include_free_sub = true, - include_played_free_games = true, - skip_unvetted_apps = false - }; - - SteamUnifiedMessages.ServiceMethodResponse response; - - try { - response = await UnifiedPlayerService.SendMessage(x => x.GetOwnedGames(request)).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - - return null; - } - - if (response.Result != EResult.OK) { - return null; - } - - CPlayer_GetOwnedGames_Response body = response.GetDeserializedResponse(); - - return body.games.ToDictionary(static game => (uint) game.appid, static game => game.name); + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); } - public override void HandleMsg(IPacketMsg packetMsg) { - if (packetMsg == null) { - throw new ArgumentNullException(nameof(packetMsg)); - } - - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - LastPacketReceived = DateTime.UtcNow; - - switch (packetMsg.MsgType) { - case EMsg.ClientCommentNotifications: - ClientMsgProtobuf commentNotifications = new(packetMsg); - Client.PostCallback(new UserNotificationsCallback(packetMsg.TargetJobID, commentNotifications.Body)); - - break; - case EMsg.ClientItemAnnouncements: - ClientMsgProtobuf itemAnnouncements = new(packetMsg); - Client.PostCallback(new UserNotificationsCallback(packetMsg.TargetJobID, itemAnnouncements.Body)); - - break; - case EMsg.ClientPlayingSessionState: - ClientMsgProtobuf playingSessionState = new(packetMsg); - Client.PostCallback(new PlayingSessionStateCallback(packetMsg.TargetJobID, playingSessionState.Body)); - - break; - case EMsg.ClientPurchaseResponse: - ClientMsgProtobuf purchaseResponse = new(packetMsg); - Client.PostCallback(new PurchaseResponseCallback(packetMsg.TargetJobID, purchaseResponse.Body)); - - break; - case EMsg.ClientRedeemGuestPassResponse: - ClientMsgProtobuf redeemGuestPassResponse = new(packetMsg); - Client.PostCallback(new RedeemGuestPassResponseCallback(packetMsg.TargetJobID, redeemGuestPassResponse.Body)); - - break; - case EMsg.ClientSharedLibraryLockStatus: - ClientMsgProtobuf sharedLibraryLockStatus = new(packetMsg); - Client.PostCallback(new SharedLibraryLockStatusCallback(packetMsg.TargetJobID, sharedLibraryLockStatus.Body)); - - break; - case EMsg.ClientUserNotifications: - ClientMsgProtobuf userNotifications = new(packetMsg); - Client.PostCallback(new UserNotificationsCallback(packetMsg.TargetJobID, userNotifications.Body)); - - break; - case EMsg.ClientVanityURLChangedNotification: - ClientMsgProtobuf vanityURLChangedNotification = new(packetMsg); - Client.PostCallback(new VanityURLChangedCallback(packetMsg.TargetJobID, vanityURLChangedNotification.Body)); - - break; - } + if (!Client.IsConnected) { + return false; } - [PublicAPI] - public async Task RemoveFriend(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + CPlayer_AddFriend_Request request = new() { steamid = steamID }; - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } + SteamUnifiedMessages.ServiceMethodResponse response; - if (!Client.IsConnected) { - return false; - } + try { + response = await UnifiedPlayerService.SendMessage(x => x.AddFriend(request)).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); - CPlayer_RemoveFriend_Request request = new() { steamid = steamID }; - - SteamUnifiedMessages.ServiceMethodResponse response; - - try { - response = await UnifiedPlayerService.SendMessage(x => x.RemoveFriend(request)).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - - return false; - } - - return response.Result == EResult.OK; + return false; } - internal void AckChatMessage(ulong chatGroupID, ulong chatID, uint timestamp) { - if (chatGroupID == 0) { - throw new ArgumentOutOfRangeException(nameof(chatGroupID)); - } + return response.Result == EResult.OK; + } - if (chatID == 0) { - throw new ArgumentOutOfRangeException(nameof(chatID)); - } - - if (timestamp == 0) { - throw new ArgumentOutOfRangeException(nameof(timestamp)); - } - - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return; - } - - CChatRoom_AckChatMessage_Notification request = new() { - chat_group_id = chatGroupID, - chat_id = chatID, - timestamp = timestamp - }; - - UnifiedChatRoomService.SendMessage(x => x.AckChatMessage(request), true); + [PublicAPI] + public async Task?> GetOwnedGames(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - internal void AckMessage(ulong steamID, uint timestamp) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (timestamp == 0) { - throw new ArgumentOutOfRangeException(nameof(timestamp)); - } - - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return; - } - - CFriendMessages_AckMessage_Notification request = new() { - steamid_partner = steamID, - timestamp = timestamp - }; - - UnifiedFriendMessagesService.SendMessage(x => x.AckMessage(request), true); + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); } - internal void AcknowledgeClanInvite(ulong steamID, bool acceptInvite) { - if ((steamID == 0) || !new SteamID(steamID).IsClanAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return; - } - - ClientMsg request = new() { - Body = { - ClanID = steamID, - AcceptInvite = acceptInvite - } - }; - - Client.Send(request); + if (!Client.IsConnected) { + return null; } - internal async Task GetClanChatGroupID(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsClanAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + CPlayer_GetOwnedGames_Request request = new() { + steamid = steamID, + include_appinfo = true, + include_free_sub = true, + include_played_free_games = true, + skip_unvetted_apps = false + }; - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } + SteamUnifiedMessages.ServiceMethodResponse response; - if (!Client.IsConnected) { - return 0; - } + try { + response = await UnifiedPlayerService.SendMessage(x => x.GetOwnedGames(request)).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); - CClanChatRooms_GetClanChatRoomInfo_Request request = new() { - autocreate = true, - steamid = steamID - }; - - SteamUnifiedMessages.ServiceMethodResponse response; - - try { - response = await UnifiedClanChatRoomsService.SendMessage(x => x.GetClanChatRoomInfo(request)).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - - return 0; - } - - if (response.Result != EResult.OK) { - return 0; - } - - CClanChatRooms_GetClanChatRoomInfo_Response body = response.GetDeserializedResponse(); - - return body.chat_group_summary.chat_group_id; + return null; } - internal async Task GetLevel() { - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return null; - } - - CPlayer_GetGameBadgeLevels_Request request = new(); - SteamUnifiedMessages.ServiceMethodResponse response; - - try { - response = await UnifiedPlayerService.SendMessage(x => x.GetGameBadgeLevels(request)).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - - return null; - } - - if (response.Result != EResult.OK) { - return null; - } - - CPlayer_GetGameBadgeLevels_Response body = response.GetDeserializedResponse(); - - return body.player_level; + if (response.Result != EResult.OK) { + return null; } - internal async Task?> GetMyChatGroupIDs() { - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } + CPlayer_GetOwnedGames_Response body = response.GetDeserializedResponse(); - if (!Client.IsConnected) { - return null; - } + return body.games.ToDictionary(static game => (uint) game.appid, static game => game.name); + } - CChatRoom_GetMyChatRoomGroups_Request request = new(); - - SteamUnifiedMessages.ServiceMethodResponse response; - - try { - response = await UnifiedChatRoomService.SendMessage(x => x.GetMyChatRoomGroups(request)).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - - return null; - } - - if (response.Result != EResult.OK) { - return null; - } - - CChatRoom_GetMyChatRoomGroups_Response body = response.GetDeserializedResponse(); - - return body.chat_room_groups.Select(static chatRoom => chatRoom.group_summary.chat_group_id).ToHashSet(); + public override void HandleMsg(IPacketMsg packetMsg) { + if (packetMsg == null) { + throw new ArgumentNullException(nameof(packetMsg)); } - internal async Task GetPrivacySettings() { - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return null; - } - - CPlayer_GetPrivacySettings_Request request = new(); - - SteamUnifiedMessages.ServiceMethodResponse response; - - try { - response = await UnifiedPlayerService.SendMessage(x => x.GetPrivacySettings(request)).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - - return null; - } - - if (response.Result != EResult.OK) { - return null; - } - - CPlayer_GetPrivacySettings_Response body = response.GetDeserializedResponse(); - - return body.privacy_settings; + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); } - internal async Task GetTradeToken() { - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return null; - } - - CEcon_GetTradeOfferAccessToken_Request request = new(); - - SteamUnifiedMessages.ServiceMethodResponse response; - - try { - response = await UnifiedEconService.SendMessage(x => x.GetTradeOfferAccessToken(request)).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - - return null; - } - - if (response.Result != EResult.OK) { - return null; - } - - CEcon_GetTradeOfferAccessToken_Response body = response.GetDeserializedResponse(); - - return body.trade_offer_access_token; - } - - internal async Task GetTwoFactorDeviceIdentifier(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return null; - } - - CTwoFactor_Status_Request request = new() { - steamid = steamID - }; - - SteamUnifiedMessages.ServiceMethodResponse response; - - try { - response = await UnifiedTwoFactorService.SendMessage(x => x.QueryStatus(request)).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - - return null; - } - - if (response.Result != EResult.OK) { - return null; - } - - CTwoFactor_Status_Response body = response.GetDeserializedResponse(); - - return body.device_identifier; - } - - internal async Task JoinChatRoomGroup(ulong chatGroupID) { - if (chatGroupID == 0) { - throw new ArgumentOutOfRangeException(nameof(chatGroupID)); - } - - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return false; - } - - CChatRoom_JoinChatRoomGroup_Request request = new() { chat_group_id = chatGroupID }; - - SteamUnifiedMessages.ServiceMethodResponse response; - - try { - response = await UnifiedChatRoomService.SendMessage(x => x.JoinChatRoomGroup(request)).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - - return false; - } - - return response.Result == EResult.OK; - } - - internal async Task PlayGames(IReadOnlyCollection gameIDs, string? gameName = null) { - if (gameIDs == null) { - throw new ArgumentNullException(nameof(gameIDs)); - } - - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return; - } - - ClientMsgProtobuf request = new(EMsg.ClientGamesPlayedWithDataBlob) { - Body = { - client_os_type = (uint) Bot.OSType - } - }; - - byte maxGamesCount = MaxGamesPlayedConcurrently; - - if (!string.IsNullOrEmpty(gameName)) { - // If we have custom name to display, we must workaround the Steam network broken behaviour and send request on clean non-playing session - // This ensures that custom name will in fact display properly - Client.Send(request); - await Task.Delay(Bot.CallbackSleep).ConfigureAwait(false); - - request.Body.games_played.Add( - new CMsgClientGamesPlayed.GamePlayed { - game_extra_info = gameName, - game_id = new GameID { - AppType = GameID.GameType.Shortcut, - ModID = uint.MaxValue - } - } - ); - - // Max games count is affected by valid AppIDs only, therefore gameName alone doesn't need exclusive slot - maxGamesCount++; - } - - if (gameIDs.Count > 0) { -#pragma warning disable CA1508 // False positive, not every IReadOnlyCollection is ISet - IEnumerable uniqueValidGameIDs = (gameIDs as ISet ?? gameIDs.Distinct()).Where(static gameID => gameID > 0); -#pragma warning restore CA1508 // False positive, not every IReadOnlyCollection is ISet - - foreach (uint gameID in uniqueValidGameIDs) { - if (request.Body.games_played.Count >= maxGamesCount) { - throw new ArgumentOutOfRangeException(nameof(gameIDs)); - } - - request.Body.games_played.Add(new CMsgClientGamesPlayed.GamePlayed { game_id = new GameID(gameID) }); - } - } - - Client.Send(request); - } - - internal async Task RedeemGuestPass(ulong guestPassID) { - if (guestPassID == 0) { - throw new ArgumentOutOfRangeException(nameof(guestPassID)); - } - - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return null; - } - - ClientMsgProtobuf request = new(EMsg.ClientRedeemGuestPass) { - SourceJobID = Client.GetNextJobID(), - Body = { guest_pass_id = guestPassID } - }; - - Client.Send(request); - - try { - return await new AsyncJob(Client, request.SourceJobID).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericException(e); - - return null; - } - } - - internal async Task RedeemKey(string key) { - if (string.IsNullOrEmpty(key)) { - throw new ArgumentNullException(nameof(key)); - } - - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return null; - } - - ClientMsgProtobuf request = new(EMsg.ClientRegisterKey) { - SourceJobID = Client.GetNextJobID(), - Body = { key = key } - }; - - Client.Send(request); - - try { - return await new AsyncJob(Client, request.SourceJobID).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericException(e); - - return null; - } - } - - internal void RequestItemAnnouncements() { - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return; - } - - ClientMsgProtobuf request = new(EMsg.ClientRequestItemAnnouncements); - Client.Send(request); - } - - internal async Task SendMessage(ulong steamID, string message) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } - - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return EResult.NoConnection; - } - - CFriendMessages_SendMessage_Request request = new() { - chat_entry_type = (int) EChatEntryType.ChatMsg, - contains_bbcode = true, - message = message, - steamid = steamID - }; - - SteamUnifiedMessages.ServiceMethodResponse response; - - try { - response = await UnifiedFriendMessagesService.SendMessage(x => x.SendMessage(request)).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - - return EResult.Timeout; - } - - return response.Result; - } - - internal async Task SendMessage(ulong chatGroupID, ulong chatID, string message) { - if (chatGroupID == 0) { - throw new ArgumentOutOfRangeException(nameof(chatGroupID)); - } - - if (chatID == 0) { - throw new ArgumentOutOfRangeException(nameof(chatID)); - } - - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } - - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return EResult.NoConnection; - } - - CChatRoom_SendChatMessage_Request request = new() { - chat_group_id = chatGroupID, - chat_id = chatID, - message = message - }; - - SteamUnifiedMessages.ServiceMethodResponse response; - - try { - response = await UnifiedChatRoomService.SendMessage(x => x.SendChatMessage(request)).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - - return EResult.Timeout; - } - - return response.Result; - } - - internal async Task SendTypingStatus(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return EResult.NoConnection; - } - - CFriendMessages_SendMessage_Request request = new() { - chat_entry_type = (int) EChatEntryType.Typing, - steamid = steamID - }; - - SteamUnifiedMessages.ServiceMethodResponse response; - - try { - response = await UnifiedFriendMessagesService.SendMessage(x => x.SendMessage(request)).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - - return EResult.Timeout; - } - - return response.Result; - } - - internal void SetCurrentMode(EUserInterfaceMode userInterfaceMode, byte chatMode = 2) { - if (!Enum.IsDefined(typeof(EUserInterfaceMode), userInterfaceMode)) { - throw new InvalidEnumArgumentException(nameof(userInterfaceMode), (int) userInterfaceMode, typeof(EUserInterfaceMode)); - } - - if (chatMode == 0) { - throw new ArgumentOutOfRangeException(nameof(chatMode)); - } - - if (Client == null) { - throw new InvalidOperationException(nameof(Client)); - } - - if (!Client.IsConnected) { - return; - } - - ClientMsgProtobuf request = new(EMsg.ClientCurrentUIMode) { - Body = { - uimode = (uint) userInterfaceMode, - chat_mode = chatMode - } - }; - - Client.Send(request); - } - - [PublicAPI] - public enum EUserInterfaceMode : byte { - Default = 0, - BigPicture = 1, - Mobile = 2 - } - - internal sealed class PlayingSessionStateCallback : CallbackMsg { - internal readonly bool PlayingBlocked; - - internal PlayingSessionStateCallback(JobID jobID, CMsgClientPlayingSessionState msg) { - if (jobID == null) { - throw new ArgumentNullException(nameof(jobID)); - } - - if (msg == null) { - throw new ArgumentNullException(nameof(msg)); - } - - JobID = jobID; - PlayingBlocked = msg.playing_blocked; - } - } - - internal sealed class RedeemGuestPassResponseCallback : CallbackMsg { - internal readonly EResult Result; - - internal RedeemGuestPassResponseCallback(JobID jobID, CMsgClientRedeemGuestPassResponse msg) { - if (jobID == null) { - throw new ArgumentNullException(nameof(jobID)); - } - - if (msg == null) { - throw new ArgumentNullException(nameof(msg)); - } - - JobID = jobID; - Result = (EResult) msg.eresult; - } - } - - internal sealed class SharedLibraryLockStatusCallback : CallbackMsg { - internal readonly ulong LibraryLockedBySteamID; - - internal SharedLibraryLockStatusCallback(JobID jobID, CMsgClientSharedLibraryLockStatus msg) { - if (jobID == null) { - throw new ArgumentNullException(nameof(jobID)); - } - - if (msg == null) { - throw new ArgumentNullException(nameof(msg)); - } - - JobID = jobID; - - if (msg.own_library_locked_by == 0) { - return; - } - - LibraryLockedBySteamID = new SteamID(msg.own_library_locked_by, EUniverse.Public, EAccountType.Individual); - } - } - - internal sealed class VanityURLChangedCallback : CallbackMsg { - internal readonly string VanityURL; - - internal VanityURLChangedCallback(JobID jobID, CMsgClientVanityURLChangedNotification msg) { - if (jobID == null) { - throw new ArgumentNullException(nameof(jobID)); - } - - if (msg == null) { - throw new ArgumentNullException(nameof(msg)); - } - - JobID = jobID; - VanityURL = msg.vanity_url; - } - } - - internal enum EPrivacySetting : byte { - Unknown, - Private, - FriendsOnly, - Public + LastPacketReceived = DateTime.UtcNow; + + switch (packetMsg.MsgType) { + case EMsg.ClientCommentNotifications: + ClientMsgProtobuf commentNotifications = new(packetMsg); + Client.PostCallback(new UserNotificationsCallback(packetMsg.TargetJobID, commentNotifications.Body)); + + break; + case EMsg.ClientItemAnnouncements: + ClientMsgProtobuf itemAnnouncements = new(packetMsg); + Client.PostCallback(new UserNotificationsCallback(packetMsg.TargetJobID, itemAnnouncements.Body)); + + break; + case EMsg.ClientPlayingSessionState: + ClientMsgProtobuf playingSessionState = new(packetMsg); + Client.PostCallback(new PlayingSessionStateCallback(packetMsg.TargetJobID, playingSessionState.Body)); + + break; + case EMsg.ClientPurchaseResponse: + ClientMsgProtobuf purchaseResponse = new(packetMsg); + Client.PostCallback(new PurchaseResponseCallback(packetMsg.TargetJobID, purchaseResponse.Body)); + + break; + case EMsg.ClientRedeemGuestPassResponse: + ClientMsgProtobuf redeemGuestPassResponse = new(packetMsg); + Client.PostCallback(new RedeemGuestPassResponseCallback(packetMsg.TargetJobID, redeemGuestPassResponse.Body)); + + break; + case EMsg.ClientSharedLibraryLockStatus: + ClientMsgProtobuf sharedLibraryLockStatus = new(packetMsg); + Client.PostCallback(new SharedLibraryLockStatusCallback(packetMsg.TargetJobID, sharedLibraryLockStatus.Body)); + + break; + case EMsg.ClientUserNotifications: + ClientMsgProtobuf userNotifications = new(packetMsg); + Client.PostCallback(new UserNotificationsCallback(packetMsg.TargetJobID, userNotifications.Body)); + + break; + case EMsg.ClientVanityURLChangedNotification: + ClientMsgProtobuf vanityURLChangedNotification = new(packetMsg); + Client.PostCallback(new VanityURLChangedCallback(packetMsg.TargetJobID, vanityURLChangedNotification.Body)); + + break; } } + + [PublicAPI] + public async Task RemoveFriend(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return false; + } + + CPlayer_RemoveFriend_Request request = new() { steamid = steamID }; + + SteamUnifiedMessages.ServiceMethodResponse response; + + try { + response = await UnifiedPlayerService.SendMessage(x => x.RemoveFriend(request)).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + + return false; + } + + return response.Result == EResult.OK; + } + + internal void AckChatMessage(ulong chatGroupID, ulong chatID, uint timestamp) { + if (chatGroupID == 0) { + throw new ArgumentOutOfRangeException(nameof(chatGroupID)); + } + + if (chatID == 0) { + throw new ArgumentOutOfRangeException(nameof(chatID)); + } + + if (timestamp == 0) { + throw new ArgumentOutOfRangeException(nameof(timestamp)); + } + + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return; + } + + CChatRoom_AckChatMessage_Notification request = new() { + chat_group_id = chatGroupID, + chat_id = chatID, + timestamp = timestamp + }; + + UnifiedChatRoomService.SendMessage(x => x.AckChatMessage(request), true); + } + + internal void AckMessage(ulong steamID, uint timestamp) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (timestamp == 0) { + throw new ArgumentOutOfRangeException(nameof(timestamp)); + } + + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return; + } + + CFriendMessages_AckMessage_Notification request = new() { + steamid_partner = steamID, + timestamp = timestamp + }; + + UnifiedFriendMessagesService.SendMessage(x => x.AckMessage(request), true); + } + + internal void AcknowledgeClanInvite(ulong steamID, bool acceptInvite) { + if ((steamID == 0) || !new SteamID(steamID).IsClanAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return; + } + + ClientMsg request = new() { + Body = { + ClanID = steamID, + AcceptInvite = acceptInvite + } + }; + + Client.Send(request); + } + + internal async Task GetClanChatGroupID(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsClanAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return 0; + } + + CClanChatRooms_GetClanChatRoomInfo_Request request = new() { + autocreate = true, + steamid = steamID + }; + + SteamUnifiedMessages.ServiceMethodResponse response; + + try { + response = await UnifiedClanChatRoomsService.SendMessage(x => x.GetClanChatRoomInfo(request)).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + + return 0; + } + + if (response.Result != EResult.OK) { + return 0; + } + + CClanChatRooms_GetClanChatRoomInfo_Response body = response.GetDeserializedResponse(); + + return body.chat_group_summary.chat_group_id; + } + + internal async Task GetLevel() { + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return null; + } + + CPlayer_GetGameBadgeLevels_Request request = new(); + SteamUnifiedMessages.ServiceMethodResponse response; + + try { + response = await UnifiedPlayerService.SendMessage(x => x.GetGameBadgeLevels(request)).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + + return null; + } + + if (response.Result != EResult.OK) { + return null; + } + + CPlayer_GetGameBadgeLevels_Response body = response.GetDeserializedResponse(); + + return body.player_level; + } + + internal async Task?> GetMyChatGroupIDs() { + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return null; + } + + CChatRoom_GetMyChatRoomGroups_Request request = new(); + + SteamUnifiedMessages.ServiceMethodResponse response; + + try { + response = await UnifiedChatRoomService.SendMessage(x => x.GetMyChatRoomGroups(request)).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + + return null; + } + + if (response.Result != EResult.OK) { + return null; + } + + CChatRoom_GetMyChatRoomGroups_Response body = response.GetDeserializedResponse(); + + return body.chat_room_groups.Select(static chatRoom => chatRoom.group_summary.chat_group_id).ToHashSet(); + } + + internal async Task GetPrivacySettings() { + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return null; + } + + CPlayer_GetPrivacySettings_Request request = new(); + + SteamUnifiedMessages.ServiceMethodResponse response; + + try { + response = await UnifiedPlayerService.SendMessage(x => x.GetPrivacySettings(request)).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + + return null; + } + + if (response.Result != EResult.OK) { + return null; + } + + CPlayer_GetPrivacySettings_Response body = response.GetDeserializedResponse(); + + return body.privacy_settings; + } + + internal async Task GetTradeToken() { + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return null; + } + + CEcon_GetTradeOfferAccessToken_Request request = new(); + + SteamUnifiedMessages.ServiceMethodResponse response; + + try { + response = await UnifiedEconService.SendMessage(x => x.GetTradeOfferAccessToken(request)).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + + return null; + } + + if (response.Result != EResult.OK) { + return null; + } + + CEcon_GetTradeOfferAccessToken_Response body = response.GetDeserializedResponse(); + + return body.trade_offer_access_token; + } + + internal async Task GetTwoFactorDeviceIdentifier(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return null; + } + + CTwoFactor_Status_Request request = new() { + steamid = steamID + }; + + SteamUnifiedMessages.ServiceMethodResponse response; + + try { + response = await UnifiedTwoFactorService.SendMessage(x => x.QueryStatus(request)).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + + return null; + } + + if (response.Result != EResult.OK) { + return null; + } + + CTwoFactor_Status_Response body = response.GetDeserializedResponse(); + + return body.device_identifier; + } + + internal async Task JoinChatRoomGroup(ulong chatGroupID) { + if (chatGroupID == 0) { + throw new ArgumentOutOfRangeException(nameof(chatGroupID)); + } + + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return false; + } + + CChatRoom_JoinChatRoomGroup_Request request = new() { chat_group_id = chatGroupID }; + + SteamUnifiedMessages.ServiceMethodResponse response; + + try { + response = await UnifiedChatRoomService.SendMessage(x => x.JoinChatRoomGroup(request)).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + + return false; + } + + return response.Result == EResult.OK; + } + + internal async Task PlayGames(IReadOnlyCollection gameIDs, string? gameName = null) { + if (gameIDs == null) { + throw new ArgumentNullException(nameof(gameIDs)); + } + + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return; + } + + ClientMsgProtobuf request = new(EMsg.ClientGamesPlayedWithDataBlob) { + Body = { + client_os_type = (uint) Bot.OSType + } + }; + + byte maxGamesCount = MaxGamesPlayedConcurrently; + + if (!string.IsNullOrEmpty(gameName)) { + // If we have custom name to display, we must workaround the Steam network broken behaviour and send request on clean non-playing session + // This ensures that custom name will in fact display properly + Client.Send(request); + await Task.Delay(Bot.CallbackSleep).ConfigureAwait(false); + + request.Body.games_played.Add( + new CMsgClientGamesPlayed.GamePlayed { + game_extra_info = gameName, + game_id = new GameID { + AppType = GameID.GameType.Shortcut, + ModID = uint.MaxValue + } + } + ); + + // Max games count is affected by valid AppIDs only, therefore gameName alone doesn't need exclusive slot + maxGamesCount++; + } + + if (gameIDs.Count > 0) { +#pragma warning disable CA1508 // False positive, not every IReadOnlyCollection is ISet + IEnumerable uniqueValidGameIDs = (gameIDs as ISet ?? gameIDs.Distinct()).Where(static gameID => gameID > 0); +#pragma warning restore CA1508 // False positive, not every IReadOnlyCollection is ISet + + foreach (uint gameID in uniqueValidGameIDs) { + if (request.Body.games_played.Count >= maxGamesCount) { + throw new ArgumentOutOfRangeException(nameof(gameIDs)); + } + + request.Body.games_played.Add(new CMsgClientGamesPlayed.GamePlayed { game_id = new GameID(gameID) }); + } + } + + Client.Send(request); + } + + internal async Task RedeemGuestPass(ulong guestPassID) { + if (guestPassID == 0) { + throw new ArgumentOutOfRangeException(nameof(guestPassID)); + } + + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return null; + } + + ClientMsgProtobuf request = new(EMsg.ClientRedeemGuestPass) { + SourceJobID = Client.GetNextJobID(), + Body = { guest_pass_id = guestPassID } + }; + + Client.Send(request); + + try { + return await new AsyncJob(Client, request.SourceJobID).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericException(e); + + return null; + } + } + + internal async Task RedeemKey(string key) { + if (string.IsNullOrEmpty(key)) { + throw new ArgumentNullException(nameof(key)); + } + + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return null; + } + + ClientMsgProtobuf request = new(EMsg.ClientRegisterKey) { + SourceJobID = Client.GetNextJobID(), + Body = { key = key } + }; + + Client.Send(request); + + try { + return await new AsyncJob(Client, request.SourceJobID).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericException(e); + + return null; + } + } + + internal void RequestItemAnnouncements() { + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return; + } + + ClientMsgProtobuf request = new(EMsg.ClientRequestItemAnnouncements); + Client.Send(request); + } + + internal async Task SendMessage(ulong steamID, string message) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return EResult.NoConnection; + } + + CFriendMessages_SendMessage_Request request = new() { + chat_entry_type = (int) EChatEntryType.ChatMsg, + contains_bbcode = true, + message = message, + steamid = steamID + }; + + SteamUnifiedMessages.ServiceMethodResponse response; + + try { + response = await UnifiedFriendMessagesService.SendMessage(x => x.SendMessage(request)).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + + return EResult.Timeout; + } + + return response.Result; + } + + internal async Task SendMessage(ulong chatGroupID, ulong chatID, string message) { + if (chatGroupID == 0) { + throw new ArgumentOutOfRangeException(nameof(chatGroupID)); + } + + if (chatID == 0) { + throw new ArgumentOutOfRangeException(nameof(chatID)); + } + + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return EResult.NoConnection; + } + + CChatRoom_SendChatMessage_Request request = new() { + chat_group_id = chatGroupID, + chat_id = chatID, + message = message + }; + + SteamUnifiedMessages.ServiceMethodResponse response; + + try { + response = await UnifiedChatRoomService.SendMessage(x => x.SendChatMessage(request)).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + + return EResult.Timeout; + } + + return response.Result; + } + + internal async Task SendTypingStatus(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return EResult.NoConnection; + } + + CFriendMessages_SendMessage_Request request = new() { + chat_entry_type = (int) EChatEntryType.Typing, + steamid = steamID + }; + + SteamUnifiedMessages.ServiceMethodResponse response; + + try { + response = await UnifiedFriendMessagesService.SendMessage(x => x.SendMessage(request)).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + + return EResult.Timeout; + } + + return response.Result; + } + + internal void SetCurrentMode(EUserInterfaceMode userInterfaceMode, byte chatMode = 2) { + if (!Enum.IsDefined(typeof(EUserInterfaceMode), userInterfaceMode)) { + throw new InvalidEnumArgumentException(nameof(userInterfaceMode), (int) userInterfaceMode, typeof(EUserInterfaceMode)); + } + + if (chatMode == 0) { + throw new ArgumentOutOfRangeException(nameof(chatMode)); + } + + if (Client == null) { + throw new InvalidOperationException(nameof(Client)); + } + + if (!Client.IsConnected) { + return; + } + + ClientMsgProtobuf request = new(EMsg.ClientCurrentUIMode) { + Body = { + uimode = (uint) userInterfaceMode, + chat_mode = chatMode + } + }; + + Client.Send(request); + } + + [PublicAPI] + public enum EUserInterfaceMode : byte { + Default = 0, + BigPicture = 1, + Mobile = 2 + } + + internal sealed class PlayingSessionStateCallback : CallbackMsg { + internal readonly bool PlayingBlocked; + + internal PlayingSessionStateCallback(JobID jobID, CMsgClientPlayingSessionState msg) { + if (jobID == null) { + throw new ArgumentNullException(nameof(jobID)); + } + + if (msg == null) { + throw new ArgumentNullException(nameof(msg)); + } + + JobID = jobID; + PlayingBlocked = msg.playing_blocked; + } + } + + internal sealed class RedeemGuestPassResponseCallback : CallbackMsg { + internal readonly EResult Result; + + internal RedeemGuestPassResponseCallback(JobID jobID, CMsgClientRedeemGuestPassResponse msg) { + if (jobID == null) { + throw new ArgumentNullException(nameof(jobID)); + } + + if (msg == null) { + throw new ArgumentNullException(nameof(msg)); + } + + JobID = jobID; + Result = (EResult) msg.eresult; + } + } + + internal sealed class SharedLibraryLockStatusCallback : CallbackMsg { + internal readonly ulong LibraryLockedBySteamID; + + internal SharedLibraryLockStatusCallback(JobID jobID, CMsgClientSharedLibraryLockStatus msg) { + if (jobID == null) { + throw new ArgumentNullException(nameof(jobID)); + } + + if (msg == null) { + throw new ArgumentNullException(nameof(msg)); + } + + JobID = jobID; + + if (msg.own_library_locked_by == 0) { + return; + } + + LibraryLockedBySteamID = new SteamID(msg.own_library_locked_by, EUniverse.Public, EAccountType.Individual); + } + } + + internal sealed class VanityURLChangedCallback : CallbackMsg { + internal readonly string VanityURL; + + internal VanityURLChangedCallback(JobID jobID, CMsgClientVanityURLChangedNotification msg) { + if (jobID == null) { + throw new ArgumentNullException(nameof(jobID)); + } + + if (msg == null) { + throw new ArgumentNullException(nameof(msg)); + } + + JobID = jobID; + VanityURL = msg.vanity_url; + } + } + + internal enum EPrivacySetting : byte { + Unknown, + Private, + FriendsOnly, + Public + } } diff --git a/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs b/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs index ad95c1caf..e9bc2b5c1 100644 --- a/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs +++ b/ArchiSteamFarm/Steam/Integration/ArchiWebHandler.cs @@ -47,428 +47,96 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using SteamKit2; -namespace ArchiSteamFarm.Steam.Integration { - public sealed class ArchiWebHandler : IDisposable { - internal const ushort MaxItemsInSingleInventoryRequest = 5000; +namespace ArchiSteamFarm.Steam.Integration; - private const string EconService = "IEconService"; - private const string LoyaltyRewardsService = "ILoyaltyRewardsService"; - private const string SteamAppsService = "ISteamApps"; - private const string SteamUserAuthService = "ISteamUserAuth"; - private const string TwoFactorService = "ITwoFactorService"; +public sealed class ArchiWebHandler : IDisposable { + internal const ushort MaxItemsInSingleInventoryRequest = 5000; - [PublicAPI] - public static Uri SteamCommunityURL => new("https://steamcommunity.com"); + private const string EconService = "IEconService"; + private const string LoyaltyRewardsService = "ILoyaltyRewardsService"; + private const string SteamAppsService = "ISteamApps"; + private const string SteamUserAuthService = "ISteamUserAuth"; + private const string TwoFactorService = "ITwoFactorService"; - [PublicAPI] - public static Uri SteamHelpURL => new("https://help.steampowered.com"); + [PublicAPI] + public static Uri SteamCommunityURL => new("https://steamcommunity.com"); - [PublicAPI] - public static Uri SteamStoreURL => new("https://store.steampowered.com"); + [PublicAPI] + public static Uri SteamHelpURL => new("https://help.steampowered.com"); - private static readonly ConcurrentDictionary CachedCardCountsForGame = new(); + [PublicAPI] + public static Uri SteamStoreURL => new("https://store.steampowered.com"); - [PublicAPI] - public ArchiCacheable CachedAccessToken { get; } + private static readonly ConcurrentDictionary CachedCardCountsForGame = new(); - [PublicAPI] - public ArchiCacheable CachedApiKey { get; } + [PublicAPI] + public ArchiCacheable CachedAccessToken { get; } - [PublicAPI] - public WebBrowser WebBrowser { get; } + [PublicAPI] + public ArchiCacheable CachedApiKey { get; } - private readonly Bot Bot; - private readonly SemaphoreSlim SessionSemaphore = new(1, 1); + [PublicAPI] + public WebBrowser WebBrowser { get; } - private bool Initialized; - private DateTime LastSessionCheck; - private DateTime LastSessionRefresh; - private bool MarkingInventoryScheduled; - private string? VanityURL; + private readonly Bot Bot; + private readonly SemaphoreSlim SessionSemaphore = new(1, 1); - internal ArchiWebHandler(Bot bot) { - Bot = bot ?? throw new ArgumentNullException(nameof(bot)); + private bool Initialized; + private DateTime LastSessionCheck; + private DateTime LastSessionRefresh; + private bool MarkingInventoryScheduled; + private string? VanityURL; - CachedApiKey = new ArchiCacheable(ResolveApiKey); - CachedAccessToken = new ArchiCacheable(ResolveAccessToken); + internal ArchiWebHandler(Bot bot) { + Bot = bot ?? throw new ArgumentNullException(nameof(bot)); - WebBrowser = new WebBrowser(bot.ArchiLogger, ASF.GlobalConfig?.WebProxy); - } + CachedApiKey = new ArchiCacheable(ResolveApiKey); + CachedAccessToken = new ArchiCacheable(ResolveAccessToken); - public void Dispose() { - CachedApiKey.Dispose(); - CachedAccessToken.Dispose(); - SessionSemaphore.Dispose(); - WebBrowser.Dispose(); - } + WebBrowser = new WebBrowser(bot.ArchiLogger, ASF.GlobalConfig?.WebProxy); + } - [PublicAPI] - public async Task GetAbsoluteProfileURL(bool waitForInitialization = true) { - if (waitForInitialization && !Initialized) { - byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; + public void Dispose() { + CachedApiKey.Dispose(); + CachedAccessToken.Dispose(); + SessionSemaphore.Dispose(); + WebBrowser.Dispose(); + } - for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { - await Task.Delay(1000).ConfigureAwait(false); - } + [PublicAPI] + public async Task GetAbsoluteProfileURL(bool waitForInitialization = true) { + if (waitForInitialization && !Initialized) { + byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; - if (!Initialized) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return null; - } + for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { + await Task.Delay(1000).ConfigureAwait(false); } - return string.IsNullOrEmpty(VanityURL) ? $"/profiles/{Bot.SteamID}" : $"/id/{VanityURL}"; - } - - [PublicAPI] - public async IAsyncEnumerable GetInventoryAsync(ulong steamID = 0, uint appID = Asset.SteamAppID, ulong contextID = Asset.SteamCommunityContextID) { - if (appID == 0) { - throw new ArgumentOutOfRangeException(nameof(appID)); - } - - if (contextID == 0) { - throw new ArgumentOutOfRangeException(nameof(contextID)); - } - - if (ASF.InventorySemaphore == null) { - throw new InvalidOperationException(nameof(ASF.InventorySemaphore)); - } - - if (steamID == 0) { - if (!Initialized) { - byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; - - for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { - await Task.Delay(1000).ConfigureAwait(false); - } - - if (!Initialized) { - throw new HttpRequestException(Strings.WarningFailed); - } - } - - steamID = Bot.SteamID; - } else if (!new SteamID(steamID).IsIndividualAccount) { - throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(steamID))); - } - - ulong startAssetID = 0; - - // We need to store asset IDs to make sure we won't get duplicate items - HashSet? assetIDs = null; - - while (true) { - await ASF.InventorySemaphore.WaitAsync().ConfigureAwait(false); - - try { - Uri request = new(SteamCommunityURL, $"/inventory/{steamID}/{appID}/{contextID}?count={MaxItemsInSingleInventoryRequest}&l=english{(startAssetID > 0 ? $"&start_assetid={startAssetID}" : "")}"); - - ObjectResponse? response = await UrlGetToJsonObjectWithSession(request).ConfigureAwait(false); - - if (response == null) { - throw new HttpRequestException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(response))); - } - - if (response.Content.Result != EResult.OK) { - throw new HttpRequestException(!string.IsNullOrEmpty(response.Content.Error) ? string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.Content.Error) : Strings.WarningFailed); - } - - if (response.Content.TotalInventoryCount == 0) { - // Empty inventory - yield break; - } - - assetIDs ??= new HashSet((int) response.Content.TotalInventoryCount); - - if ((response.Content.Assets.Count == 0) || (response.Content.Descriptions.Count == 0)) { - throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, $"{nameof(response.Content.Assets)} || {nameof(response.Content.Descriptions)}")); - } - - Dictionary<(ulong ClassID, ulong InstanceID), InventoryResponse.Description> descriptions = new(); - - foreach (InventoryResponse.Description description in response.Content.Descriptions) { - if (description.ClassID == 0) { - throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(description.ClassID))); - } - - (ulong ClassID, ulong InstanceID) key = (description.ClassID, description.InstanceID); - - if (descriptions.ContainsKey(key)) { - continue; - } - - descriptions[key] = description; - } - - foreach (Asset asset in response.Content.Assets) { - if (!descriptions.TryGetValue((asset.ClassID, asset.InstanceID), out InventoryResponse.Description? description) || assetIDs.Contains(asset.AssetID)) { - continue; - } - - asset.Marketable = description.Marketable; - asset.Tradable = description.Tradable; - asset.Tags = description.Tags; - asset.RealAppID = description.RealAppID; - asset.Type = description.Type; - asset.Rarity = description.Rarity; - - if (description.AdditionalProperties != null) { - asset.AdditionalProperties = description.AdditionalProperties; - } - - assetIDs.Add(asset.AssetID); - - yield return asset; - } - - if (!response.Content.MoreItems) { - yield break; - } - - if (response.Content.LastAssetID == 0) { - throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(response.Content.LastAssetID))); - } - - startAssetID = response.Content.LastAssetID; - } finally { - byte inventoryLimiterDelay = ASF.GlobalConfig?.InventoryLimiterDelay ?? GlobalConfig.DefaultInventoryLimiterDelay; - - if (inventoryLimiterDelay == 0) { - ASF.InventorySemaphore.Release(); - } else { - Utilities.InBackground( - async () => { - await Task.Delay(inventoryLimiterDelay * 1000).ConfigureAwait(false); - ASF.InventorySemaphore.Release(); - } - ); - } - } - } - } - - [PublicAPI] - public async Task GetPointsBalance() { - (bool success, string? accessToken) = await CachedAccessToken.GetValue().ConfigureAwait(false); - - if (!success || string.IsNullOrEmpty(accessToken)) { - return null; - } - - // Extra entry for format - Dictionary arguments = new(3, StringComparer.Ordinal) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - { "access_token", accessToken! }, - - { "steamid", Bot.SteamID } - }; - - KeyValue? response = null; - - for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { - using WebAPI.AsyncInterface loyaltyRewardsService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(LoyaltyRewardsService); - - loyaltyRewardsService.Timeout = WebBrowser.Timeout; - - try { - response = await WebLimitRequest( - WebAPI.DefaultBaseAddress, - - // ReSharper disable once AccessToDisposedClosure - async () => await loyaltyRewardsService.CallAsync(HttpMethod.Get, "GetSummary", args: arguments).ConfigureAwait(false) - ).ConfigureAwait(false); - } catch (TaskCanceledException e) { - Bot.ArchiLogger.LogGenericDebuggingException(e); - } catch (Exception e) { - Bot.ArchiLogger.LogGenericWarningException(e); - } - } - - if (response == null) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + if (!Initialized) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); return null; } - - KeyValue pointsInfo = response["summary"]["points"]; - - if (pointsInfo == KeyValue.Invalid) { - Bot.ArchiLogger.LogNullError(nameof(pointsInfo)); - - return null; - } - - uint result = pointsInfo.AsUnsignedInteger(uint.MaxValue); - - if (result == uint.MaxValue) { - Bot.ArchiLogger.LogNullError(nameof(result)); - - return null; - } - - return result; } - [PublicAPI] - public async Task HasValidApiKey() { - (bool success, string? steamApiKey) = await CachedApiKey.GetValue().ConfigureAwait(false); + return string.IsNullOrEmpty(VanityURL) ? $"/profiles/{Bot.SteamID}" : $"/id/{VanityURL}"; + } - return success ? !string.IsNullOrEmpty(steamApiKey) : null; + [PublicAPI] + public async IAsyncEnumerable GetInventoryAsync(ulong steamID = 0, uint appID = Asset.SteamAppID, ulong contextID = Asset.SteamCommunityContextID) { + if (appID == 0) { + throw new ArgumentOutOfRangeException(nameof(appID)); } - [PublicAPI] - public async Task JoinGroup(ulong groupID) { - if ((groupID == 0) || !new SteamID(groupID).IsClanAccount) { - throw new ArgumentOutOfRangeException(nameof(groupID)); - } - - Uri request = new(SteamCommunityURL, $"/gid/{groupID}"); - - // Extra entry for sessionID - Dictionary data = new(2, StringComparer.Ordinal) { { "action", "join" } }; - - return await UrlPostWithSession(request, data: data, session: ESession.CamelCase).ConfigureAwait(false); + if (contextID == 0) { + throw new ArgumentOutOfRangeException(nameof(contextID)); } - [PublicAPI] - public async Task<(bool Success, HashSet? MobileTradeOfferIDs)> SendTradeOffer(ulong steamID, IReadOnlyCollection? itemsToGive = null, IReadOnlyCollection? itemsToReceive = null, string? token = null, bool forcedSingleOffer = false, ushort itemsPerTrade = Trading.MaxItemsPerTrade) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (((itemsToGive == null) || (itemsToGive.Count == 0)) && ((itemsToReceive == null) || (itemsToReceive.Count == 0))) { - throw new ArgumentException($"{nameof(itemsToGive)} && {nameof(itemsToReceive)}"); - } - - if (itemsPerTrade <= 2) { - throw new ArgumentOutOfRangeException(nameof(itemsPerTrade)); - } - - TradeOfferSendRequest singleTrade = new(); - HashSet trades = new() { singleTrade }; - - if (itemsToGive != null) { - foreach (Asset itemToGive in itemsToGive) { - if (!forcedSingleOffer && (singleTrade.ItemsToGive.Assets.Count + singleTrade.ItemsToReceive.Assets.Count >= itemsPerTrade)) { - if (trades.Count >= Trading.MaxTradesPerAccount) { - break; - } - - singleTrade = new TradeOfferSendRequest(); - trades.Add(singleTrade); - } - - singleTrade.ItemsToGive.Assets.Add(itemToGive); - } - } - - if (itemsToReceive != null) { - foreach (Asset itemToReceive in itemsToReceive) { - if (!forcedSingleOffer && (singleTrade.ItemsToGive.Assets.Count + singleTrade.ItemsToReceive.Assets.Count >= itemsPerTrade)) { - if (trades.Count >= Trading.MaxTradesPerAccount) { - break; - } - - singleTrade = new TradeOfferSendRequest(); - trades.Add(singleTrade); - } - - singleTrade.ItemsToReceive.Assets.Add(itemToReceive); - } - } - - Uri request = new(SteamCommunityURL, "/tradeoffer/new/send"); - Uri referer = new(SteamCommunityURL, "/tradeoffer/new"); - - // Extra entry for sessionID - Dictionary data = new(6, StringComparer.Ordinal) { - { "partner", steamID.ToString(CultureInfo.InvariantCulture) }, - { "serverid", "1" }, - { "trade_offer_create_params", !string.IsNullOrEmpty(token) ? new JObject { { "trade_offer_access_token", token } }.ToString(Formatting.None) : "" }, - { "tradeoffermessage", $"Sent by {SharedInfo.PublicIdentifier}/{SharedInfo.Version}" } - }; - - HashSet mobileTradeOfferIDs = new(); - - foreach (TradeOfferSendRequest trade in trades) { - data["json_tradeoffer"] = JsonConvert.SerializeObject(trade); - - ObjectResponse? response = null; - - for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { - response = await UrlPostToJsonObjectWithSession(request, data: data, referer: referer, requestOptions: WebBrowser.ERequestOptions.ReturnServerErrors).ConfigureAwait(false); - - if (response == null) { - return (false, mobileTradeOfferIDs); - } - - if (response.StatusCode.IsServerErrorCode()) { - if (string.IsNullOrEmpty(response.Content.ErrorText)) { - // This is a generic server error without a reason, try again - response = null; - - continue; - } - - // This is actually client error with a reason, so it doesn't make sense to retry - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.Content.ErrorText)); - - return (false, mobileTradeOfferIDs); - } - } - - if (response == null) { - return (false, mobileTradeOfferIDs); - } - - if (response.Content.TradeOfferID == 0) { - Bot.ArchiLogger.LogNullError(nameof(response.Content.TradeOfferID)); - - return (false, mobileTradeOfferIDs); - } - - if (response.Content.RequiresMobileConfirmation) { - mobileTradeOfferIDs.Add(response.Content.TradeOfferID); - } - } - - return (true, mobileTradeOfferIDs); + if (ASF.InventorySemaphore == null) { + throw new InvalidOperationException(nameof(ASF.InventorySemaphore)); } - [PublicAPI] - public async Task UrlGetToHtmlDocumentWithSession(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (maxTries == 0) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - - if (checkSessionPreemptively) { - // Check session preemptively as this request might not get redirected to expiration - bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); - - if (sessionExpired.GetValueOrDefault(true)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlGetToHtmlDocumentWithSession(request, headers, referer, requestOptions, true, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - } else { - // If session refresh is already in progress, just wait for it - await SessionSemaphore.WaitAsync().ConfigureAwait(false); - SessionSemaphore.Release(); - } - + if (steamID == 0) { if (!Initialized) { byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; @@ -477,1775 +145,93 @@ namespace ArchiSteamFarm.Steam.Integration { } if (!Initialized) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; + throw new HttpRequestException(Strings.WarningFailed); } } - Uri host = new(request.GetLeftPart(UriPartial.Authority)); - - HtmlDocumentResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlGetToHtmlDocument(request, headers, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); - - if (response == null) { - return null; - } - - if (IsSessionExpiredUri(response.FinalUri)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlGetToHtmlDocumentWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - - // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case - if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); - - return await UrlGetToHtmlDocumentWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - return response; + steamID = Bot.SteamID; + } else if (!new SteamID(steamID).IsIndividualAccount) { + throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(steamID))); } - [PublicAPI] - public async Task?> UrlGetToJsonObjectWithSession(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } + ulong startAssetID = 0; - if (maxTries == 0) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return default(ObjectResponse?); - } - - if (checkSessionPreemptively) { - // Check session preemptively as this request might not get redirected to expiration - bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); - - if (sessionExpired.GetValueOrDefault(true)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlGetToJsonObjectWithSession(request, headers, referer, requestOptions, true, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - } else { - // If session refresh is already in progress, just wait for it - await SessionSemaphore.WaitAsync().ConfigureAwait(false); - SessionSemaphore.Release(); - } - - if (!Initialized) { - byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; - - for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { - await Task.Delay(1000).ConfigureAwait(false); - } - - if (!Initialized) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return default(ObjectResponse?); - } - } - - Uri host = new(request.GetLeftPart(UriPartial.Authority)); - - ObjectResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlGetToJsonObject(request, headers, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); - - if (response == null) { - return default(ObjectResponse?); - } - - if (IsSessionExpiredUri(response.FinalUri)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlGetToJsonObjectWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - - // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case - if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); - - return await UrlGetToJsonObjectWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - return response; - } - - [Obsolete("ASF no longer uses any XML-related functions, re-implement it yourself if needed.")] - [PublicAPI] - public async Task UrlGetToXmlDocumentWithSession(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (maxTries == 0) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - - if (checkSessionPreemptively) { - // Check session preemptively as this request might not get redirected to expiration - bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); - - if (sessionExpired.GetValueOrDefault(true)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlGetToXmlDocumentWithSession(request, headers, referer, requestOptions, true, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - } else { - // If session refresh is already in progress, just wait for it - await SessionSemaphore.WaitAsync().ConfigureAwait(false); - SessionSemaphore.Release(); - } - - if (!Initialized) { - byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; - - for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { - await Task.Delay(1000).ConfigureAwait(false); - } - - if (!Initialized) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - } - - Uri host = new(request.GetLeftPart(UriPartial.Authority)); - - XmlDocumentResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlGetToXmlDocument(request, headers, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); - - if (response == null) { - return null; - } - - if (IsSessionExpiredUri(response.FinalUri)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlGetToXmlDocumentWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - - // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case - if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); - - return await UrlGetToXmlDocumentWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - return response; - } - - [PublicAPI] - public async Task UrlHeadWithSession(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (maxTries == 0) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return false; - } - - if (checkSessionPreemptively) { - // Check session preemptively as this request might not get redirected to expiration - bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); - - if (sessionExpired.GetValueOrDefault(true)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlHeadWithSession(request, headers, referer, requestOptions, true, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return false; - } - } else { - // If session refresh is already in progress, just wait for it - await SessionSemaphore.WaitAsync().ConfigureAwait(false); - SessionSemaphore.Release(); - } - - if (!Initialized) { - byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; - - for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { - await Task.Delay(1000).ConfigureAwait(false); - } - - if (!Initialized) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return false; - } - } - - Uri host = new(request.GetLeftPart(UriPartial.Authority)); - - BasicResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlHead(request, headers, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); - - if (response == null) { - return false; - } - - if (IsSessionExpiredUri(response.FinalUri)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlHeadWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return false; - } - - // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case - if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); - - return await UrlHeadWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - return true; - } - - [PublicAPI] - public async Task UrlPostToHtmlDocumentWithSession(Uri request, IReadOnlyCollection>? headers = null, IDictionary? data = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, ESession session = ESession.Lowercase, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (!Enum.IsDefined(typeof(ESession), session)) { - throw new InvalidEnumArgumentException(nameof(session), (int) session, typeof(ESession)); - } - - if (maxTries == 0) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - - if (checkSessionPreemptively) { - // Check session preemptively as this request might not get redirected to expiration - bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); - - if (sessionExpired.GetValueOrDefault(true)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlPostToHtmlDocumentWithSession(request, headers, data, referer, requestOptions, session, true, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - } else { - // If session refresh is already in progress, just wait for it - await SessionSemaphore.WaitAsync().ConfigureAwait(false); - SessionSemaphore.Release(); - } - - if (!Initialized) { - byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; - - for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { - await Task.Delay(1000).ConfigureAwait(false); - } - - if (!Initialized) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - } - - Uri host = new(request.GetLeftPart(UriPartial.Authority)); - - if (session != ESession.None) { - string? sessionID = WebBrowser.CookieContainer.GetCookieValue(host, "sessionid"); - - if (string.IsNullOrEmpty(sessionID)) { - Bot.ArchiLogger.LogNullError(nameof(sessionID)); - - return null; - } - - string sessionName = session switch { - ESession.CamelCase => "sessionID", - ESession.Lowercase => "sessionid", - ESession.PascalCase => "SessionID", - _ => throw new ArgumentOutOfRangeException(nameof(session)) - }; - - if (data != null) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - data[sessionName] = sessionID!; - } else { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - data = new Dictionary(1, StringComparer.Ordinal) { { sessionName, sessionID! } }; - } - } - - HtmlDocumentResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlPostToHtmlDocument(request, headers, data, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); - - if (response == null) { - return null; - } - - if (IsSessionExpiredUri(response.FinalUri)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlPostToHtmlDocumentWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - - // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case - if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); - - return await UrlPostToHtmlDocumentWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - return response; - } - - [PublicAPI] - public async Task?> UrlPostToJsonObjectWithSession(Uri request, IReadOnlyCollection>? headers = null, IDictionary? data = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, ESession session = ESession.Lowercase, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (!Enum.IsDefined(typeof(ESession), session)) { - throw new InvalidEnumArgumentException(nameof(session), (int) session, typeof(ESession)); - } - - if (maxTries == 0) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - - if (checkSessionPreemptively) { - // Check session preemptively as this request might not get redirected to expiration - bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); - - if (sessionExpired.GetValueOrDefault(true)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlPostToJsonObjectWithSession(request, headers, data, referer, requestOptions, session, true, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - } else { - // If session refresh is already in progress, just wait for it - await SessionSemaphore.WaitAsync().ConfigureAwait(false); - SessionSemaphore.Release(); - } - - if (!Initialized) { - byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; - - for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { - await Task.Delay(1000).ConfigureAwait(false); - } - - if (!Initialized) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - } - - Uri host = new(request.GetLeftPart(UriPartial.Authority)); - - if (session != ESession.None) { - string? sessionID = WebBrowser.CookieContainer.GetCookieValue(host, "sessionid"); - - if (string.IsNullOrEmpty(sessionID)) { - Bot.ArchiLogger.LogNullError(nameof(sessionID)); - - return null; - } - - string sessionName = session switch { - ESession.CamelCase => "sessionID", - ESession.Lowercase => "sessionid", - ESession.PascalCase => "SessionID", - _ => throw new ArgumentOutOfRangeException(nameof(session)) - }; - - if (data != null) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - data[sessionName] = sessionID!; - } else { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - data = new Dictionary(1, StringComparer.Ordinal) { { sessionName, sessionID! } }; - } - } - - ObjectResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlPostToJsonObject>(request, headers, data, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); - - if (response == null) { - return null; - } - - if (IsSessionExpiredUri(response.FinalUri)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlPostToJsonObjectWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - - // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case - if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); - - return await UrlPostToJsonObjectWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - return response; - } - - [PublicAPI] - public async Task?> UrlPostToJsonObjectWithSession(Uri request, IReadOnlyCollection>? headers = null, ICollection>? data = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, ESession session = ESession.Lowercase, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (!Enum.IsDefined(typeof(ESession), session)) { - throw new InvalidEnumArgumentException(nameof(session), (int) session, typeof(ESession)); - } - - if (maxTries == 0) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - - if (checkSessionPreemptively) { - // Check session preemptively as this request might not get redirected to expiration - bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); - - if (sessionExpired.GetValueOrDefault(true)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlPostToJsonObjectWithSession(request, headers, data, referer, requestOptions, session, true, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - } else { - // If session refresh is already in progress, just wait for it - await SessionSemaphore.WaitAsync().ConfigureAwait(false); - SessionSemaphore.Release(); - } - - if (!Initialized) { - byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; - - for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { - await Task.Delay(1000).ConfigureAwait(false); - } - - if (!Initialized) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - } - - Uri host = new(request.GetLeftPart(UriPartial.Authority)); - - if (session != ESession.None) { - string? sessionID = WebBrowser.CookieContainer.GetCookieValue(host, "sessionid"); - - if (string.IsNullOrEmpty(sessionID)) { - Bot.ArchiLogger.LogNullError(nameof(sessionID)); - - return null; - } - - string sessionName = session switch { - ESession.CamelCase => "sessionID", - ESession.Lowercase => "sessionid", - ESession.PascalCase => "SessionID", - _ => throw new ArgumentOutOfRangeException(nameof(session)) - }; - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - KeyValuePair sessionValue = new(sessionName, sessionID!); - - if (data != null) { - data.Remove(sessionValue); - data.Add(sessionValue); - } else { - data = new List>(1) { sessionValue }; - } - } - - ObjectResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlPostToJsonObject>>(request, headers, data, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); - - if (response == null) { - return null; - } - - if (IsSessionExpiredUri(response.FinalUri)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlPostToJsonObjectWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return null; - } - - // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case - if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); - - return await UrlPostToJsonObjectWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - return response; - } - - [PublicAPI] - public async Task UrlPostWithSession(Uri request, IReadOnlyCollection>? headers = null, IDictionary? data = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, ESession session = ESession.Lowercase, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (!Enum.IsDefined(typeof(ESession), session)) { - throw new InvalidEnumArgumentException(nameof(session), (int) session, typeof(ESession)); - } - - if (maxTries == 0) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return false; - } - - if (checkSessionPreemptively) { - // Check session preemptively as this request might not get redirected to expiration - bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); - - if (sessionExpired.GetValueOrDefault(true)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlPostWithSession(request, headers, data, referer, requestOptions, session, true, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return false; - } - } else { - // If session refresh is already in progress, just wait for it - await SessionSemaphore.WaitAsync().ConfigureAwait(false); - SessionSemaphore.Release(); - } - - if (!Initialized) { - byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; - - for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { - await Task.Delay(1000).ConfigureAwait(false); - } - - if (!Initialized) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return false; - } - } - - Uri host = new(request.GetLeftPart(UriPartial.Authority)); - - if (session != ESession.None) { - string? sessionID = WebBrowser.CookieContainer.GetCookieValue(host, "sessionid"); - - if (string.IsNullOrEmpty(sessionID)) { - Bot.ArchiLogger.LogNullError(nameof(sessionID)); - - return false; - } - - string sessionName = session switch { - ESession.CamelCase => "sessionID", - ESession.Lowercase => "sessionid", - ESession.PascalCase => "SessionID", - _ => throw new ArgumentOutOfRangeException(nameof(session)) - }; - - if (data != null) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - data[sessionName] = sessionID!; - } else { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - data = new Dictionary(1, StringComparer.Ordinal) { { sessionName, sessionID! } }; - } - } - - BasicResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlPost(request, headers, data, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); - - if (response == null) { - return false; - } - - if (IsSessionExpiredUri(response.FinalUri)) { - if (await RefreshSession().ConfigureAwait(false)) { - return await UrlPostWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - return false; - } - - // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case - if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); - - return await UrlPostWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); - } - - return true; - } - - [PublicAPI] - public static async Task WebLimitRequest(Uri service, Func> function) { - if (service == null) { - throw new ArgumentNullException(nameof(service)); - } - - if (function == null) { - throw new ArgumentNullException(nameof(function)); - } - - if (ASF.RateLimitingSemaphore == null) { - throw new InvalidOperationException(nameof(ASF.RateLimitingSemaphore)); - } - - if (ASF.WebLimitingSemaphores == null) { - throw new InvalidOperationException(nameof(ASF.WebLimitingSemaphores)); - } - - ushort webLimiterDelay = ASF.GlobalConfig?.WebLimiterDelay ?? GlobalConfig.DefaultWebLimiterDelay; - - if (webLimiterDelay == 0) { - return await function().ConfigureAwait(false); - } - - if (!ASF.WebLimitingSemaphores.TryGetValue(service, out (ICrossProcessSemaphore RateLimitingSemaphore, SemaphoreSlim OpenConnectionsSemaphore) limiters)) { - ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(service), service)); - - limiters.RateLimitingSemaphore = ASF.RateLimitingSemaphore; - } - - // Sending a request opens a new connection - await limiters.OpenConnectionsSemaphore.WaitAsync().ConfigureAwait(false); - - try { - // It also increases number of requests - await limiters.RateLimitingSemaphore.WaitAsync().ConfigureAwait(false); - - // We release rate-limiter semaphore regardless of our task completion, since we use that one only to guarantee rate-limiting of their creation - Utilities.InBackground( - async () => { - await Task.Delay(webLimiterDelay).ConfigureAwait(false); - limiters.RateLimitingSemaphore.Release(); - } - ); - - return await function().ConfigureAwait(false); - } finally { - // We release open connections semaphore only once we're indeed done sending a particular request - limiters.OpenConnectionsSemaphore.Release(); - } - } - - internal async Task AcceptDigitalGiftCard(ulong giftCardID) { - if (giftCardID == 0) { - throw new ArgumentOutOfRangeException(nameof(giftCardID)); - } - - Uri request = new(SteamStoreURL, "/gifts/0/resolvegiftcard"); - - // Extra entry for sessionID - Dictionary data = new(3, StringComparer.Ordinal) { - { "accept", "1" }, - { "giftcardid", giftCardID.ToString(CultureInfo.InvariantCulture) } - }; - - ObjectResponse? response = await UrlPostToJsonObjectWithSession(request, data: data).ConfigureAwait(false); - - if (response == null) { - return false; - } - - if (response.Content.Result != EResult.OK) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return false; - } - - return true; - } - - internal async Task<(bool Success, bool RequiresMobileConfirmation)> AcceptTradeOffer(ulong tradeID) { - if (tradeID == 0) { - throw new ArgumentOutOfRangeException(nameof(tradeID)); - } - - Uri request = new(SteamCommunityURL, $"/tradeoffer/{tradeID}/accept"); - Uri referer = new(SteamCommunityURL, $"/tradeoffer/{tradeID}"); - - // Extra entry for sessionID - Dictionary data = new(3, StringComparer.Ordinal) { - { "serverid", "1" }, - { "tradeofferid", tradeID.ToString(CultureInfo.InvariantCulture) } - }; - - ObjectResponse? response = null; - - for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { - response = await UrlPostToJsonObjectWithSession(request, data: data, referer: referer, requestOptions: WebBrowser.ERequestOptions.ReturnServerErrors).ConfigureAwait(false); - - if (response == null) { - return (false, false); - } - - if (response.StatusCode.IsServerErrorCode()) { - if (string.IsNullOrEmpty(response.Content.ErrorText)) { - // This is a generic server error without a reason, try again - response = null; - - continue; - } - - // This is actually client error with a reason, so it doesn't make sense to retry - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.Content.ErrorText)); - - return (false, false); - } - } - - return response != null ? (true, response.Content.RequiresMobileConfirmation) : (false, false); - } - - internal async Task<(EResult Result, EPurchaseResultDetail PurchaseResult)> AddFreeLicense(uint subID) { - if (subID == 0) { - throw new ArgumentOutOfRangeException(nameof(subID)); - } - - Uri request = new(SteamStoreURL, $"/checkout/addfreelicense/{subID}"); - - // Extra entry for sessionID - Dictionary data = new(2, StringComparer.Ordinal) { - { "ajax", "true" } - }; - - ObjectResponse? response = await UrlPostToJsonObjectWithSession(request, data: data, requestOptions: WebBrowser.ERequestOptions.ReturnClientErrors | WebBrowser.ERequestOptions.ReturnServerErrors).ConfigureAwait(false); - - if (response == null) { - return (EResult.Fail, EPurchaseResultDetail.Timeout); - } - - switch (response.StatusCode) { - case HttpStatusCode.Forbidden: - // Let's convert this into something reasonable - return (EResult.AccessDenied, EPurchaseResultDetail.InvalidPackage); - case HttpStatusCode.InternalServerError: - case HttpStatusCode.OK: - // This API is total nuts, it returns sometimes [ ], sometimes { "purchaseresultdetail": int } and sometimes null because f**k you, that's why, I wouldn't be surprised if it returned XML one day - // There is not much we can do apart from trying to extract the result and returning it along with the OK and non-OK response, it's also why it doesn't make any sense to strong-type it - EPurchaseResultDetail purchaseResult = EPurchaseResultDetail.NoDetail; - - if (response.Content is JObject jObject) { - byte? numberResult = jObject["purchaseresultdetail"]?.Value(); - - if (numberResult.HasValue) { - purchaseResult = (EPurchaseResultDetail) numberResult.Value; - } - } - - return (response.StatusCode.IsSuccessCode() ? EResult.OK : EResult.Fail, purchaseResult); - case HttpStatusCode.Unauthorized: - // Let's convert this into something reasonable - return (EResult.AccessDenied, EPurchaseResultDetail.NoDetail); - default: - // We should handle all expected status codes above, this is a generic fallback for those that we don't - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(response.StatusCode), response.StatusCode)); - - return (response.StatusCode.IsSuccessCode() ? EResult.OK : EResult.Fail, EPurchaseResultDetail.ContactSupport); - } - } - - internal async Task ChangePrivacySettings(UserPrivacy userPrivacy) { - if (userPrivacy == null) { - throw new ArgumentNullException(nameof(userPrivacy)); - } - - string? profileURL = await GetAbsoluteProfileURL().ConfigureAwait(false); - - if (string.IsNullOrEmpty(profileURL)) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return false; - } - - Uri request = new(SteamCommunityURL, $"{profileURL}/ajaxsetprivacy"); - - // Extra entry for sessionID - Dictionary data = new(3, StringComparer.Ordinal) { - { "eCommentPermission", ((byte) userPrivacy.CommentPermission).ToString(CultureInfo.InvariantCulture) }, - { "Privacy", JsonConvert.SerializeObject(userPrivacy.Settings) } - }; - - ObjectResponse? response = await UrlPostToJsonObjectWithSession(request, data: data).ConfigureAwait(false); - - if (response == null) { - return false; - } - - if (response.Content.Result != EResult.OK) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return false; - } - - return true; - } - - internal async Task ClearFromDiscoveryQueue(uint appID) { - if (appID == 0) { - throw new ArgumentOutOfRangeException(nameof(appID)); - } - - Uri request = new(SteamStoreURL, $"/app/{appID}"); - - // Extra entry for sessionID - Dictionary data = new(2, StringComparer.Ordinal) { { "appid_to_clear_from_queue", appID.ToString(CultureInfo.InvariantCulture) } }; - - return await UrlPostWithSession(request, data: data).ConfigureAwait(false); - } - - internal async Task DeclineTradeOffer(ulong tradeID) { - if (tradeID == 0) { - throw new ArgumentOutOfRangeException(nameof(tradeID)); - } - - (bool success, string? steamApiKey) = await CachedApiKey.GetValue().ConfigureAwait(false); - - if (!success || string.IsNullOrEmpty(steamApiKey)) { - return false; - } - - // Extra entry for format - Dictionary arguments = new(3, StringComparer.Ordinal) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - { "key", steamApiKey! }, - - { "tradeofferid", tradeID } - }; - - KeyValue? response = null; - - for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { - using WebAPI.AsyncInterface econService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(EconService); - - econService.Timeout = WebBrowser.Timeout; - - try { - response = await WebLimitRequest( - WebAPI.DefaultBaseAddress, - - // ReSharper disable once AccessToDisposedClosure - async () => await econService.CallAsync(HttpMethod.Post, "DeclineTradeOffer", args: arguments).ConfigureAwait(false) - ).ConfigureAwait(false); - } catch (TaskCanceledException e) { - Bot.ArchiLogger.LogGenericDebuggingException(e); - } catch (Exception e) { - Bot.ArchiLogger.LogGenericWarningException(e); - } - } - - if (response == null) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); - - return false; - } - - return true; - } - - internal HttpClient GenerateDisposableHttpClient() => WebBrowser.GenerateDisposableHttpClient(); - - internal async Task?> GenerateNewDiscoveryQueue() { - Uri request = new(SteamStoreURL, "/explore/generatenewdiscoveryqueue"); - - // Extra entry for sessionID - Dictionary data = new(2, StringComparer.Ordinal) { { "queuetype", "0" } }; - - ObjectResponse? response = await UrlPostToJsonObjectWithSession(request, data: data).ConfigureAwait(false); - - return response?.Content.Queue; - } - - internal async Task?> GetActiveTradeOffers() { - (bool success, string? steamApiKey) = await CachedApiKey.GetValue().ConfigureAwait(false); - - if (!success || string.IsNullOrEmpty(steamApiKey)) { - return null; - } - - // Extra entry for format - Dictionary arguments = new(6, StringComparer.Ordinal) { - { "active_only", 1 }, - { "get_descriptions", 1 }, - { "get_received_offers", 1 }, - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - { "key", steamApiKey! }, - - { "time_historical_cutoff", uint.MaxValue } - }; - - KeyValue? response = null; - - for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { - using WebAPI.AsyncInterface econService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(EconService); - - econService.Timeout = WebBrowser.Timeout; - - try { - response = await WebLimitRequest( - WebAPI.DefaultBaseAddress, - - // ReSharper disable once AccessToDisposedClosure - async () => await econService.CallAsync(HttpMethod.Get, "GetTradeOffers", args: arguments).ConfigureAwait(false) - ).ConfigureAwait(false); - } catch (TaskCanceledException e) { - Bot.ArchiLogger.LogGenericDebuggingException(e); - } catch (Exception e) { - Bot.ArchiLogger.LogGenericWarningException(e); - } - } - - if (response == null) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); - - return null; - } - - Dictionary<(uint AppID, ulong ClassID, ulong InstanceID), InventoryResponse.Description> descriptions = new(); - - foreach (KeyValue description in response["descriptions"].Children) { - uint appID = description["appid"].AsUnsignedInteger(); - - if (appID == 0) { - Bot.ArchiLogger.LogNullError(nameof(appID)); - - return null; - } - - ulong classID = description["classid"].AsUnsignedLong(); - - if (classID == 0) { - Bot.ArchiLogger.LogNullError(nameof(classID)); - - return null; - } - - ulong instanceID = description["instanceid"].AsUnsignedLong(); - - (uint AppID, ulong ClassID, ulong InstanceID) key = (appID, classID, instanceID); - - if (descriptions.ContainsKey(key)) { - continue; - } - - InventoryResponse.Description parsedDescription = new() { - AppID = appID, - ClassID = classID, - InstanceID = instanceID, - Marketable = description["marketable"].AsBoolean(), - Tradable = true // We're parsing active trade offers, we can assume as much - }; - - List tags = description["tags"].Children; - - if (tags.Count > 0) { - HashSet parsedTags = new(tags.Count); - - foreach (KeyValue tag in tags) { - string? identifier = tag["category"].AsString(); - - if (string.IsNullOrEmpty(identifier)) { - Bot.ArchiLogger.LogNullError(nameof(identifier)); - - return null; - } - - string? value = tag["internal_name"].AsString(); - - // Apparently, name can be empty, but not null - if (value == null) { - Bot.ArchiLogger.LogNullError(nameof(value)); - - return null; - } - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - parsedTags.Add(new Tag(identifier!, value)); - } - - parsedDescription.Tags = parsedTags.ToImmutableHashSet(); - } - - descriptions[key] = parsedDescription; - } - - HashSet result = new(); - - foreach (KeyValue trade in response["trade_offers_received"].Children) { - ETradeOfferState state = trade["trade_offer_state"].AsEnum(); - - if (!Enum.IsDefined(typeof(ETradeOfferState), state)) { - Bot.ArchiLogger.LogNullError(nameof(state)); - - return null; - } - - if (state != ETradeOfferState.Active) { - continue; - } - - ulong tradeOfferID = trade["tradeofferid"].AsUnsignedLong(); - - if (tradeOfferID == 0) { - Bot.ArchiLogger.LogNullError(nameof(tradeOfferID)); - - return null; - } - - uint otherSteamID3 = trade["accountid_other"].AsUnsignedInteger(); - - if (otherSteamID3 == 0) { - Bot.ArchiLogger.LogNullError(nameof(otherSteamID3)); - - return null; - } - - TradeOffer tradeOffer = new(tradeOfferID, otherSteamID3, state); - - List itemsToGive = trade["items_to_give"].Children; - - if (itemsToGive.Count > 0) { - if (!ParseItems(descriptions, itemsToGive, tradeOffer.ItemsToGive)) { - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(itemsToGive))); - - return null; - } - } - - List itemsToReceive = trade["items_to_receive"].Children; - - if (itemsToReceive.Count > 0) { - if (!ParseItems(descriptions, itemsToReceive, tradeOffer.ItemsToReceive)) { - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(itemsToReceive))); - - return null; - } - } - - result.Add(tradeOffer); - } - - return result; - } - - internal async Task?> GetAppList() { - KeyValue? response = null; - - for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { - using WebAPI.AsyncInterface steamAppsService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(SteamAppsService); - - steamAppsService.Timeout = WebBrowser.Timeout; - - try { - response = await WebLimitRequest( - WebAPI.DefaultBaseAddress, - - // ReSharper disable once AccessToDisposedClosure - async () => await steamAppsService.CallAsync(HttpMethod.Get, "GetAppList", 2).ConfigureAwait(false) - ).ConfigureAwait(false); - } catch (TaskCanceledException e) { - Bot.ArchiLogger.LogGenericDebuggingException(e); - } catch (Exception e) { - Bot.ArchiLogger.LogGenericWarningException(e); - } - } - - if (response == null) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); - - return null; - } - - List apps = response["apps"].Children; - - if (apps.Count == 0) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(apps))); - - return null; - } - - HashSet result = new(apps.Count); - - foreach (uint appID in apps.Select(static app => app["appid"].AsUnsignedInteger())) { - if (appID == 0) { - Bot.ArchiLogger.LogNullError(nameof(appID)); - - return null; - } - - result.Add(appID); - } - - return result; - } - - internal async Task GetBadgePage(byte page) { - if (page == 0) { - throw new ArgumentOutOfRangeException(nameof(page)); - } - - Uri request = new(SteamCommunityURL, $"/my/badges?l=english&p={page}"); - - HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request, checkSessionPreemptively: false).ConfigureAwait(false); - - return response?.Content; - } - - internal async Task GetCardCountForGame(uint appID) { - if (appID == 0) { - throw new ArgumentOutOfRangeException(nameof(appID)); - } - - if (CachedCardCountsForGame.TryGetValue(appID, out byte result)) { - return result; - } - - using IDocument? htmlDocument = await GetGameCardsPage(appID).ConfigureAwait(false); - - if (htmlDocument == null) { - Bot.ArchiLogger.LogNullError(nameof(htmlDocument)); - - return 0; - } - - IEnumerable htmlNodes = htmlDocument.SelectNodes("//div[@class='badge_card_set_cards']/div[starts-with(@class, 'badge_card_set_card')]"); - - result = (byte) htmlNodes.Count(); - - if (result == 0) { - Bot.ArchiLogger.LogNullError(nameof(result)); - - return 0; - } - - CachedCardCountsForGame.TryAdd(appID, result); - - return result; - } - - internal async Task GetConfirmationsPage(string deviceID, string confirmationHash, uint time) { - if (string.IsNullOrEmpty(deviceID)) { - throw new ArgumentNullException(nameof(deviceID)); - } - - if (string.IsNullOrEmpty(confirmationHash)) { - throw new ArgumentNullException(nameof(confirmationHash)); - } - - if (time == 0) { - throw new ArgumentOutOfRangeException(nameof(time)); - } - - if (!Initialized) { - byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; - - for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { - await Task.Delay(1000).ConfigureAwait(false); - } - - if (!Initialized) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return null; - } - } - - Uri request = new(SteamCommunityURL, $"/mobileconf/conf?a={Bot.SteamID}&k={WebUtility.UrlEncode(confirmationHash)}&l=english&m=android&p={WebUtility.UrlEncode(deviceID)}&t={time}&tag=conf"); - - HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request).ConfigureAwait(false); - - return response?.Content; - } - - internal async Task?> GetDigitalGiftCards() { - Uri request = new(SteamStoreURL, "/gifts"); - - using HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request).ConfigureAwait(false); - - if (response == null) { - return null; - } - - IEnumerable htmlNodes = response.Content.SelectNodes("//div[@class='pending_gift']/div[starts-with(@id, 'pending_gift_')][count(div[@class='pending_giftcard_leftcol']) > 0]/@id"); - - HashSet results = new(); - - foreach (string? giftCardIDText in htmlNodes.Select(static node => node.GetAttribute("id"))) { - if (string.IsNullOrEmpty(giftCardIDText)) { - Bot.ArchiLogger.LogNullError(nameof(giftCardIDText)); - - return null; - } - - if (giftCardIDText.Length <= 13) { - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(giftCardIDText))); - - return null; - } - - if (!ulong.TryParse(giftCardIDText[13..], out ulong giftCardID) || (giftCardID == 0)) { - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(giftCardID))); - - return null; - } - - results.Add(giftCardID); - } - - return results; - } - - internal async Task GetDiscoveryQueuePage() { - Uri request = new(SteamStoreURL, "/explore?l=english"); - - HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request).ConfigureAwait(false); - - return response?.Content; - } - - internal async Task?> GetFamilySharingSteamIDs() { - Uri request = new(SteamStoreURL, "/account/managedevices?l=english"); - - using HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request).ConfigureAwait(false); - - if (response == null) { - return null; - } - - IEnumerable htmlNodes = response.Content.SelectNodes("(//table[@class='accountTable'])[2]//a/@data-miniprofile"); - - HashSet result = new(); - - foreach (string? miniProfile in htmlNodes.Select(static htmlNode => htmlNode.GetAttribute("data-miniprofile"))) { - if (string.IsNullOrEmpty(miniProfile)) { - Bot.ArchiLogger.LogNullError(nameof(miniProfile)); - - return null; - } - - if (!uint.TryParse(miniProfile, out uint steamID3) || (steamID3 == 0)) { - Bot.ArchiLogger.LogNullError(nameof(steamID3)); - - return null; - } - - ulong steamID = new SteamID(steamID3, EUniverse.Public, EAccountType.Individual); - result.Add(steamID); - } - - return result; - } - - internal async Task GetGameCardsPage(uint appID) { - if (appID == 0) { - throw new ArgumentOutOfRangeException(nameof(appID)); - } - - Uri request = new(SteamCommunityURL, $"/my/gamecards/{appID}?l=english"); - - HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request, checkSessionPreemptively: false).ConfigureAwait(false); - - return response?.Content; - } - - internal async Task GetServerTime() { - KeyValue? response = null; - - for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { - using WebAPI.AsyncInterface twoFactorService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(TwoFactorService); - - twoFactorService.Timeout = WebBrowser.Timeout; - - try { - response = await WebLimitRequest( - WebAPI.DefaultBaseAddress, - - // ReSharper disable once AccessToDisposedClosure - async () => await twoFactorService.CallAsync(HttpMethod.Post, "QueryTime").ConfigureAwait(false) - ).ConfigureAwait(false); - } catch (TaskCanceledException e) { - Bot.ArchiLogger.LogGenericDebuggingException(e); - } catch (Exception e) { - Bot.ArchiLogger.LogGenericWarningException(e); - } - } - - if (response == null) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); - - return 0; - } - - uint result = response["server_time"].AsUnsignedInteger(); - - if (result == 0) { - Bot.ArchiLogger.LogNullError(nameof(result)); - - return 0; - } - - return result; - } - - internal async Task GetTradeHoldDurationForTrade(ulong tradeID) { - if (tradeID == 0) { - throw new ArgumentOutOfRangeException(nameof(tradeID)); - } - - Uri request = new(SteamCommunityURL, $"/tradeoffer/{tradeID}?l=english"); - - using HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request).ConfigureAwait(false); - - IElement? htmlNode = response?.Content.SelectSingleNode("//div[@class='pagecontent']/script"); - - if (htmlNode == null) { - // Trade can be no longer valid - return null; - } - - string text = htmlNode.TextContent; - - if (string.IsNullOrEmpty(text)) { - Bot.ArchiLogger.LogNullError(nameof(text)); - - return null; - } - - const string daysTheirVariableName = "g_daysTheirEscrow = "; - int index = text.IndexOf(daysTheirVariableName, StringComparison.Ordinal); - - if (index < 0) { - Bot.ArchiLogger.LogNullError(nameof(index)); - - return null; - } - - index += daysTheirVariableName.Length; - text = text[index..]; - - index = text.IndexOf(';', StringComparison.Ordinal); - - if (index < 0) { - Bot.ArchiLogger.LogNullError(nameof(index)); - - return null; - } - - text = text[..index]; - - if (!byte.TryParse(text, out byte result)) { - Bot.ArchiLogger.LogNullError(nameof(result)); - - return null; - } - - return result; - } - - internal async Task GetTradeHoldDurationForUser(ulong steamID, string? tradeToken = null) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - (bool success, string? steamApiKey) = await CachedApiKey.GetValue().ConfigureAwait(false); - - if (!success || string.IsNullOrEmpty(steamApiKey)) { - return null; - } - - // Extra entry for format - Dictionary arguments = new(!string.IsNullOrEmpty(tradeToken) ? 4 : 3, StringComparer.Ordinal) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - { "key", steamApiKey! }, - - { "steamid_target", steamID } - }; - - if (!string.IsNullOrEmpty(tradeToken)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - arguments["trade_offer_access_token"] = tradeToken!; - } - - KeyValue? response = null; - - for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { - using WebAPI.AsyncInterface econService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(EconService); - - econService.Timeout = WebBrowser.Timeout; - - try { - response = await WebLimitRequest( - WebAPI.DefaultBaseAddress, - - // ReSharper disable once AccessToDisposedClosure - async () => await econService.CallAsync(HttpMethod.Get, "GetTradeHoldDurations", args: arguments).ConfigureAwait(false) - ).ConfigureAwait(false); - } catch (TaskCanceledException e) { - Bot.ArchiLogger.LogGenericDebuggingException(e); - } catch (Exception e) { - Bot.ArchiLogger.LogGenericWarningException(e); - } - } - - if (response == null) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); - - return null; - } - - uint resultInSeconds = response["their_escrow"]["escrow_end_duration_seconds"].AsUnsignedInteger(uint.MaxValue); - - if (resultInSeconds == uint.MaxValue) { - Bot.ArchiLogger.LogNullError(nameof(resultInSeconds)); - - return null; - } - - return resultInSeconds == 0 ? (byte) 0 : (byte) (resultInSeconds / 86400); - } - - internal async Task HandleConfirmation(string deviceID, string confirmationHash, uint time, ulong confirmationID, ulong confirmationKey, bool accept) { - if (string.IsNullOrEmpty(deviceID)) { - throw new ArgumentNullException(nameof(deviceID)); - } - - if (string.IsNullOrEmpty(confirmationHash)) { - throw new ArgumentNullException(nameof(confirmationHash)); - } - - if (time == 0) { - throw new ArgumentOutOfRangeException(nameof(time)); - } - - if (confirmationID == 0) { - throw new ArgumentOutOfRangeException(nameof(confirmationID)); - } - - if (confirmationKey == 0) { - throw new ArgumentOutOfRangeException(nameof(confirmationKey)); - } - - if (!Initialized) { - byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; - - for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { - await Task.Delay(1000).ConfigureAwait(false); - } - - if (!Initialized) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return null; - } - } - - Uri request = new(SteamCommunityURL, $"/mobileconf/ajaxop?a={Bot.SteamID}&cid={confirmationID}&ck={confirmationKey}&k={WebUtility.UrlEncode(confirmationHash)}&l=english&m=android&op={(accept ? "allow" : "cancel")}&p={WebUtility.UrlEncode(deviceID)}&t={time}&tag=conf"); - - ObjectResponse? response = await UrlGetToJsonObjectWithSession(request).ConfigureAwait(false); - - return response?.Content.Success; - } - - internal async Task HandleConfirmations(string deviceID, string confirmationHash, uint time, IReadOnlyCollection confirmations, bool accept) { - if (string.IsNullOrEmpty(deviceID)) { - throw new ArgumentNullException(nameof(deviceID)); - } - - if (string.IsNullOrEmpty(confirmationHash)) { - throw new ArgumentNullException(nameof(confirmationHash)); - } - - if (time == 0) { - throw new ArgumentOutOfRangeException(nameof(time)); - } - - if ((confirmations == null) || (confirmations.Count == 0)) { - throw new ArgumentNullException(nameof(confirmations)); - } - - if (!Initialized) { - byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; - - for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { - await Task.Delay(1000).ConfigureAwait(false); - } - - if (!Initialized) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return null; - } - } - - Uri request = new(SteamCommunityURL, "/mobileconf/multiajaxop"); - - // Extra entry for sessionID - List> data = new(8 + (confirmations.Count * 2)) { - new KeyValuePair("a", Bot.SteamID.ToString(CultureInfo.InvariantCulture)), - new KeyValuePair("k", confirmationHash), - new KeyValuePair("m", "android"), - new KeyValuePair("op", accept ? "allow" : "cancel"), - new KeyValuePair("p", deviceID), - new KeyValuePair("t", time.ToString(CultureInfo.InvariantCulture)), - new KeyValuePair("tag", "conf") - }; - - foreach (Confirmation confirmation in confirmations) { - data.Add(new KeyValuePair("cid[]", confirmation.ID.ToString(CultureInfo.InvariantCulture))); - data.Add(new KeyValuePair("ck[]", confirmation.Key.ToString(CultureInfo.InvariantCulture))); - } - - ObjectResponse? response = await UrlPostToJsonObjectWithSession(request, data: data).ConfigureAwait(false); - - return response?.Content.Success; - } - - internal async Task Init(ulong steamID, EUniverse universe, string webAPIUserNonce, string? parentalCode = null) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if ((universe == EUniverse.Invalid) || !Enum.IsDefined(typeof(EUniverse), universe)) { - throw new InvalidEnumArgumentException(nameof(universe), (int) universe, typeof(EUniverse)); - } - - if (string.IsNullOrEmpty(webAPIUserNonce)) { - throw new ArgumentNullException(nameof(webAPIUserNonce)); - } - - byte[]? publicKey = KeyDictionary.GetPublicKey(universe); - - if ((publicKey == null) || (publicKey.Length == 0)) { - Bot.ArchiLogger.LogNullError(nameof(publicKey)); - - return false; - } - - // Generate a random 32-byte session key - byte[] sessionKey = CryptoHelper.GenerateRandomBlock(32); - - // RSA encrypt our session key with the public key for the universe we're on - byte[] encryptedSessionKey; - - using (RSACrypto rsa = new(publicKey)) { - encryptedSessionKey = rsa.Encrypt(sessionKey); - } - - // Generate login key from the user nonce that we've received from Steam network - byte[] loginKey = Encoding.UTF8.GetBytes(webAPIUserNonce); - - // AES encrypt our login key with our session key - byte[] encryptedLoginKey = CryptoHelper.SymmetricEncrypt(loginKey, sessionKey); - - // Extra entry for format - Dictionary arguments = new(4, StringComparer.Ordinal) { - { "encrypted_loginkey", encryptedLoginKey }, - { "sessionkey", encryptedSessionKey }, - { "steamid", steamID } - }; - - // We're now ready to send the data to Steam API - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.LoggingIn, SteamUserAuthService)); - - KeyValue? response; - - // We do not use usual retry pattern here as webAPIUserNonce is valid only for a single request - // Even during timeout, webAPIUserNonce is most likely already invalid - // Instead, the caller is supposed to ask for new webAPIUserNonce and call Init() again on failure - using (WebAPI.AsyncInterface steamUserAuthService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(SteamUserAuthService)) { - steamUserAuthService.Timeout = WebBrowser.Timeout; - - try { - response = await WebLimitRequest( - WebAPI.DefaultBaseAddress, - - // ReSharper disable once AccessToDisposedClosure - async () => await steamUserAuthService.CallAsync(HttpMethod.Post, "AuthenticateUser", args: arguments).ConfigureAwait(false) - ).ConfigureAwait(false); - } catch (TaskCanceledException e) { - Bot.ArchiLogger.LogGenericDebuggingException(e); - - return false; - } catch (Exception e) { - Bot.ArchiLogger.LogGenericWarningException(e); - - return false; - } - } - - string? steamLogin = response["token"].AsString(); - - if (string.IsNullOrEmpty(steamLogin)) { - Bot.ArchiLogger.LogNullError(nameof(steamLogin)); - - return false; - } - - string? steamLoginSecure = response["tokensecure"].AsString(); - - if (string.IsNullOrEmpty(steamLoginSecure)) { - Bot.ArchiLogger.LogNullError(nameof(steamLoginSecure)); - - return false; - } - - string sessionID = Convert.ToBase64String(Encoding.UTF8.GetBytes(steamID.ToString(CultureInfo.InvariantCulture))); - - WebBrowser.CookieContainer.Add(new Cookie("sessionid", sessionID, "/", $".{SteamCommunityURL.Host}")); - WebBrowser.CookieContainer.Add(new Cookie("sessionid", sessionID, "/", $".{SteamHelpURL.Host}")); - WebBrowser.CookieContainer.Add(new Cookie("sessionid", sessionID, "/", $".{SteamStoreURL.Host}")); - - WebBrowser.CookieContainer.Add(new Cookie("steamLogin", steamLogin, "/", $".{SteamCommunityURL.Host}")); - WebBrowser.CookieContainer.Add(new Cookie("steamLogin", steamLogin, "/", $".{SteamHelpURL.Host}")); - WebBrowser.CookieContainer.Add(new Cookie("steamLogin", steamLogin, "/", $".{SteamStoreURL.Host}")); - - WebBrowser.CookieContainer.Add(new Cookie("steamLoginSecure", steamLoginSecure, "/", $".{SteamCommunityURL.Host}")); - WebBrowser.CookieContainer.Add(new Cookie("steamLoginSecure", steamLoginSecure, "/", $".{SteamHelpURL.Host}")); - WebBrowser.CookieContainer.Add(new Cookie("steamLoginSecure", steamLoginSecure, "/", $".{SteamStoreURL.Host}")); - - // Report proper time when doing timezone-based calculations, see setTimezoneCookies() from https://steamcommunity-a.akamaihd.net/public/shared/javascript/shared_global.js - string timeZoneOffset = $"{DateTimeOffset.Now.Offset.TotalSeconds}{WebUtility.UrlEncode(",")}0"; - - WebBrowser.CookieContainer.Add(new Cookie("timezoneOffset", timeZoneOffset, "/", $".{SteamCommunityURL.Host}")); - WebBrowser.CookieContainer.Add(new Cookie("timezoneOffset", timeZoneOffset, "/", $".{SteamHelpURL.Host}")); - WebBrowser.CookieContainer.Add(new Cookie("timezoneOffset", timeZoneOffset, "/", $".{SteamStoreURL.Host}")); - - Bot.ArchiLogger.LogGenericInfo(Strings.Success); - - // Unlock Steam Parental if needed - if (parentalCode?.Length == BotConfig.SteamParentalCodeLength) { - if (!await UnlockParentalAccount(parentalCode).ConfigureAwait(false)) { - return false; - } - } - - LastSessionCheck = LastSessionRefresh = DateTime.UtcNow; - Initialized = true; - - return true; - } - - internal async Task MarkInventory() { - if (ASF.InventorySemaphore == null) { - throw new InvalidOperationException(nameof(ASF.InventorySemaphore)); - } - - // We aim to have a maximum of 2 tasks, one already working, and one waiting in the queue - // This way we can call this function as many times as needed e.g. because of Steam events - lock (ASF.InventorySemaphore) { - if (MarkingInventoryScheduled) { - return; - } - - MarkingInventoryScheduled = true; - } + // We need to store asset IDs to make sure we won't get duplicate items + HashSet? assetIDs = null; + while (true) { await ASF.InventorySemaphore.WaitAsync().ConfigureAwait(false); try { - lock (ASF.InventorySemaphore) { - MarkingInventoryScheduled = false; + Uri request = new(SteamCommunityURL, $"/inventory/{steamID}/{appID}/{contextID}?count={MaxItemsInSingleInventoryRequest}&l=english{(startAssetID > 0 ? $"&start_assetid={startAssetID}" : "")}"); + + ObjectResponse? response = await UrlGetToJsonObjectWithSession(request).ConfigureAwait(false); + + if (response == null) { + throw new HttpRequestException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(response))); } - Uri request = new(SteamCommunityURL, "/my/inventory"); + if (response.Content.Result != EResult.OK) { + throw new HttpRequestException(!string.IsNullOrEmpty(response.Content.Error) ? string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.Content.Error) : Strings.WarningFailed); + } - await UrlHeadWithSession(request, checkSessionPreemptively: false).ConfigureAwait(false); + if (response.Content.TotalInventoryCount == 0) { + // Empty inventory + yield break; + } + + assetIDs ??= new HashSet((int) response.Content.TotalInventoryCount); + + if ((response.Content.Assets.Count == 0) || (response.Content.Descriptions.Count == 0)) { + throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, $"{nameof(response.Content.Assets)} || {nameof(response.Content.Descriptions)}")); + } + + Dictionary<(ulong ClassID, ulong InstanceID), InventoryResponse.Description> descriptions = new(); + + foreach (InventoryResponse.Description description in response.Content.Descriptions) { + if (description.ClassID == 0) { + throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(description.ClassID))); + } + + (ulong ClassID, ulong InstanceID) key = (description.ClassID, description.InstanceID); + + if (descriptions.ContainsKey(key)) { + continue; + } + + descriptions[key] = description; + } + + foreach (Asset asset in response.Content.Assets) { + if (!descriptions.TryGetValue((asset.ClassID, asset.InstanceID), out InventoryResponse.Description? description) || assetIDs.Contains(asset.AssetID)) { + continue; + } + + asset.Marketable = description.Marketable; + asset.Tradable = description.Tradable; + asset.Tags = description.Tags; + asset.RealAppID = description.RealAppID; + asset.Type = description.Type; + asset.Rarity = description.Rarity; + + if (description.AdditionalProperties != null) { + asset.AdditionalProperties = description.AdditionalProperties; + } + + assetIDs.Add(asset.AssetID); + + yield return asset; + } + + if (!response.Content.MoreItems) { + yield break; + } + + if (response.Content.LastAssetID == 0) { + throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, Strings.ErrorObjectIsNull, nameof(response.Content.LastAssetID))); + } + + startAssetID = response.Content.LastAssetID; } finally { byte inventoryLimiterDelay = ASF.GlobalConfig?.InventoryLimiterDelay ?? GlobalConfig.DefaultInventoryLimiterDelay; @@ -2261,456 +247,881 @@ namespace ArchiSteamFarm.Steam.Integration { } } } + } - internal async Task MarkSentTrades() { - Uri request = new(SteamCommunityURL, "/my/tradeoffers/sent"); + [PublicAPI] + public async Task GetPointsBalance() { + (bool success, string? accessToken) = await CachedAccessToken.GetValue().ConfigureAwait(false); - return await UrlHeadWithSession(request, checkSessionPreemptively: false).ConfigureAwait(false); + if (!success || string.IsNullOrEmpty(accessToken)) { + return null; } - internal void OnDisconnected() { - Initialized = false; - Utilities.InBackground(CachedApiKey.Reset); - } + // Extra entry for format + Dictionary arguments = new(3, StringComparer.Ordinal) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + { "access_token", accessToken! }, - internal void OnVanityURLChanged(string? vanityURL = null) => VanityURL = !string.IsNullOrEmpty(vanityURL) ? vanityURL : null; + { "steamid", Bot.SteamID } + }; - internal async Task<(EResult Result, EPurchaseResultDetail? PurchaseResult)?> RedeemWalletKey(string key) { - if (string.IsNullOrEmpty(key)) { - throw new ArgumentNullException(nameof(key)); - } + KeyValue? response = null; - // ASF should redeem wallet key only in case of existing wallet - if (Bot.WalletCurrency == ECurrencyCode.Invalid) { - Bot.ArchiLogger.LogNullError(nameof(Bot.WalletCurrency)); + for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { + using WebAPI.AsyncInterface loyaltyRewardsService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(LoyaltyRewardsService); - return null; - } - - Uri request = new(SteamStoreURL, "/account/ajaxredeemwalletcode"); - - // Extra entry for sessionID - Dictionary data = new(2, StringComparer.Ordinal) { { "wallet_code", key } }; - - ObjectResponse? response = await UrlPostToJsonObjectWithSession(request, data: data).ConfigureAwait(false); - - if (response == null) { - return null; - } - - // We can not trust EResult response, because it is OK even in the case of error, so changing it to Fail in this case - if ((response.Content.Result != EResult.OK) || (response.Content.PurchaseResultDetail != EPurchaseResultDetail.NoDetail)) { - return (response.Content.Result == EResult.OK ? EResult.Fail : response.Content.Result, response.Content.PurchaseResultDetail); - } - - return (EResult.OK, EPurchaseResultDetail.NoDetail); - } - - internal async Task UnpackBooster(uint appID, ulong itemID) { - if (appID == 0) { - throw new ArgumentOutOfRangeException(nameof(appID)); - } - - if (itemID == 0) { - throw new ArgumentOutOfRangeException(nameof(itemID)); - } - - string? profileURL = await GetAbsoluteProfileURL().ConfigureAwait(false); - - if (string.IsNullOrEmpty(profileURL)) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return false; - } - - Uri request = new(SteamCommunityURL, $"{profileURL}/ajaxunpackbooster"); - - // Extra entry for sessionID - Dictionary data = new(3, StringComparer.Ordinal) { - { "appid", appID.ToString(CultureInfo.InvariantCulture) }, - { "communityitemid", itemID.ToString(CultureInfo.InvariantCulture) } - }; - - ObjectResponse? response = await UrlPostToJsonObjectWithSession(request, data: data).ConfigureAwait(false); - - return response?.Content.Result == EResult.OK; - } - - private async Task<(ESteamApiKeyState State, string? Key)> GetApiKeyState() { - Uri request = new(SteamCommunityURL, "/dev/apikey?l=english"); - - using HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request).ConfigureAwait(false); - - if (response == null) { - return (ESteamApiKeyState.Timeout, null); - } - - IElement? titleNode = response.Content.SelectSingleNode("//div[@id='mainContents']/h2"); - - if (titleNode == null) { - Bot.ArchiLogger.LogNullError(nameof(titleNode)); - - return (ESteamApiKeyState.Error, null); - } - - string title = titleNode.TextContent; - - if (string.IsNullOrEmpty(title)) { - Bot.ArchiLogger.LogNullError(nameof(title)); - - return (ESteamApiKeyState.Error, null); - } - - if (title.Contains("Access Denied", StringComparison.OrdinalIgnoreCase) || title.Contains("Validated email address required", StringComparison.OrdinalIgnoreCase)) { - return (ESteamApiKeyState.AccessDenied, null); - } - - IElement? htmlNode = response.Content.SelectSingleNode("//div[@id='bodyContents_ex']/p"); - - if (htmlNode == null) { - Bot.ArchiLogger.LogNullError(nameof(htmlNode)); - - return (ESteamApiKeyState.Error, null); - } - - string text = htmlNode.TextContent; - - if (string.IsNullOrEmpty(text)) { - Bot.ArchiLogger.LogNullError(nameof(text)); - - return (ESteamApiKeyState.Error, null); - } - - if (text.Contains("Registering for a Steam Web API Key", StringComparison.OrdinalIgnoreCase)) { - return (ESteamApiKeyState.NotRegisteredYet, null); - } - - int keyIndex = text.IndexOf("Key: ", StringComparison.Ordinal); - - if (keyIndex < 0) { - Bot.ArchiLogger.LogNullError(nameof(keyIndex)); - - return (ESteamApiKeyState.Error, null); - } - - keyIndex += 5; - - if (text.Length <= keyIndex) { - Bot.ArchiLogger.LogNullError(nameof(text)); - - return (ESteamApiKeyState.Error, null); - } - - text = text[keyIndex..]; - - if ((text.Length != 32) || !Utilities.IsValidHexadecimalText(text)) { - Bot.ArchiLogger.LogNullError(nameof(text)); - - return (ESteamApiKeyState.Error, null); - } - - return (ESteamApiKeyState.Registered, text); - } - - private async Task IsProfileUri(Uri uri, bool waitForInitialization = true) { - if (uri == null) { - throw new ArgumentNullException(nameof(uri)); - } - - string? profileURL = await GetAbsoluteProfileURL(waitForInitialization).ConfigureAwait(false); - - if (string.IsNullOrEmpty(profileURL)) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return false; - } - - return uri.AbsolutePath.Equals(profileURL, StringComparison.OrdinalIgnoreCase); - } - - private async Task IsSessionExpired() { - DateTime triggeredAt = DateTime.UtcNow; - - if (triggeredAt <= LastSessionCheck) { - return LastSessionCheck != LastSessionRefresh; - } - - await SessionSemaphore.WaitAsync().ConfigureAwait(false); + loyaltyRewardsService.Timeout = WebBrowser.Timeout; try { - if (triggeredAt <= LastSessionCheck) { - return LastSessionCheck != LastSessionRefresh; + response = await WebLimitRequest( + WebAPI.DefaultBaseAddress, + + // ReSharper disable once AccessToDisposedClosure + async () => await loyaltyRewardsService.CallAsync(HttpMethod.Get, "GetSummary", args: arguments).ConfigureAwait(false) + ).ConfigureAwait(false); + } catch (TaskCanceledException e) { + Bot.ArchiLogger.LogGenericDebuggingException(e); + } catch (Exception e) { + Bot.ArchiLogger.LogGenericWarningException(e); + } + } + + if (response == null) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + + return null; + } + + KeyValue pointsInfo = response["summary"]["points"]; + + if (pointsInfo == KeyValue.Invalid) { + Bot.ArchiLogger.LogNullError(nameof(pointsInfo)); + + return null; + } + + uint result = pointsInfo.AsUnsignedInteger(uint.MaxValue); + + if (result == uint.MaxValue) { + Bot.ArchiLogger.LogNullError(nameof(result)); + + return null; + } + + return result; + } + + [PublicAPI] + public async Task HasValidApiKey() { + (bool success, string? steamApiKey) = await CachedApiKey.GetValue().ConfigureAwait(false); + + return success ? !string.IsNullOrEmpty(steamApiKey) : null; + } + + [PublicAPI] + public async Task JoinGroup(ulong groupID) { + if ((groupID == 0) || !new SteamID(groupID).IsClanAccount) { + throw new ArgumentOutOfRangeException(nameof(groupID)); + } + + Uri request = new(SteamCommunityURL, $"/gid/{groupID}"); + + // Extra entry for sessionID + Dictionary data = new(2, StringComparer.Ordinal) { { "action", "join" } }; + + return await UrlPostWithSession(request, data: data, session: ESession.CamelCase).ConfigureAwait(false); + } + + [PublicAPI] + public async Task<(bool Success, HashSet? MobileTradeOfferIDs)> SendTradeOffer(ulong steamID, IReadOnlyCollection? itemsToGive = null, IReadOnlyCollection? itemsToReceive = null, string? token = null, bool forcedSingleOffer = false, ushort itemsPerTrade = Trading.MaxItemsPerTrade) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (((itemsToGive == null) || (itemsToGive.Count == 0)) && ((itemsToReceive == null) || (itemsToReceive.Count == 0))) { + throw new ArgumentException($"{nameof(itemsToGive)} && {nameof(itemsToReceive)}"); + } + + if (itemsPerTrade <= 2) { + throw new ArgumentOutOfRangeException(nameof(itemsPerTrade)); + } + + TradeOfferSendRequest singleTrade = new(); + HashSet trades = new() { singleTrade }; + + if (itemsToGive != null) { + foreach (Asset itemToGive in itemsToGive) { + if (!forcedSingleOffer && (singleTrade.ItemsToGive.Assets.Count + singleTrade.ItemsToReceive.Assets.Count >= itemsPerTrade)) { + if (trades.Count >= Trading.MaxTradesPerAccount) { + break; + } + + singleTrade = new TradeOfferSendRequest(); + trades.Add(singleTrade); } - // Choosing proper URL to check against is actually much harder than it initially looks like, we must abide by several rules to make this function as lightweight and reliable as possible - // We should prefer to use Steam store, as the community is much more unstable and broken, plus majority of our requests get there anyway, so load-balancing with store makes much more sense. It also has a higher priority than the community, so all eventual issues should be fixed there first - // The URL must be fast enough to render, as this function will be called reasonably often, and every extra delay adds up. We're already making our best effort by using HEAD request, but the URL itself plays a very important role as well - // The page should have as little internal dependencies as possible, since every extra chunk increases likelihood of broken functionality. We can only make a guess here based on the amount of content that the page returns to us - // It should also be URL with fairly fixed address that isn't going to disappear anytime soon, preferably something staple that is a dependency of other requests, so it's very unlikely to change in a way that would add overhead in the future - // Lastly, it should be a request that is preferably generic enough as a routine check, not something specialized and targetted, to make it very clear that we're just checking if session is up, and to further aid internal dependencies specified above by rendering as general Steam info as possible - Uri request = new(SteamStoreURL, "/account"); + singleTrade.ItemsToGive.Assets.Add(itemToGive); + } + } - BasicResponse? response = await WebLimitRequest(SteamStoreURL, async () => await WebBrowser.UrlHead(request).ConfigureAwait(false)).ConfigureAwait(false); + if (itemsToReceive != null) { + foreach (Asset itemToReceive in itemsToReceive) { + if (!forcedSingleOffer && (singleTrade.ItemsToGive.Assets.Count + singleTrade.ItemsToReceive.Assets.Count >= itemsPerTrade)) { + if (trades.Count >= Trading.MaxTradesPerAccount) { + break; + } + + singleTrade = new TradeOfferSendRequest(); + trades.Add(singleTrade); + } + + singleTrade.ItemsToReceive.Assets.Add(itemToReceive); + } + } + + Uri request = new(SteamCommunityURL, "/tradeoffer/new/send"); + Uri referer = new(SteamCommunityURL, "/tradeoffer/new"); + + // Extra entry for sessionID + Dictionary data = new(6, StringComparer.Ordinal) { + { "partner", steamID.ToString(CultureInfo.InvariantCulture) }, + { "serverid", "1" }, + { "trade_offer_create_params", !string.IsNullOrEmpty(token) ? new JObject { { "trade_offer_access_token", token } }.ToString(Formatting.None) : "" }, + { "tradeoffermessage", $"Sent by {SharedInfo.PublicIdentifier}/{SharedInfo.Version}" } + }; + + HashSet mobileTradeOfferIDs = new(); + + foreach (TradeOfferSendRequest trade in trades) { + data["json_tradeoffer"] = JsonConvert.SerializeObject(trade); + + ObjectResponse? response = null; + + for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { + response = await UrlPostToJsonObjectWithSession(request, data: data, referer: referer, requestOptions: WebBrowser.ERequestOptions.ReturnServerErrors).ConfigureAwait(false); if (response == null) { - return null; + return (false, mobileTradeOfferIDs); } - bool result = IsSessionExpiredUri(response.FinalUri); + if (response.StatusCode.IsServerErrorCode()) { + if (string.IsNullOrEmpty(response.Content.ErrorText)) { + // This is a generic server error without a reason, try again + response = null; - DateTime now = DateTime.UtcNow; - - if (result) { - Initialized = false; - } else { - LastSessionRefresh = now; - } - - LastSessionCheck = now; - - return result; - } finally { - SessionSemaphore.Release(); - } - } - - private static bool IsSessionExpiredUri(Uri uri) { - if (uri == null) { - throw new ArgumentNullException(nameof(uri)); - } - - return uri.AbsolutePath.StartsWith("/login", StringComparison.OrdinalIgnoreCase) || uri.Host.Equals("lostauth", StringComparison.OrdinalIgnoreCase); - } - - private static bool ParseItems(IReadOnlyDictionary<(uint AppID, ulong ClassID, ulong InstanceID), InventoryResponse.Description> descriptions, IReadOnlyCollection input, ICollection output) { - if (descriptions == null) { - throw new ArgumentNullException(nameof(descriptions)); - } - - if ((input == null) || (input.Count == 0)) { - throw new ArgumentNullException(nameof(input)); - } - - if (output == null) { - throw new ArgumentNullException(nameof(output)); - } - - foreach (KeyValue item in input) { - uint appID = item["appid"].AsUnsignedInteger(); - - if (appID == 0) { - ASF.ArchiLogger.LogNullError(nameof(appID)); - - return false; - } - - ulong contextID = item["contextid"].AsUnsignedLong(); - - if (contextID == 0) { - ASF.ArchiLogger.LogNullError(nameof(contextID)); - - return false; - } - - ulong classID = item["classid"].AsUnsignedLong(); - - if (classID == 0) { - ASF.ArchiLogger.LogNullError(nameof(classID)); - - return false; - } - - ulong instanceID = item["instanceid"].AsUnsignedLong(); - - (uint AppID, ulong ClassID, ulong InstanceID) key = (appID, classID, instanceID); - - uint amount = item["amount"].AsUnsignedInteger(); - - if (amount == 0) { - ASF.ArchiLogger.LogNullError(nameof(amount)); - - return false; - } - - ulong assetID = item["assetid"].AsUnsignedLong(); - - bool marketable = true; - bool tradable = true; - ImmutableHashSet? tags = null; - uint realAppID = 0; - Asset.EType type = Asset.EType.Unknown; - Asset.ERarity rarity = Asset.ERarity.Unknown; - - if (descriptions.TryGetValue(key, out InventoryResponse.Description? description)) { - marketable = description.Marketable; - tradable = description.Tradable; - tags = description.Tags; - realAppID = description.RealAppID; - type = description.Type; - rarity = description.Rarity; - } - - Asset steamAsset = new(appID, contextID, classID, amount, instanceID, assetID, marketable, tradable, tags, realAppID, type, rarity); - output.Add(steamAsset); - } - - return true; - } - - private async Task RefreshSession() { - if (!Bot.IsConnectedAndLoggedOn) { - return false; - } - - DateTime triggeredAt = DateTime.UtcNow; - - if (triggeredAt <= LastSessionCheck) { - return LastSessionCheck == LastSessionRefresh; - } - - await SessionSemaphore.WaitAsync().ConfigureAwait(false); - - try { - if (triggeredAt <= LastSessionCheck) { - return LastSessionCheck == LastSessionRefresh; - } - - Initialized = false; - - if (!Bot.IsConnectedAndLoggedOn) { - return false; - } - - Bot.ArchiLogger.LogGenericInfo(Strings.RefreshingOurSession); - bool result = await Bot.RefreshSession().ConfigureAwait(false); - - DateTime now = DateTime.UtcNow; - - if (result) { - LastSessionRefresh = now; - } - - LastSessionCheck = now; - - return result; - } finally { - SessionSemaphore.Release(); - } - } - - private async Task RegisterApiKey() { - Uri request = new(SteamCommunityURL, "/dev/registerkey"); - - // Extra entry for sessionID - Dictionary data = new(4, StringComparer.Ordinal) { - { "agreeToTerms", "agreed" }, -#pragma warning disable CA1308 // False positive, we're intentionally converting this part to lowercase and it's not used for any security decisions based on the result of the normalization - { "domain", $"generated.by.{SharedInfo.AssemblyName.ToLowerInvariant()}.localhost" }, -#pragma warning restore CA1308 // False positive, we're intentionally converting this part to lowercase and it's not used for any security decisions based on the result of the normalization - { "Submit", "Register" } - }; - - return await UrlPostWithSession(request, data: data).ConfigureAwait(false); - } - - private async Task<(bool Success, string? Result)> ResolveAccessToken() { - Uri request = new(SteamStoreURL, "/pointssummary/ajaxgetasyncconfig"); - - ObjectResponse? response = await UrlGetToJsonObjectWithSession(request).ConfigureAwait(false); - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - return !string.IsNullOrEmpty(response?.Content.Data.WebAPIToken) ? (true, response!.Content.Data.WebAPIToken) : (false, null); - } - - private async Task<(bool Success, string? Result)> ResolveApiKey() { - if (Bot.IsAccountLimited) { - // API key is permanently unavailable for limited accounts - return (true, null); - } - - (ESteamApiKeyState State, string? Key) result = await GetApiKeyState().ConfigureAwait(false); - - switch (result.State) { - case ESteamApiKeyState.AccessDenied: - // We succeeded in fetching API key, but it resulted in access denied - // Return empty result, API key is unavailable permanently - return (true, ""); - case ESteamApiKeyState.NotRegisteredYet: - // We succeeded in fetching API key, and it resulted in no key registered yet - // Let's try to register a new key - if (!await RegisterApiKey().ConfigureAwait(false)) { - // Request timed out, bad luck, we'll try again later - goto case ESteamApiKeyState.Timeout; + continue; } - // We should have the key ready, so let's fetch it again - result = await GetApiKeyState().ConfigureAwait(false); + // This is actually client error with a reason, so it doesn't make sense to retry + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.Content.ErrorText)); - if (result.State == ESteamApiKeyState.Timeout) { - // Request timed out, bad luck, we'll try again later - goto case ESteamApiKeyState.Timeout; - } + return (false, mobileTradeOfferIDs); + } + } - if (result.State != ESteamApiKeyState.Registered) { - // Something went wrong, report error - goto default; - } + if (response == null) { + return (false, mobileTradeOfferIDs); + } - goto case ESteamApiKeyState.Registered; - case ESteamApiKeyState.Registered: - // We succeeded in fetching API key, and it resulted in registered key - // Cache the result, this is the API key we want - return (true, result.Key); - case ESteamApiKeyState.Timeout: - // Request timed out, bad luck, we'll try again later - return (false, null); - default: - // We got an unhandled error, this should never happen - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(result.State), result.State)); + if (response.Content.TradeOfferID == 0) { + Bot.ArchiLogger.LogNullError(nameof(response.Content.TradeOfferID)); - return (false, null); + return (false, mobileTradeOfferIDs); + } + + if (response.Content.RequiresMobileConfirmation) { + mobileTradeOfferIDs.Add(response.Content.TradeOfferID); } } - private async Task UnlockParentalAccount(string parentalCode) { - if (string.IsNullOrEmpty(parentalCode)) { - throw new ArgumentNullException(nameof(parentalCode)); - } + return (true, mobileTradeOfferIDs); + } - Bot.ArchiLogger.LogGenericInfo(Strings.UnlockingParentalAccount); + [PublicAPI] + public async Task UrlGetToHtmlDocumentWithSession(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } - bool[] results = await Task.WhenAll(UnlockParentalAccountForService(SteamCommunityURL, parentalCode), UnlockParentalAccountForService(SteamStoreURL, parentalCode)).ConfigureAwait(false); + if (maxTries == 0) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + + if (checkSessionPreemptively) { + // Check session preemptively as this request might not get redirected to expiration + bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); + + if (sessionExpired.GetValueOrDefault(true)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlGetToHtmlDocumentWithSession(request, headers, referer, requestOptions, true, --maxTries).ConfigureAwait(false); + } - if (results.Any(static result => !result)) { Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - return false; + return null; } - - Bot.ArchiLogger.LogGenericInfo(Strings.Success); - - return true; + } else { + // If session refresh is already in progress, just wait for it + await SessionSemaphore.WaitAsync().ConfigureAwait(false); + SessionSemaphore.Release(); } - private async Task UnlockParentalAccountForService(Uri service, string parentalCode, byte maxTries = WebBrowser.MaxTries) { - if (service == null) { - throw new ArgumentNullException(nameof(service)); + if (!Initialized) { + byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; + + for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { + await Task.Delay(1000).ConfigureAwait(false); } - if (string.IsNullOrEmpty(parentalCode)) { - throw new ArgumentNullException(nameof(parentalCode)); + if (!Initialized) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + } + + Uri host = new(request.GetLeftPart(UriPartial.Authority)); + + HtmlDocumentResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlGetToHtmlDocument(request, headers, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); + + if (response == null) { + return null; + } + + if (IsSessionExpiredUri(response.FinalUri)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlGetToHtmlDocumentWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); } - Uri request = new(service, "/parental/ajaxunlock"); + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - if (maxTries == 0) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + return null; + } + + // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case + if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); + + return await UrlGetToHtmlDocumentWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); + } + + return response; + } + + [PublicAPI] + public async Task?> UrlGetToJsonObjectWithSession(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (maxTries == 0) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return default(ObjectResponse?); + } + + if (checkSessionPreemptively) { + // Check session preemptively as this request might not get redirected to expiration + bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); + + if (sessionExpired.GetValueOrDefault(true)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlGetToJsonObjectWithSession(request, headers, referer, requestOptions, true, --maxTries).ConfigureAwait(false); + } + + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + } else { + // If session refresh is already in progress, just wait for it + await SessionSemaphore.WaitAsync().ConfigureAwait(false); + SessionSemaphore.Release(); + } + + if (!Initialized) { + byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; + + for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { + await Task.Delay(1000).ConfigureAwait(false); + } + + if (!Initialized) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return default(ObjectResponse?); + } + } + + Uri host = new(request.GetLeftPart(UriPartial.Authority)); + + ObjectResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlGetToJsonObject(request, headers, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); + + if (response == null) { + return default(ObjectResponse?); + } + + if (IsSessionExpiredUri(response.FinalUri)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlGetToJsonObjectWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); + } + + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + + // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case + if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); + + return await UrlGetToJsonObjectWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); + } + + return response; + } + + [Obsolete("ASF no longer uses any XML-related functions, re-implement it yourself if needed.")] + [PublicAPI] + public async Task UrlGetToXmlDocumentWithSession(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (maxTries == 0) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + + if (checkSessionPreemptively) { + // Check session preemptively as this request might not get redirected to expiration + bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); + + if (sessionExpired.GetValueOrDefault(true)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlGetToXmlDocumentWithSession(request, headers, referer, requestOptions, true, --maxTries).ConfigureAwait(false); + } + + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + } else { + // If session refresh is already in progress, just wait for it + await SessionSemaphore.WaitAsync().ConfigureAwait(false); + SessionSemaphore.Release(); + } + + if (!Initialized) { + byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; + + for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { + await Task.Delay(1000).ConfigureAwait(false); + } + + if (!Initialized) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + } + + Uri host = new(request.GetLeftPart(UriPartial.Authority)); + + XmlDocumentResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlGetToXmlDocument(request, headers, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); + + if (response == null) { + return null; + } + + if (IsSessionExpiredUri(response.FinalUri)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlGetToXmlDocumentWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); + } + + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + + // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case + if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); + + return await UrlGetToXmlDocumentWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); + } + + return response; + } + + [PublicAPI] + public async Task UrlHeadWithSession(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (maxTries == 0) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return false; + } + + if (checkSessionPreemptively) { + // Check session preemptively as this request might not get redirected to expiration + bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); + + if (sessionExpired.GetValueOrDefault(true)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlHeadWithSession(request, headers, referer, requestOptions, true, --maxTries).ConfigureAwait(false); + } + + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); return false; } + } else { + // If session refresh is already in progress, just wait for it + await SessionSemaphore.WaitAsync().ConfigureAwait(false); + SessionSemaphore.Release(); + } - string? sessionID = WebBrowser.CookieContainer.GetCookieValue(service, "sessionid"); + if (!Initialized) { + byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; + + for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { + await Task.Delay(1000).ConfigureAwait(false); + } + + if (!Initialized) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return false; + } + } + + Uri host = new(request.GetLeftPart(UriPartial.Authority)); + + BasicResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlHead(request, headers, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); + + if (response == null) { + return false; + } + + if (IsSessionExpiredUri(response.FinalUri)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlHeadWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); + } + + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return false; + } + + // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case + if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); + + return await UrlHeadWithSession(request, headers, referer, requestOptions, checkSessionPreemptively, --maxTries).ConfigureAwait(false); + } + + return true; + } + + [PublicAPI] + public async Task UrlPostToHtmlDocumentWithSession(Uri request, IReadOnlyCollection>? headers = null, IDictionary? data = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, ESession session = ESession.Lowercase, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (!Enum.IsDefined(typeof(ESession), session)) { + throw new InvalidEnumArgumentException(nameof(session), (int) session, typeof(ESession)); + } + + if (maxTries == 0) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + + if (checkSessionPreemptively) { + // Check session preemptively as this request might not get redirected to expiration + bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); + + if (sessionExpired.GetValueOrDefault(true)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlPostToHtmlDocumentWithSession(request, headers, data, referer, requestOptions, session, true, --maxTries).ConfigureAwait(false); + } + + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + } else { + // If session refresh is already in progress, just wait for it + await SessionSemaphore.WaitAsync().ConfigureAwait(false); + SessionSemaphore.Release(); + } + + if (!Initialized) { + byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; + + for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { + await Task.Delay(1000).ConfigureAwait(false); + } + + if (!Initialized) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + } + + Uri host = new(request.GetLeftPart(UriPartial.Authority)); + + if (session != ESession.None) { + string? sessionID = WebBrowser.CookieContainer.GetCookieValue(host, "sessionid"); + + if (string.IsNullOrEmpty(sessionID)) { + Bot.ArchiLogger.LogNullError(nameof(sessionID)); + + return null; + } + + string sessionName = session switch { + ESession.CamelCase => "sessionID", + ESession.Lowercase => "sessionid", + ESession.PascalCase => "SessionID", + _ => throw new ArgumentOutOfRangeException(nameof(session)) + }; + + if (data != null) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + data[sessionName] = sessionID!; + } else { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + data = new Dictionary(1, StringComparer.Ordinal) { { sessionName, sessionID! } }; + } + } + + HtmlDocumentResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlPostToHtmlDocument(request, headers, data, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); + + if (response == null) { + return null; + } + + if (IsSessionExpiredUri(response.FinalUri)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlPostToHtmlDocumentWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); + } + + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + + // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case + if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); + + return await UrlPostToHtmlDocumentWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); + } + + return response; + } + + [PublicAPI] + public async Task?> UrlPostToJsonObjectWithSession(Uri request, IReadOnlyCollection>? headers = null, IDictionary? data = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, ESession session = ESession.Lowercase, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (!Enum.IsDefined(typeof(ESession), session)) { + throw new InvalidEnumArgumentException(nameof(session), (int) session, typeof(ESession)); + } + + if (maxTries == 0) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + + if (checkSessionPreemptively) { + // Check session preemptively as this request might not get redirected to expiration + bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); + + if (sessionExpired.GetValueOrDefault(true)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlPostToJsonObjectWithSession(request, headers, data, referer, requestOptions, session, true, --maxTries).ConfigureAwait(false); + } + + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + } else { + // If session refresh is already in progress, just wait for it + await SessionSemaphore.WaitAsync().ConfigureAwait(false); + SessionSemaphore.Release(); + } + + if (!Initialized) { + byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; + + for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { + await Task.Delay(1000).ConfigureAwait(false); + } + + if (!Initialized) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + } + + Uri host = new(request.GetLeftPart(UriPartial.Authority)); + + if (session != ESession.None) { + string? sessionID = WebBrowser.CookieContainer.GetCookieValue(host, "sessionid"); + + if (string.IsNullOrEmpty(sessionID)) { + Bot.ArchiLogger.LogNullError(nameof(sessionID)); + + return null; + } + + string sessionName = session switch { + ESession.CamelCase => "sessionID", + ESession.Lowercase => "sessionid", + ESession.PascalCase => "SessionID", + _ => throw new ArgumentOutOfRangeException(nameof(session)) + }; + + if (data != null) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + data[sessionName] = sessionID!; + } else { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + data = new Dictionary(1, StringComparer.Ordinal) { { sessionName, sessionID! } }; + } + } + + ObjectResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlPostToJsonObject>(request, headers, data, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); + + if (response == null) { + return null; + } + + if (IsSessionExpiredUri(response.FinalUri)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlPostToJsonObjectWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); + } + + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + + // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case + if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); + + return await UrlPostToJsonObjectWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); + } + + return response; + } + + [PublicAPI] + public async Task?> UrlPostToJsonObjectWithSession(Uri request, IReadOnlyCollection>? headers = null, ICollection>? data = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, ESession session = ESession.Lowercase, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (!Enum.IsDefined(typeof(ESession), session)) { + throw new InvalidEnumArgumentException(nameof(session), (int) session, typeof(ESession)); + } + + if (maxTries == 0) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + + if (checkSessionPreemptively) { + // Check session preemptively as this request might not get redirected to expiration + bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); + + if (sessionExpired.GetValueOrDefault(true)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlPostToJsonObjectWithSession(request, headers, data, referer, requestOptions, session, true, --maxTries).ConfigureAwait(false); + } + + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + } else { + // If session refresh is already in progress, just wait for it + await SessionSemaphore.WaitAsync().ConfigureAwait(false); + SessionSemaphore.Release(); + } + + if (!Initialized) { + byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; + + for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { + await Task.Delay(1000).ConfigureAwait(false); + } + + if (!Initialized) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + } + + Uri host = new(request.GetLeftPart(UriPartial.Authority)); + + if (session != ESession.None) { + string? sessionID = WebBrowser.CookieContainer.GetCookieValue(host, "sessionid"); + + if (string.IsNullOrEmpty(sessionID)) { + Bot.ArchiLogger.LogNullError(nameof(sessionID)); + + return null; + } + + string sessionName = session switch { + ESession.CamelCase => "sessionID", + ESession.Lowercase => "sessionid", + ESession.PascalCase => "SessionID", + _ => throw new ArgumentOutOfRangeException(nameof(session)) + }; + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + KeyValuePair sessionValue = new(sessionName, sessionID!); + + if (data != null) { + data.Remove(sessionValue); + data.Add(sessionValue); + } else { + data = new List>(1) { sessionValue }; + } + } + + ObjectResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlPostToJsonObject>>(request, headers, data, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); + + if (response == null) { + return null; + } + + if (IsSessionExpiredUri(response.FinalUri)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlPostToJsonObjectWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); + } + + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return null; + } + + // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case + if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); + + return await UrlPostToJsonObjectWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); + } + + return response; + } + + [PublicAPI] + public async Task UrlPostWithSession(Uri request, IReadOnlyCollection>? headers = null, IDictionary? data = null, Uri? referer = null, WebBrowser.ERequestOptions requestOptions = WebBrowser.ERequestOptions.None, ESession session = ESession.Lowercase, bool checkSessionPreemptively = true, byte maxTries = WebBrowser.MaxTries) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (!Enum.IsDefined(typeof(ESession), session)) { + throw new InvalidEnumArgumentException(nameof(session), (int) session, typeof(ESession)); + } + + if (maxTries == 0) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return false; + } + + if (checkSessionPreemptively) { + // Check session preemptively as this request might not get redirected to expiration + bool? sessionExpired = await IsSessionExpired().ConfigureAwait(false); + + if (sessionExpired.GetValueOrDefault(true)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlPostWithSession(request, headers, data, referer, requestOptions, session, true, --maxTries).ConfigureAwait(false); + } + + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return false; + } + } else { + // If session refresh is already in progress, just wait for it + await SessionSemaphore.WaitAsync().ConfigureAwait(false); + SessionSemaphore.Release(); + } + + if (!Initialized) { + byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; + + for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { + await Task.Delay(1000).ConfigureAwait(false); + } + + if (!Initialized) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return false; + } + } + + Uri host = new(request.GetLeftPart(UriPartial.Authority)); + + if (session != ESession.None) { + string? sessionID = WebBrowser.CookieContainer.GetCookieValue(host, "sessionid"); if (string.IsNullOrEmpty(sessionID)) { Bot.ArchiLogger.LogNullError(nameof(sessionID)); @@ -2718,44 +1129,1633 @@ namespace ArchiSteamFarm.Steam.Integration { return false; } - Dictionary data = new(2, StringComparer.Ordinal) { - { "pin", parentalCode }, - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - { "sessionid", sessionID! } + string sessionName = session switch { + ESession.CamelCase => "sessionID", + ESession.Lowercase => "sessionid", + ESession.PascalCase => "SessionID", + _ => throw new ArgumentOutOfRangeException(nameof(session)) }; - // This request doesn't go through UrlPostRetryWithSession as we have no access to session refresh capability (this is in fact session initialization) - BasicResponse? response = await WebLimitRequest(service, async () => await WebBrowser.UrlPost(request, data: data, referer: service).ConfigureAwait(false)).ConfigureAwait(false); + if (data != null) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + data[sessionName] = sessionID!; + } else { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + data = new Dictionary(1, StringComparer.Ordinal) { { sessionName, sessionID! } }; + } + } + + BasicResponse? response = await WebLimitRequest(host, async () => await WebBrowser.UrlPost(request, headers, data, referer, requestOptions).ConfigureAwait(false)).ConfigureAwait(false); + + if (response == null) { + return false; + } + + if (IsSessionExpiredUri(response.FinalUri)) { + if (await RefreshSession().ConfigureAwait(false)) { + return await UrlPostWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); + } + + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return false; + } + + // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case + if (await IsProfileUri(response.FinalUri).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); + + return await UrlPostWithSession(request, headers, data, referer, requestOptions, session, checkSessionPreemptively, --maxTries).ConfigureAwait(false); + } + + return true; + } + + [PublicAPI] + public static async Task WebLimitRequest(Uri service, Func> function) { + if (service == null) { + throw new ArgumentNullException(nameof(service)); + } + + if (function == null) { + throw new ArgumentNullException(nameof(function)); + } + + if (ASF.RateLimitingSemaphore == null) { + throw new InvalidOperationException(nameof(ASF.RateLimitingSemaphore)); + } + + if (ASF.WebLimitingSemaphores == null) { + throw new InvalidOperationException(nameof(ASF.WebLimitingSemaphores)); + } + + ushort webLimiterDelay = ASF.GlobalConfig?.WebLimiterDelay ?? GlobalConfig.DefaultWebLimiterDelay; + + if (webLimiterDelay == 0) { + return await function().ConfigureAwait(false); + } + + if (!ASF.WebLimitingSemaphores.TryGetValue(service, out (ICrossProcessSemaphore RateLimitingSemaphore, SemaphoreSlim OpenConnectionsSemaphore) limiters)) { + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(service), service)); + + limiters.RateLimitingSemaphore = ASF.RateLimitingSemaphore; + } + + // Sending a request opens a new connection + await limiters.OpenConnectionsSemaphore.WaitAsync().ConfigureAwait(false); + + try { + // It also increases number of requests + await limiters.RateLimitingSemaphore.WaitAsync().ConfigureAwait(false); + + // We release rate-limiter semaphore regardless of our task completion, since we use that one only to guarantee rate-limiting of their creation + Utilities.InBackground( + async () => { + await Task.Delay(webLimiterDelay).ConfigureAwait(false); + limiters.RateLimitingSemaphore.Release(); + } + ); + + return await function().ConfigureAwait(false); + } finally { + // We release open connections semaphore only once we're indeed done sending a particular request + limiters.OpenConnectionsSemaphore.Release(); + } + } + + internal async Task AcceptDigitalGiftCard(ulong giftCardID) { + if (giftCardID == 0) { + throw new ArgumentOutOfRangeException(nameof(giftCardID)); + } + + Uri request = new(SteamStoreURL, "/gifts/0/resolvegiftcard"); + + // Extra entry for sessionID + Dictionary data = new(3, StringComparer.Ordinal) { + { "accept", "1" }, + { "giftcardid", giftCardID.ToString(CultureInfo.InvariantCulture) } + }; + + ObjectResponse? response = await UrlPostToJsonObjectWithSession(request, data: data).ConfigureAwait(false); + + if (response == null) { + return false; + } + + if (response.Content.Result != EResult.OK) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + + return false; + } + + return true; + } + + internal async Task<(bool Success, bool RequiresMobileConfirmation)> AcceptTradeOffer(ulong tradeID) { + if (tradeID == 0) { + throw new ArgumentOutOfRangeException(nameof(tradeID)); + } + + Uri request = new(SteamCommunityURL, $"/tradeoffer/{tradeID}/accept"); + Uri referer = new(SteamCommunityURL, $"/tradeoffer/{tradeID}"); + + // Extra entry for sessionID + Dictionary data = new(3, StringComparer.Ordinal) { + { "serverid", "1" }, + { "tradeofferid", tradeID.ToString(CultureInfo.InvariantCulture) } + }; + + ObjectResponse? response = null; + + for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { + response = await UrlPostToJsonObjectWithSession(request, data: data, referer: referer, requestOptions: WebBrowser.ERequestOptions.ReturnServerErrors).ConfigureAwait(false); + + if (response == null) { + return (false, false); + } + + if (response.StatusCode.IsServerErrorCode()) { + if (string.IsNullOrEmpty(response.Content.ErrorText)) { + // This is a generic server error without a reason, try again + response = null; + + continue; + } + + // This is actually client error with a reason, so it doesn't make sense to retry + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.Content.ErrorText)); + + return (false, false); + } + } + + return response != null ? (true, response.Content.RequiresMobileConfirmation) : (false, false); + } + + internal async Task<(EResult Result, EPurchaseResultDetail PurchaseResult)> AddFreeLicense(uint subID) { + if (subID == 0) { + throw new ArgumentOutOfRangeException(nameof(subID)); + } + + Uri request = new(SteamStoreURL, $"/checkout/addfreelicense/{subID}"); + + // Extra entry for sessionID + Dictionary data = new(2, StringComparer.Ordinal) { + { "ajax", "true" } + }; + + ObjectResponse? response = await UrlPostToJsonObjectWithSession(request, data: data, requestOptions: WebBrowser.ERequestOptions.ReturnClientErrors | WebBrowser.ERequestOptions.ReturnServerErrors).ConfigureAwait(false); + + if (response == null) { + return (EResult.Fail, EPurchaseResultDetail.Timeout); + } + + switch (response.StatusCode) { + case HttpStatusCode.Forbidden: + // Let's convert this into something reasonable + return (EResult.AccessDenied, EPurchaseResultDetail.InvalidPackage); + case HttpStatusCode.InternalServerError: + case HttpStatusCode.OK: + // This API is total nuts, it returns sometimes [ ], sometimes { "purchaseresultdetail": int } and sometimes null because f**k you, that's why, I wouldn't be surprised if it returned XML one day + // There is not much we can do apart from trying to extract the result and returning it along with the OK and non-OK response, it's also why it doesn't make any sense to strong-type it + EPurchaseResultDetail purchaseResult = EPurchaseResultDetail.NoDetail; + + if (response.Content is JObject jObject) { + byte? numberResult = jObject["purchaseresultdetail"]?.Value(); + + if (numberResult.HasValue) { + purchaseResult = (EPurchaseResultDetail) numberResult.Value; + } + } + + return (response.StatusCode.IsSuccessCode() ? EResult.OK : EResult.Fail, purchaseResult); + case HttpStatusCode.Unauthorized: + // Let's convert this into something reasonable + return (EResult.AccessDenied, EPurchaseResultDetail.NoDetail); + default: + // We should handle all expected status codes above, this is a generic fallback for those that we don't + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(response.StatusCode), response.StatusCode)); + + return (response.StatusCode.IsSuccessCode() ? EResult.OK : EResult.Fail, EPurchaseResultDetail.ContactSupport); + } + } + + internal async Task ChangePrivacySettings(UserPrivacy userPrivacy) { + if (userPrivacy == null) { + throw new ArgumentNullException(nameof(userPrivacy)); + } + + string? profileURL = await GetAbsoluteProfileURL().ConfigureAwait(false); + + if (string.IsNullOrEmpty(profileURL)) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + + return false; + } + + Uri request = new(SteamCommunityURL, $"{profileURL}/ajaxsetprivacy"); + + // Extra entry for sessionID + Dictionary data = new(3, StringComparer.Ordinal) { + { "eCommentPermission", ((byte) userPrivacy.CommentPermission).ToString(CultureInfo.InvariantCulture) }, + { "Privacy", JsonConvert.SerializeObject(userPrivacy.Settings) } + }; + + ObjectResponse? response = await UrlPostToJsonObjectWithSession(request, data: data).ConfigureAwait(false); + + if (response == null) { + return false; + } + + if (response.Content.Result != EResult.OK) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + + return false; + } + + return true; + } + + internal async Task ClearFromDiscoveryQueue(uint appID) { + if (appID == 0) { + throw new ArgumentOutOfRangeException(nameof(appID)); + } + + Uri request = new(SteamStoreURL, $"/app/{appID}"); + + // Extra entry for sessionID + Dictionary data = new(2, StringComparer.Ordinal) { { "appid_to_clear_from_queue", appID.ToString(CultureInfo.InvariantCulture) } }; + + return await UrlPostWithSession(request, data: data).ConfigureAwait(false); + } + + internal async Task DeclineTradeOffer(ulong tradeID) { + if (tradeID == 0) { + throw new ArgumentOutOfRangeException(nameof(tradeID)); + } + + (bool success, string? steamApiKey) = await CachedApiKey.GetValue().ConfigureAwait(false); + + if (!success || string.IsNullOrEmpty(steamApiKey)) { + return false; + } + + // Extra entry for format + Dictionary arguments = new(3, StringComparer.Ordinal) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + { "key", steamApiKey! }, + + { "tradeofferid", tradeID } + }; + + KeyValue? response = null; + + for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { + using WebAPI.AsyncInterface econService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(EconService); + + econService.Timeout = WebBrowser.Timeout; + + try { + response = await WebLimitRequest( + WebAPI.DefaultBaseAddress, + + // ReSharper disable once AccessToDisposedClosure + async () => await econService.CallAsync(HttpMethod.Post, "DeclineTradeOffer", args: arguments).ConfigureAwait(false) + ).ConfigureAwait(false); + } catch (TaskCanceledException e) { + Bot.ArchiLogger.LogGenericDebuggingException(e); + } catch (Exception e) { + Bot.ArchiLogger.LogGenericWarningException(e); + } + } + + if (response == null) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + + return false; + } + + return true; + } + + internal HttpClient GenerateDisposableHttpClient() => WebBrowser.GenerateDisposableHttpClient(); + + internal async Task?> GenerateNewDiscoveryQueue() { + Uri request = new(SteamStoreURL, "/explore/generatenewdiscoveryqueue"); + + // Extra entry for sessionID + Dictionary data = new(2, StringComparer.Ordinal) { { "queuetype", "0" } }; + + ObjectResponse? response = await UrlPostToJsonObjectWithSession(request, data: data).ConfigureAwait(false); + + return response?.Content.Queue; + } + + internal async Task?> GetActiveTradeOffers() { + (bool success, string? steamApiKey) = await CachedApiKey.GetValue().ConfigureAwait(false); + + if (!success || string.IsNullOrEmpty(steamApiKey)) { + return null; + } + + // Extra entry for format + Dictionary arguments = new(6, StringComparer.Ordinal) { + { "active_only", 1 }, + { "get_descriptions", 1 }, + { "get_received_offers", 1 }, + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + { "key", steamApiKey! }, + + { "time_historical_cutoff", uint.MaxValue } + }; + + KeyValue? response = null; + + for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { + using WebAPI.AsyncInterface econService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(EconService); + + econService.Timeout = WebBrowser.Timeout; + + try { + response = await WebLimitRequest( + WebAPI.DefaultBaseAddress, + + // ReSharper disable once AccessToDisposedClosure + async () => await econService.CallAsync(HttpMethod.Get, "GetTradeOffers", args: arguments).ConfigureAwait(false) + ).ConfigureAwait(false); + } catch (TaskCanceledException e) { + Bot.ArchiLogger.LogGenericDebuggingException(e); + } catch (Exception e) { + Bot.ArchiLogger.LogGenericWarningException(e); + } + } + + if (response == null) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + + return null; + } + + Dictionary<(uint AppID, ulong ClassID, ulong InstanceID), InventoryResponse.Description> descriptions = new(); + + foreach (KeyValue description in response["descriptions"].Children) { + uint appID = description["appid"].AsUnsignedInteger(); + + if (appID == 0) { + Bot.ArchiLogger.LogNullError(nameof(appID)); + + return null; + } + + ulong classID = description["classid"].AsUnsignedLong(); + + if (classID == 0) { + Bot.ArchiLogger.LogNullError(nameof(classID)); + + return null; + } + + ulong instanceID = description["instanceid"].AsUnsignedLong(); + + (uint AppID, ulong ClassID, ulong InstanceID) key = (appID, classID, instanceID); + + if (descriptions.ContainsKey(key)) { + continue; + } + + InventoryResponse.Description parsedDescription = new() { + AppID = appID, + ClassID = classID, + InstanceID = instanceID, + Marketable = description["marketable"].AsBoolean(), + Tradable = true // We're parsing active trade offers, we can assume as much + }; + + List tags = description["tags"].Children; + + if (tags.Count > 0) { + HashSet parsedTags = new(tags.Count); + + foreach (KeyValue tag in tags) { + string? identifier = tag["category"].AsString(); + + if (string.IsNullOrEmpty(identifier)) { + Bot.ArchiLogger.LogNullError(nameof(identifier)); + + return null; + } + + string? value = tag["internal_name"].AsString(); + + // Apparently, name can be empty, but not null + if (value == null) { + Bot.ArchiLogger.LogNullError(nameof(value)); + + return null; + } + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + parsedTags.Add(new Tag(identifier!, value)); + } + + parsedDescription.Tags = parsedTags.ToImmutableHashSet(); + } + + descriptions[key] = parsedDescription; + } + + HashSet result = new(); + + foreach (KeyValue trade in response["trade_offers_received"].Children) { + ETradeOfferState state = trade["trade_offer_state"].AsEnum(); + + if (!Enum.IsDefined(typeof(ETradeOfferState), state)) { + Bot.ArchiLogger.LogNullError(nameof(state)); + + return null; + } + + if (state != ETradeOfferState.Active) { + continue; + } + + ulong tradeOfferID = trade["tradeofferid"].AsUnsignedLong(); + + if (tradeOfferID == 0) { + Bot.ArchiLogger.LogNullError(nameof(tradeOfferID)); + + return null; + } + + uint otherSteamID3 = trade["accountid_other"].AsUnsignedInteger(); + + if (otherSteamID3 == 0) { + Bot.ArchiLogger.LogNullError(nameof(otherSteamID3)); + + return null; + } + + TradeOffer tradeOffer = new(tradeOfferID, otherSteamID3, state); + + List itemsToGive = trade["items_to_give"].Children; + + if (itemsToGive.Count > 0) { + if (!ParseItems(descriptions, itemsToGive, tradeOffer.ItemsToGive)) { + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(itemsToGive))); + + return null; + } + } + + List itemsToReceive = trade["items_to_receive"].Children; + + if (itemsToReceive.Count > 0) { + if (!ParseItems(descriptions, itemsToReceive, tradeOffer.ItemsToReceive)) { + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(itemsToReceive))); + + return null; + } + } + + result.Add(tradeOffer); + } + + return result; + } + + internal async Task?> GetAppList() { + KeyValue? response = null; + + for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { + using WebAPI.AsyncInterface steamAppsService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(SteamAppsService); + + steamAppsService.Timeout = WebBrowser.Timeout; + + try { + response = await WebLimitRequest( + WebAPI.DefaultBaseAddress, + + // ReSharper disable once AccessToDisposedClosure + async () => await steamAppsService.CallAsync(HttpMethod.Get, "GetAppList", 2).ConfigureAwait(false) + ).ConfigureAwait(false); + } catch (TaskCanceledException e) { + Bot.ArchiLogger.LogGenericDebuggingException(e); + } catch (Exception e) { + Bot.ArchiLogger.LogGenericWarningException(e); + } + } + + if (response == null) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + + return null; + } + + List apps = response["apps"].Children; + + if (apps.Count == 0) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(apps))); + + return null; + } + + HashSet result = new(apps.Count); + + foreach (uint appID in apps.Select(static app => app["appid"].AsUnsignedInteger())) { + if (appID == 0) { + Bot.ArchiLogger.LogNullError(nameof(appID)); + + return null; + } + + result.Add(appID); + } + + return result; + } + + internal async Task GetBadgePage(byte page) { + if (page == 0) { + throw new ArgumentOutOfRangeException(nameof(page)); + } + + Uri request = new(SteamCommunityURL, $"/my/badges?l=english&p={page}"); + + HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request, checkSessionPreemptively: false).ConfigureAwait(false); + + return response?.Content; + } + + internal async Task GetCardCountForGame(uint appID) { + if (appID == 0) { + throw new ArgumentOutOfRangeException(nameof(appID)); + } + + if (CachedCardCountsForGame.TryGetValue(appID, out byte result)) { + return result; + } + + using IDocument? htmlDocument = await GetGameCardsPage(appID).ConfigureAwait(false); + + if (htmlDocument == null) { + Bot.ArchiLogger.LogNullError(nameof(htmlDocument)); + + return 0; + } + + IEnumerable htmlNodes = htmlDocument.SelectNodes("//div[@class='badge_card_set_cards']/div[starts-with(@class, 'badge_card_set_card')]"); + + result = (byte) htmlNodes.Count(); + + if (result == 0) { + Bot.ArchiLogger.LogNullError(nameof(result)); + + return 0; + } + + CachedCardCountsForGame.TryAdd(appID, result); + + return result; + } + + internal async Task GetConfirmationsPage(string deviceID, string confirmationHash, uint time) { + if (string.IsNullOrEmpty(deviceID)) { + throw new ArgumentNullException(nameof(deviceID)); + } + + if (string.IsNullOrEmpty(confirmationHash)) { + throw new ArgumentNullException(nameof(confirmationHash)); + } + + if (time == 0) { + throw new ArgumentOutOfRangeException(nameof(time)); + } + + if (!Initialized) { + byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; + + for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { + await Task.Delay(1000).ConfigureAwait(false); + } + + if (!Initialized) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + + return null; + } + } + + Uri request = new(SteamCommunityURL, $"/mobileconf/conf?a={Bot.SteamID}&k={WebUtility.UrlEncode(confirmationHash)}&l=english&m=android&p={WebUtility.UrlEncode(deviceID)}&t={time}&tag=conf"); + + HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request).ConfigureAwait(false); + + return response?.Content; + } + + internal async Task?> GetDigitalGiftCards() { + Uri request = new(SteamStoreURL, "/gifts"); + + using HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request).ConfigureAwait(false); + + if (response == null) { + return null; + } + + IEnumerable htmlNodes = response.Content.SelectNodes("//div[@class='pending_gift']/div[starts-with(@id, 'pending_gift_')][count(div[@class='pending_giftcard_leftcol']) > 0]/@id"); + + HashSet results = new(); + + foreach (string? giftCardIDText in htmlNodes.Select(static node => node.GetAttribute("id"))) { + if (string.IsNullOrEmpty(giftCardIDText)) { + Bot.ArchiLogger.LogNullError(nameof(giftCardIDText)); + + return null; + } + + if (giftCardIDText.Length <= 13) { + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(giftCardIDText))); + + return null; + } + + if (!ulong.TryParse(giftCardIDText[13..], out ulong giftCardID) || (giftCardID == 0)) { + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(giftCardID))); + + return null; + } + + results.Add(giftCardID); + } + + return results; + } + + internal async Task GetDiscoveryQueuePage() { + Uri request = new(SteamStoreURL, "/explore?l=english"); + + HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request).ConfigureAwait(false); + + return response?.Content; + } + + internal async Task?> GetFamilySharingSteamIDs() { + Uri request = new(SteamStoreURL, "/account/managedevices?l=english"); + + using HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request).ConfigureAwait(false); + + if (response == null) { + return null; + } + + IEnumerable htmlNodes = response.Content.SelectNodes("(//table[@class='accountTable'])[2]//a/@data-miniprofile"); + + HashSet result = new(); + + foreach (string? miniProfile in htmlNodes.Select(static htmlNode => htmlNode.GetAttribute("data-miniprofile"))) { + if (string.IsNullOrEmpty(miniProfile)) { + Bot.ArchiLogger.LogNullError(nameof(miniProfile)); + + return null; + } + + if (!uint.TryParse(miniProfile, out uint steamID3) || (steamID3 == 0)) { + Bot.ArchiLogger.LogNullError(nameof(steamID3)); + + return null; + } + + ulong steamID = new SteamID(steamID3, EUniverse.Public, EAccountType.Individual); + result.Add(steamID); + } + + return result; + } + + internal async Task GetGameCardsPage(uint appID) { + if (appID == 0) { + throw new ArgumentOutOfRangeException(nameof(appID)); + } + + Uri request = new(SteamCommunityURL, $"/my/gamecards/{appID}?l=english"); + + HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request, checkSessionPreemptively: false).ConfigureAwait(false); + + return response?.Content; + } + + internal async Task GetServerTime() { + KeyValue? response = null; + + for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { + using WebAPI.AsyncInterface twoFactorService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(TwoFactorService); + + twoFactorService.Timeout = WebBrowser.Timeout; + + try { + response = await WebLimitRequest( + WebAPI.DefaultBaseAddress, + + // ReSharper disable once AccessToDisposedClosure + async () => await twoFactorService.CallAsync(HttpMethod.Post, "QueryTime").ConfigureAwait(false) + ).ConfigureAwait(false); + } catch (TaskCanceledException e) { + Bot.ArchiLogger.LogGenericDebuggingException(e); + } catch (Exception e) { + Bot.ArchiLogger.LogGenericWarningException(e); + } + } + + if (response == null) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + + return 0; + } + + uint result = response["server_time"].AsUnsignedInteger(); + + if (result == 0) { + Bot.ArchiLogger.LogNullError(nameof(result)); + + return 0; + } + + return result; + } + + internal async Task GetTradeHoldDurationForTrade(ulong tradeID) { + if (tradeID == 0) { + throw new ArgumentOutOfRangeException(nameof(tradeID)); + } + + Uri request = new(SteamCommunityURL, $"/tradeoffer/{tradeID}?l=english"); + + using HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request).ConfigureAwait(false); + + IElement? htmlNode = response?.Content.SelectSingleNode("//div[@class='pagecontent']/script"); + + if (htmlNode == null) { + // Trade can be no longer valid + return null; + } + + string text = htmlNode.TextContent; + + if (string.IsNullOrEmpty(text)) { + Bot.ArchiLogger.LogNullError(nameof(text)); + + return null; + } + + const string daysTheirVariableName = "g_daysTheirEscrow = "; + int index = text.IndexOf(daysTheirVariableName, StringComparison.Ordinal); + + if (index < 0) { + Bot.ArchiLogger.LogNullError(nameof(index)); + + return null; + } + + index += daysTheirVariableName.Length; + text = text[index..]; + + index = text.IndexOf(';', StringComparison.Ordinal); + + if (index < 0) { + Bot.ArchiLogger.LogNullError(nameof(index)); + + return null; + } + + text = text[..index]; + + if (!byte.TryParse(text, out byte result)) { + Bot.ArchiLogger.LogNullError(nameof(result)); + + return null; + } + + return result; + } + + internal async Task GetTradeHoldDurationForUser(ulong steamID, string? tradeToken = null) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + (bool success, string? steamApiKey) = await CachedApiKey.GetValue().ConfigureAwait(false); + + if (!success || string.IsNullOrEmpty(steamApiKey)) { + return null; + } + + // Extra entry for format + Dictionary arguments = new(!string.IsNullOrEmpty(tradeToken) ? 4 : 3, StringComparer.Ordinal) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + { "key", steamApiKey! }, + + { "steamid_target", steamID } + }; + + if (!string.IsNullOrEmpty(tradeToken)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + arguments["trade_offer_access_token"] = tradeToken!; + } + + KeyValue? response = null; + + for (byte i = 0; (i < WebBrowser.MaxTries) && (response == null); i++) { + using WebAPI.AsyncInterface econService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(EconService); + + econService.Timeout = WebBrowser.Timeout; + + try { + response = await WebLimitRequest( + WebAPI.DefaultBaseAddress, + + // ReSharper disable once AccessToDisposedClosure + async () => await econService.CallAsync(HttpMethod.Get, "GetTradeHoldDurations", args: arguments).ConfigureAwait(false) + ).ConfigureAwait(false); + } catch (TaskCanceledException e) { + Bot.ArchiLogger.LogGenericDebuggingException(e); + } catch (Exception e) { + Bot.ArchiLogger.LogGenericWarningException(e); + } + } + + if (response == null) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + + return null; + } + + uint resultInSeconds = response["their_escrow"]["escrow_end_duration_seconds"].AsUnsignedInteger(uint.MaxValue); + + if (resultInSeconds == uint.MaxValue) { + Bot.ArchiLogger.LogNullError(nameof(resultInSeconds)); + + return null; + } + + return resultInSeconds == 0 ? (byte) 0 : (byte) (resultInSeconds / 86400); + } + + internal async Task HandleConfirmation(string deviceID, string confirmationHash, uint time, ulong confirmationID, ulong confirmationKey, bool accept) { + if (string.IsNullOrEmpty(deviceID)) { + throw new ArgumentNullException(nameof(deviceID)); + } + + if (string.IsNullOrEmpty(confirmationHash)) { + throw new ArgumentNullException(nameof(confirmationHash)); + } + + if (time == 0) { + throw new ArgumentOutOfRangeException(nameof(time)); + } + + if (confirmationID == 0) { + throw new ArgumentOutOfRangeException(nameof(confirmationID)); + } + + if (confirmationKey == 0) { + throw new ArgumentOutOfRangeException(nameof(confirmationKey)); + } + + if (!Initialized) { + byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; + + for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { + await Task.Delay(1000).ConfigureAwait(false); + } + + if (!Initialized) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + + return null; + } + } + + Uri request = new(SteamCommunityURL, $"/mobileconf/ajaxop?a={Bot.SteamID}&cid={confirmationID}&ck={confirmationKey}&k={WebUtility.UrlEncode(confirmationHash)}&l=english&m=android&op={(accept ? "allow" : "cancel")}&p={WebUtility.UrlEncode(deviceID)}&t={time}&tag=conf"); + + ObjectResponse? response = await UrlGetToJsonObjectWithSession(request).ConfigureAwait(false); + + return response?.Content.Success; + } + + internal async Task HandleConfirmations(string deviceID, string confirmationHash, uint time, IReadOnlyCollection confirmations, bool accept) { + if (string.IsNullOrEmpty(deviceID)) { + throw new ArgumentNullException(nameof(deviceID)); + } + + if (string.IsNullOrEmpty(confirmationHash)) { + throw new ArgumentNullException(nameof(confirmationHash)); + } + + if (time == 0) { + throw new ArgumentOutOfRangeException(nameof(time)); + } + + if ((confirmations == null) || (confirmations.Count == 0)) { + throw new ArgumentNullException(nameof(confirmations)); + } + + if (!Initialized) { + byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout; + + for (byte i = 0; (i < connectionTimeout) && !Initialized && Bot.IsConnectedAndLoggedOn; i++) { + await Task.Delay(1000).ConfigureAwait(false); + } + + if (!Initialized) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + + return null; + } + } + + Uri request = new(SteamCommunityURL, "/mobileconf/multiajaxop"); + + // Extra entry for sessionID + List> data = new(8 + (confirmations.Count * 2)) { + new KeyValuePair("a", Bot.SteamID.ToString(CultureInfo.InvariantCulture)), + new KeyValuePair("k", confirmationHash), + new KeyValuePair("m", "android"), + new KeyValuePair("op", accept ? "allow" : "cancel"), + new KeyValuePair("p", deviceID), + new KeyValuePair("t", time.ToString(CultureInfo.InvariantCulture)), + new KeyValuePair("tag", "conf") + }; + + foreach (Confirmation confirmation in confirmations) { + data.Add(new KeyValuePair("cid[]", confirmation.ID.ToString(CultureInfo.InvariantCulture))); + data.Add(new KeyValuePair("ck[]", confirmation.Key.ToString(CultureInfo.InvariantCulture))); + } + + ObjectResponse? response = await UrlPostToJsonObjectWithSession(request, data: data).ConfigureAwait(false); + + return response?.Content.Success; + } + + internal async Task Init(ulong steamID, EUniverse universe, string webAPIUserNonce, string? parentalCode = null) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if ((universe == EUniverse.Invalid) || !Enum.IsDefined(typeof(EUniverse), universe)) { + throw new InvalidEnumArgumentException(nameof(universe), (int) universe, typeof(EUniverse)); + } + + if (string.IsNullOrEmpty(webAPIUserNonce)) { + throw new ArgumentNullException(nameof(webAPIUserNonce)); + } + + byte[]? publicKey = KeyDictionary.GetPublicKey(universe); + + if ((publicKey == null) || (publicKey.Length == 0)) { + Bot.ArchiLogger.LogNullError(nameof(publicKey)); + + return false; + } + + // Generate a random 32-byte session key + byte[] sessionKey = CryptoHelper.GenerateRandomBlock(32); + + // RSA encrypt our session key with the public key for the universe we're on + byte[] encryptedSessionKey; + + using (RSACrypto rsa = new(publicKey)) { + encryptedSessionKey = rsa.Encrypt(sessionKey); + } + + // Generate login key from the user nonce that we've received from Steam network + byte[] loginKey = Encoding.UTF8.GetBytes(webAPIUserNonce); + + // AES encrypt our login key with our session key + byte[] encryptedLoginKey = CryptoHelper.SymmetricEncrypt(loginKey, sessionKey); + + // Extra entry for format + Dictionary arguments = new(4, StringComparer.Ordinal) { + { "encrypted_loginkey", encryptedLoginKey }, + { "sessionkey", encryptedSessionKey }, + { "steamid", steamID } + }; + + // We're now ready to send the data to Steam API + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.LoggingIn, SteamUserAuthService)); + + KeyValue? response; + + // We do not use usual retry pattern here as webAPIUserNonce is valid only for a single request + // Even during timeout, webAPIUserNonce is most likely already invalid + // Instead, the caller is supposed to ask for new webAPIUserNonce and call Init() again on failure + using (WebAPI.AsyncInterface steamUserAuthService = Bot.SteamConfiguration.GetAsyncWebAPIInterface(SteamUserAuthService)) { + steamUserAuthService.Timeout = WebBrowser.Timeout; + + try { + response = await WebLimitRequest( + WebAPI.DefaultBaseAddress, + + // ReSharper disable once AccessToDisposedClosure + async () => await steamUserAuthService.CallAsync(HttpMethod.Post, "AuthenticateUser", args: arguments).ConfigureAwait(false) + ).ConfigureAwait(false); + } catch (TaskCanceledException e) { + Bot.ArchiLogger.LogGenericDebuggingException(e); + + return false; + } catch (Exception e) { + Bot.ArchiLogger.LogGenericWarningException(e); + + return false; + } + } + + string? steamLogin = response["token"].AsString(); + + if (string.IsNullOrEmpty(steamLogin)) { + Bot.ArchiLogger.LogNullError(nameof(steamLogin)); + + return false; + } + + string? steamLoginSecure = response["tokensecure"].AsString(); + + if (string.IsNullOrEmpty(steamLoginSecure)) { + Bot.ArchiLogger.LogNullError(nameof(steamLoginSecure)); + + return false; + } + + string sessionID = Convert.ToBase64String(Encoding.UTF8.GetBytes(steamID.ToString(CultureInfo.InvariantCulture))); + + WebBrowser.CookieContainer.Add(new Cookie("sessionid", sessionID, "/", $".{SteamCommunityURL.Host}")); + WebBrowser.CookieContainer.Add(new Cookie("sessionid", sessionID, "/", $".{SteamHelpURL.Host}")); + WebBrowser.CookieContainer.Add(new Cookie("sessionid", sessionID, "/", $".{SteamStoreURL.Host}")); + + WebBrowser.CookieContainer.Add(new Cookie("steamLogin", steamLogin, "/", $".{SteamCommunityURL.Host}")); + WebBrowser.CookieContainer.Add(new Cookie("steamLogin", steamLogin, "/", $".{SteamHelpURL.Host}")); + WebBrowser.CookieContainer.Add(new Cookie("steamLogin", steamLogin, "/", $".{SteamStoreURL.Host}")); + + WebBrowser.CookieContainer.Add(new Cookie("steamLoginSecure", steamLoginSecure, "/", $".{SteamCommunityURL.Host}")); + WebBrowser.CookieContainer.Add(new Cookie("steamLoginSecure", steamLoginSecure, "/", $".{SteamHelpURL.Host}")); + WebBrowser.CookieContainer.Add(new Cookie("steamLoginSecure", steamLoginSecure, "/", $".{SteamStoreURL.Host}")); + + // Report proper time when doing timezone-based calculations, see setTimezoneCookies() from https://steamcommunity-a.akamaihd.net/public/shared/javascript/shared_global.js + string timeZoneOffset = $"{DateTimeOffset.Now.Offset.TotalSeconds}{WebUtility.UrlEncode(",")}0"; + + WebBrowser.CookieContainer.Add(new Cookie("timezoneOffset", timeZoneOffset, "/", $".{SteamCommunityURL.Host}")); + WebBrowser.CookieContainer.Add(new Cookie("timezoneOffset", timeZoneOffset, "/", $".{SteamHelpURL.Host}")); + WebBrowser.CookieContainer.Add(new Cookie("timezoneOffset", timeZoneOffset, "/", $".{SteamStoreURL.Host}")); + + Bot.ArchiLogger.LogGenericInfo(Strings.Success); + + // Unlock Steam Parental if needed + if (parentalCode?.Length == BotConfig.SteamParentalCodeLength) { + if (!await UnlockParentalAccount(parentalCode).ConfigureAwait(false)) { + return false; + } + } + + LastSessionCheck = LastSessionRefresh = DateTime.UtcNow; + Initialized = true; + + return true; + } + + internal async Task MarkInventory() { + if (ASF.InventorySemaphore == null) { + throw new InvalidOperationException(nameof(ASF.InventorySemaphore)); + } + + // We aim to have a maximum of 2 tasks, one already working, and one waiting in the queue + // This way we can call this function as many times as needed e.g. because of Steam events + lock (ASF.InventorySemaphore) { + if (MarkingInventoryScheduled) { + return; + } + + MarkingInventoryScheduled = true; + } + + await ASF.InventorySemaphore.WaitAsync().ConfigureAwait(false); + + try { + lock (ASF.InventorySemaphore) { + MarkingInventoryScheduled = false; + } + + Uri request = new(SteamCommunityURL, "/my/inventory"); + + await UrlHeadWithSession(request, checkSessionPreemptively: false).ConfigureAwait(false); + } finally { + byte inventoryLimiterDelay = ASF.GlobalConfig?.InventoryLimiterDelay ?? GlobalConfig.DefaultInventoryLimiterDelay; + + if (inventoryLimiterDelay == 0) { + ASF.InventorySemaphore.Release(); + } else { + Utilities.InBackground( + async () => { + await Task.Delay(inventoryLimiterDelay * 1000).ConfigureAwait(false); + ASF.InventorySemaphore.Release(); + } + ); + } + } + } + + internal async Task MarkSentTrades() { + Uri request = new(SteamCommunityURL, "/my/tradeoffers/sent"); + + return await UrlHeadWithSession(request, checkSessionPreemptively: false).ConfigureAwait(false); + } + + internal void OnDisconnected() { + Initialized = false; + Utilities.InBackground(CachedApiKey.Reset); + } + + internal void OnVanityURLChanged(string? vanityURL = null) => VanityURL = !string.IsNullOrEmpty(vanityURL) ? vanityURL : null; + + internal async Task<(EResult Result, EPurchaseResultDetail? PurchaseResult)?> RedeemWalletKey(string key) { + if (string.IsNullOrEmpty(key)) { + throw new ArgumentNullException(nameof(key)); + } + + // ASF should redeem wallet key only in case of existing wallet + if (Bot.WalletCurrency == ECurrencyCode.Invalid) { + Bot.ArchiLogger.LogNullError(nameof(Bot.WalletCurrency)); + + return null; + } + + Uri request = new(SteamStoreURL, "/account/ajaxredeemwalletcode"); + + // Extra entry for sessionID + Dictionary data = new(2, StringComparer.Ordinal) { { "wallet_code", key } }; + + ObjectResponse? response = await UrlPostToJsonObjectWithSession(request, data: data).ConfigureAwait(false); + + if (response == null) { + return null; + } + + // We can not trust EResult response, because it is OK even in the case of error, so changing it to Fail in this case + if ((response.Content.Result != EResult.OK) || (response.Content.PurchaseResultDetail != EPurchaseResultDetail.NoDetail)) { + return (response.Content.Result == EResult.OK ? EResult.Fail : response.Content.Result, response.Content.PurchaseResultDetail); + } + + return (EResult.OK, EPurchaseResultDetail.NoDetail); + } + + internal async Task UnpackBooster(uint appID, ulong itemID) { + if (appID == 0) { + throw new ArgumentOutOfRangeException(nameof(appID)); + } + + if (itemID == 0) { + throw new ArgumentOutOfRangeException(nameof(itemID)); + } + + string? profileURL = await GetAbsoluteProfileURL().ConfigureAwait(false); + + if (string.IsNullOrEmpty(profileURL)) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + + return false; + } + + Uri request = new(SteamCommunityURL, $"{profileURL}/ajaxunpackbooster"); + + // Extra entry for sessionID + Dictionary data = new(3, StringComparer.Ordinal) { + { "appid", appID.ToString(CultureInfo.InvariantCulture) }, + { "communityitemid", itemID.ToString(CultureInfo.InvariantCulture) } + }; + + ObjectResponse? response = await UrlPostToJsonObjectWithSession(request, data: data).ConfigureAwait(false); + + return response?.Content.Result == EResult.OK; + } + + private async Task<(ESteamApiKeyState State, string? Key)> GetApiKeyState() { + Uri request = new(SteamCommunityURL, "/dev/apikey?l=english"); + + using HtmlDocumentResponse? response = await UrlGetToHtmlDocumentWithSession(request).ConfigureAwait(false); + + if (response == null) { + return (ESteamApiKeyState.Timeout, null); + } + + IElement? titleNode = response.Content.SelectSingleNode("//div[@id='mainContents']/h2"); + + if (titleNode == null) { + Bot.ArchiLogger.LogNullError(nameof(titleNode)); + + return (ESteamApiKeyState.Error, null); + } + + string title = titleNode.TextContent; + + if (string.IsNullOrEmpty(title)) { + Bot.ArchiLogger.LogNullError(nameof(title)); + + return (ESteamApiKeyState.Error, null); + } + + if (title.Contains("Access Denied", StringComparison.OrdinalIgnoreCase) || title.Contains("Validated email address required", StringComparison.OrdinalIgnoreCase)) { + return (ESteamApiKeyState.AccessDenied, null); + } + + IElement? htmlNode = response.Content.SelectSingleNode("//div[@id='bodyContents_ex']/p"); + + if (htmlNode == null) { + Bot.ArchiLogger.LogNullError(nameof(htmlNode)); + + return (ESteamApiKeyState.Error, null); + } + + string text = htmlNode.TextContent; + + if (string.IsNullOrEmpty(text)) { + Bot.ArchiLogger.LogNullError(nameof(text)); + + return (ESteamApiKeyState.Error, null); + } + + if (text.Contains("Registering for a Steam Web API Key", StringComparison.OrdinalIgnoreCase)) { + return (ESteamApiKeyState.NotRegisteredYet, null); + } + + int keyIndex = text.IndexOf("Key: ", StringComparison.Ordinal); + + if (keyIndex < 0) { + Bot.ArchiLogger.LogNullError(nameof(keyIndex)); + + return (ESteamApiKeyState.Error, null); + } + + keyIndex += 5; + + if (text.Length <= keyIndex) { + Bot.ArchiLogger.LogNullError(nameof(text)); + + return (ESteamApiKeyState.Error, null); + } + + text = text[keyIndex..]; + + if ((text.Length != 32) || !Utilities.IsValidHexadecimalText(text)) { + Bot.ArchiLogger.LogNullError(nameof(text)); + + return (ESteamApiKeyState.Error, null); + } + + return (ESteamApiKeyState.Registered, text); + } + + private async Task IsProfileUri(Uri uri, bool waitForInitialization = true) { + if (uri == null) { + throw new ArgumentNullException(nameof(uri)); + } + + string? profileURL = await GetAbsoluteProfileURL(waitForInitialization).ConfigureAwait(false); + + if (string.IsNullOrEmpty(profileURL)) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + + return false; + } + + return uri.AbsolutePath.Equals(profileURL, StringComparison.OrdinalIgnoreCase); + } + + private async Task IsSessionExpired() { + DateTime triggeredAt = DateTime.UtcNow; + + if (triggeredAt <= LastSessionCheck) { + return LastSessionCheck != LastSessionRefresh; + } + + await SessionSemaphore.WaitAsync().ConfigureAwait(false); + + try { + if (triggeredAt <= LastSessionCheck) { + return LastSessionCheck != LastSessionRefresh; + } + + // Choosing proper URL to check against is actually much harder than it initially looks like, we must abide by several rules to make this function as lightweight and reliable as possible + // We should prefer to use Steam store, as the community is much more unstable and broken, plus majority of our requests get there anyway, so load-balancing with store makes much more sense. It also has a higher priority than the community, so all eventual issues should be fixed there first + // The URL must be fast enough to render, as this function will be called reasonably often, and every extra delay adds up. We're already making our best effort by using HEAD request, but the URL itself plays a very important role as well + // The page should have as little internal dependencies as possible, since every extra chunk increases likelihood of broken functionality. We can only make a guess here based on the amount of content that the page returns to us + // It should also be URL with fairly fixed address that isn't going to disappear anytime soon, preferably something staple that is a dependency of other requests, so it's very unlikely to change in a way that would add overhead in the future + // Lastly, it should be a request that is preferably generic enough as a routine check, not something specialized and targetted, to make it very clear that we're just checking if session is up, and to further aid internal dependencies specified above by rendering as general Steam info as possible + Uri request = new(SteamStoreURL, "/account"); + + BasicResponse? response = await WebLimitRequest(SteamStoreURL, async () => await WebBrowser.UrlHead(request).ConfigureAwait(false)).ConfigureAwait(false); + + if (response == null) { + return null; + } + + bool result = IsSessionExpiredUri(response.FinalUri); + + DateTime now = DateTime.UtcNow; + + if (result) { + Initialized = false; + } else { + LastSessionRefresh = now; + } + + LastSessionCheck = now; + + return result; + } finally { + SessionSemaphore.Release(); + } + } + + private static bool IsSessionExpiredUri(Uri uri) { + if (uri == null) { + throw new ArgumentNullException(nameof(uri)); + } + + return uri.AbsolutePath.StartsWith("/login", StringComparison.OrdinalIgnoreCase) || uri.Host.Equals("lostauth", StringComparison.OrdinalIgnoreCase); + } + + private static bool ParseItems(IReadOnlyDictionary<(uint AppID, ulong ClassID, ulong InstanceID), InventoryResponse.Description> descriptions, IReadOnlyCollection input, ICollection output) { + if (descriptions == null) { + throw new ArgumentNullException(nameof(descriptions)); + } + + if ((input == null) || (input.Count == 0)) { + throw new ArgumentNullException(nameof(input)); + } + + if (output == null) { + throw new ArgumentNullException(nameof(output)); + } + + foreach (KeyValue item in input) { + uint appID = item["appid"].AsUnsignedInteger(); + + if (appID == 0) { + ASF.ArchiLogger.LogNullError(nameof(appID)); - if ((response == null) || IsSessionExpiredUri(response.FinalUri)) { - // There is no session refresh capability at this stage return false; } - // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case - if (await IsProfileUri(response.FinalUri, false).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); + ulong contextID = item["contextid"].AsUnsignedLong(); - return await UnlockParentalAccountForService(service, parentalCode, --maxTries).ConfigureAwait(false); + if (contextID == 0) { + ASF.ArchiLogger.LogNullError(nameof(contextID)); + + return false; } - return true; + ulong classID = item["classid"].AsUnsignedLong(); + + if (classID == 0) { + ASF.ArchiLogger.LogNullError(nameof(classID)); + + return false; + } + + ulong instanceID = item["instanceid"].AsUnsignedLong(); + + (uint AppID, ulong ClassID, ulong InstanceID) key = (appID, classID, instanceID); + + uint amount = item["amount"].AsUnsignedInteger(); + + if (amount == 0) { + ASF.ArchiLogger.LogNullError(nameof(amount)); + + return false; + } + + ulong assetID = item["assetid"].AsUnsignedLong(); + + bool marketable = true; + bool tradable = true; + ImmutableHashSet? tags = null; + uint realAppID = 0; + Asset.EType type = Asset.EType.Unknown; + Asset.ERarity rarity = Asset.ERarity.Unknown; + + if (descriptions.TryGetValue(key, out InventoryResponse.Description? description)) { + marketable = description.Marketable; + tradable = description.Tradable; + tags = description.Tags; + realAppID = description.RealAppID; + type = description.Type; + rarity = description.Rarity; + } + + Asset steamAsset = new(appID, contextID, classID, amount, instanceID, assetID, marketable, tradable, tags, realAppID, type, rarity); + output.Add(steamAsset); } - public enum ESession : byte { - None, - Lowercase, - CamelCase, - PascalCase + return true; + } + + private async Task RefreshSession() { + if (!Bot.IsConnectedAndLoggedOn) { + return false; } - private enum ESteamApiKeyState : byte { - Error, - Timeout, - Registered, - NotRegisteredYet, - AccessDenied + DateTime triggeredAt = DateTime.UtcNow; + + if (triggeredAt <= LastSessionCheck) { + return LastSessionCheck == LastSessionRefresh; + } + + await SessionSemaphore.WaitAsync().ConfigureAwait(false); + + try { + if (triggeredAt <= LastSessionCheck) { + return LastSessionCheck == LastSessionRefresh; + } + + Initialized = false; + + if (!Bot.IsConnectedAndLoggedOn) { + return false; + } + + Bot.ArchiLogger.LogGenericInfo(Strings.RefreshingOurSession); + bool result = await Bot.RefreshSession().ConfigureAwait(false); + + DateTime now = DateTime.UtcNow; + + if (result) { + LastSessionRefresh = now; + } + + LastSessionCheck = now; + + return result; + } finally { + SessionSemaphore.Release(); } } + + private async Task RegisterApiKey() { + Uri request = new(SteamCommunityURL, "/dev/registerkey"); + + // Extra entry for sessionID + Dictionary data = new(4, StringComparer.Ordinal) { + { "agreeToTerms", "agreed" }, +#pragma warning disable CA1308 // False positive, we're intentionally converting this part to lowercase and it's not used for any security decisions based on the result of the normalization + { "domain", $"generated.by.{SharedInfo.AssemblyName.ToLowerInvariant()}.localhost" }, +#pragma warning restore CA1308 // False positive, we're intentionally converting this part to lowercase and it's not used for any security decisions based on the result of the normalization + { "Submit", "Register" } + }; + + return await UrlPostWithSession(request, data: data).ConfigureAwait(false); + } + + private async Task<(bool Success, string? Result)> ResolveAccessToken() { + Uri request = new(SteamStoreURL, "/pointssummary/ajaxgetasyncconfig"); + + ObjectResponse? response = await UrlGetToJsonObjectWithSession(request).ConfigureAwait(false); + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + return !string.IsNullOrEmpty(response?.Content.Data.WebAPIToken) ? (true, response!.Content.Data.WebAPIToken) : (false, null); + } + + private async Task<(bool Success, string? Result)> ResolveApiKey() { + if (Bot.IsAccountLimited) { + // API key is permanently unavailable for limited accounts + return (true, null); + } + + (ESteamApiKeyState State, string? Key) result = await GetApiKeyState().ConfigureAwait(false); + + switch (result.State) { + case ESteamApiKeyState.AccessDenied: + // We succeeded in fetching API key, but it resulted in access denied + // Return empty result, API key is unavailable permanently + return (true, ""); + case ESteamApiKeyState.NotRegisteredYet: + // We succeeded in fetching API key, and it resulted in no key registered yet + // Let's try to register a new key + if (!await RegisterApiKey().ConfigureAwait(false)) { + // Request timed out, bad luck, we'll try again later + goto case ESteamApiKeyState.Timeout; + } + + // We should have the key ready, so let's fetch it again + result = await GetApiKeyState().ConfigureAwait(false); + + if (result.State == ESteamApiKeyState.Timeout) { + // Request timed out, bad luck, we'll try again later + goto case ESteamApiKeyState.Timeout; + } + + if (result.State != ESteamApiKeyState.Registered) { + // Something went wrong, report error + goto default; + } + + goto case ESteamApiKeyState.Registered; + case ESteamApiKeyState.Registered: + // We succeeded in fetching API key, and it resulted in registered key + // Cache the result, this is the API key we want + return (true, result.Key); + case ESteamApiKeyState.Timeout: + // Request timed out, bad luck, we'll try again later + return (false, null); + default: + // We got an unhandled error, this should never happen + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(result.State), result.State)); + + return (false, null); + } + } + + private async Task UnlockParentalAccount(string parentalCode) { + if (string.IsNullOrEmpty(parentalCode)) { + throw new ArgumentNullException(nameof(parentalCode)); + } + + Bot.ArchiLogger.LogGenericInfo(Strings.UnlockingParentalAccount); + + bool[] results = await Task.WhenAll(UnlockParentalAccountForService(SteamCommunityURL, parentalCode), UnlockParentalAccountForService(SteamStoreURL, parentalCode)).ConfigureAwait(false); + + if (results.Any(static result => !result)) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + + return false; + } + + Bot.ArchiLogger.LogGenericInfo(Strings.Success); + + return true; + } + + private async Task UnlockParentalAccountForService(Uri service, string parentalCode, byte maxTries = WebBrowser.MaxTries) { + if (service == null) { + throw new ArgumentNullException(nameof(service)); + } + + if (string.IsNullOrEmpty(parentalCode)) { + throw new ArgumentNullException(nameof(parentalCode)); + } + + Uri request = new(service, "/parental/ajaxunlock"); + + if (maxTries == 0) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + return false; + } + + string? sessionID = WebBrowser.CookieContainer.GetCookieValue(service, "sessionid"); + + if (string.IsNullOrEmpty(sessionID)) { + Bot.ArchiLogger.LogNullError(nameof(sessionID)); + + return false; + } + + Dictionary data = new(2, StringComparer.Ordinal) { + { "pin", parentalCode }, + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + { "sessionid", sessionID! } + }; + + // This request doesn't go through UrlPostRetryWithSession as we have no access to session refresh capability (this is in fact session initialization) + BasicResponse? response = await WebLimitRequest(service, async () => await WebBrowser.UrlPost(request, data: data, referer: service).ConfigureAwait(false)).ConfigureAwait(false); + + if ((response == null) || IsSessionExpiredUri(response.FinalUri)) { + // There is no session refresh capability at this stage + return false; + } + + // Under special brain-damaged circumstances, Steam might just return our own profile as a response to the request, for absolutely no reason whatsoever - just try again in this case + if (await IsProfileUri(response.FinalUri, false).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningWorkaroundTriggered, nameof(IsProfileUri))); + + return await UnlockParentalAccountForService(service, parentalCode, --maxTries).ConfigureAwait(false); + } + + return true; + } + + public enum ESession : byte { + None, + Lowercase, + CamelCase, + PascalCase + } + + private enum ESteamApiKeyState : byte { + Error, + Timeout, + Registered, + NotRegisteredYet, + AccessDenied + } } diff --git a/ArchiSteamFarm/Steam/Integration/CMsgs/CMsgClientAcknowledgeClanInvite.cs b/ArchiSteamFarm/Steam/Integration/CMsgs/CMsgClientAcknowledgeClanInvite.cs index 29dba8a83..1b8f82cf6 100644 --- a/ArchiSteamFarm/Steam/Integration/CMsgs/CMsgClientAcknowledgeClanInvite.cs +++ b/ArchiSteamFarm/Steam/Integration/CMsgs/CMsgClientAcknowledgeClanInvite.cs @@ -25,33 +25,33 @@ using System.Text; using SteamKit2; using SteamKit2.Internal; -namespace ArchiSteamFarm.Steam.Integration.CMsgs { - internal sealed class CMsgClientAcknowledgeClanInvite : ISteamSerializableMessage { - internal bool AcceptInvite { private get; set; } - internal ulong ClanID { private get; set; } +namespace ArchiSteamFarm.Steam.Integration.CMsgs; - void ISteamSerializable.Deserialize(Stream stream) { - if (stream == null) { - throw new ArgumentNullException(nameof(stream)); - } +internal sealed class CMsgClientAcknowledgeClanInvite : ISteamSerializableMessage { + internal bool AcceptInvite { private get; set; } + internal ulong ClanID { private get; set; } - using BinaryReader binaryReader = new(stream, Encoding.UTF8, true); - - ClanID = binaryReader.ReadUInt64(); - AcceptInvite = binaryReader.ReadBoolean(); + void ISteamSerializable.Deserialize(Stream stream) { + if (stream == null) { + throw new ArgumentNullException(nameof(stream)); } - EMsg ISteamSerializableMessage.GetEMsg() => EMsg.ClientAcknowledgeClanInvite; + using BinaryReader binaryReader = new(stream, Encoding.UTF8, true); - void ISteamSerializable.Serialize(Stream stream) { - if (stream == null) { - throw new ArgumentNullException(nameof(stream)); - } + ClanID = binaryReader.ReadUInt64(); + AcceptInvite = binaryReader.ReadBoolean(); + } - using BinaryWriter binaryWriter = new(stream, Encoding.UTF8, true); + EMsg ISteamSerializableMessage.GetEMsg() => EMsg.ClientAcknowledgeClanInvite; - binaryWriter.Write(ClanID); - binaryWriter.Write(AcceptInvite); + void ISteamSerializable.Serialize(Stream stream) { + if (stream == null) { + throw new ArgumentNullException(nameof(stream)); } + + using BinaryWriter binaryWriter = new(stream, Encoding.UTF8, true); + + binaryWriter.Write(ClanID); + binaryWriter.Write(AcceptInvite); } } diff --git a/ArchiSteamFarm/Steam/Integration/Callbacks/PurchaseResponseCallback.cs b/ArchiSteamFarm/Steam/Integration/Callbacks/PurchaseResponseCallback.cs index cc47421ff..f7a2e7283 100644 --- a/ArchiSteamFarm/Steam/Integration/Callbacks/PurchaseResponseCallback.cs +++ b/ArchiSteamFarm/Steam/Integration/Callbacks/PurchaseResponseCallback.cs @@ -29,93 +29,93 @@ using JetBrains.Annotations; using SteamKit2; using SteamKit2.Internal; -namespace ArchiSteamFarm.Steam.Integration.Callbacks { - public sealed class PurchaseResponseCallback : CallbackMsg { - [PublicAPI] - public Dictionary? Items { get; } +namespace ArchiSteamFarm.Steam.Integration.Callbacks; - public EPurchaseResultDetail PurchaseResultDetail { get; internal set; } +public sealed class PurchaseResponseCallback : CallbackMsg { + [PublicAPI] + public Dictionary? Items { get; } - [PublicAPI] - public EResult Result { get; internal set; } + public EPurchaseResultDetail PurchaseResultDetail { get; internal set; } - internal PurchaseResponseCallback(EResult result, EPurchaseResultDetail purchaseResult) { - if (!Enum.IsDefined(typeof(EResult), result)) { - throw new InvalidEnumArgumentException(nameof(result), (int) result, typeof(EResult)); - } + [PublicAPI] + public EResult Result { get; internal set; } - if (!Enum.IsDefined(typeof(EPurchaseResultDetail), purchaseResult)) { - throw new InvalidEnumArgumentException(nameof(purchaseResult), (int) purchaseResult, typeof(EPurchaseResultDetail)); - } - - Result = result; - PurchaseResultDetail = purchaseResult; + internal PurchaseResponseCallback(EResult result, EPurchaseResultDetail purchaseResult) { + if (!Enum.IsDefined(typeof(EResult), result)) { + throw new InvalidEnumArgumentException(nameof(result), (int) result, typeof(EResult)); } - internal PurchaseResponseCallback(JobID jobID, CMsgClientPurchaseResponse msg) { - if (jobID == null) { - throw new ArgumentNullException(nameof(jobID)); - } + if (!Enum.IsDefined(typeof(EPurchaseResultDetail), purchaseResult)) { + throw new InvalidEnumArgumentException(nameof(purchaseResult), (int) purchaseResult, typeof(EPurchaseResultDetail)); + } - if (msg == null) { - throw new ArgumentNullException(nameof(msg)); - } + Result = result; + PurchaseResultDetail = purchaseResult; + } - JobID = jobID; - PurchaseResultDetail = (EPurchaseResultDetail) msg.purchase_result_details; - Result = (EResult) msg.eresult; + internal PurchaseResponseCallback(JobID jobID, CMsgClientPurchaseResponse msg) { + if (jobID == null) { + throw new ArgumentNullException(nameof(jobID)); + } - if (msg.purchase_receipt_info == null) { - ASF.ArchiLogger.LogNullError(nameof(msg.purchase_receipt_info)); + if (msg == null) { + throw new ArgumentNullException(nameof(msg)); + } + + JobID = jobID; + PurchaseResultDetail = (EPurchaseResultDetail) msg.purchase_result_details; + Result = (EResult) msg.eresult; + + if (msg.purchase_receipt_info == null) { + ASF.ArchiLogger.LogNullError(nameof(msg.purchase_receipt_info)); + + return; + } + + KeyValue receiptInfo = new(); + + using (MemoryStream ms = new(msg.purchase_receipt_info)) { + if (!receiptInfo.TryReadAsBinary(ms)) { + ASF.ArchiLogger.LogNullError(nameof(ms)); return; } + } - KeyValue receiptInfo = new(); + List lineItems = receiptInfo["lineitems"].Children; - using (MemoryStream ms = new(msg.purchase_receipt_info)) { - if (!receiptInfo.TryReadAsBinary(ms)) { - ASF.ArchiLogger.LogNullError(nameof(ms)); + if (lineItems.Count == 0) { + return; + } - return; - } - } + Items = new Dictionary(lineItems.Count); - List lineItems = receiptInfo["lineitems"].Children; + foreach (KeyValue lineItem in lineItems) { + uint packageID = lineItem["PackageID"].AsUnsignedInteger(); - if (lineItems.Count == 0) { - return; - } - - Items = new Dictionary(lineItems.Count); - - foreach (KeyValue lineItem in lineItems) { - uint packageID = lineItem["PackageID"].AsUnsignedInteger(); + if (packageID == 0) { + // Coupons have PackageID of -1 (don't ask me why) + // We'll use ItemAppID in this case + packageID = lineItem["ItemAppID"].AsUnsignedInteger(); if (packageID == 0) { - // Coupons have PackageID of -1 (don't ask me why) - // We'll use ItemAppID in this case - packageID = lineItem["ItemAppID"].AsUnsignedInteger(); - - if (packageID == 0) { - ASF.ArchiLogger.LogNullError(nameof(packageID)); - - return; - } - } - - string? gameName = lineItem["ItemDescription"].AsString(); - - if (string.IsNullOrEmpty(gameName)) { - ASF.ArchiLogger.LogNullError(nameof(gameName)); + ASF.ArchiLogger.LogNullError(nameof(packageID)); return; } - - // Apparently steam expects client to decode sent HTML - gameName = WebUtility.HtmlDecode(gameName); - Items[packageID] = gameName; } + + string? gameName = lineItem["ItemDescription"].AsString(); + + if (string.IsNullOrEmpty(gameName)) { + ASF.ArchiLogger.LogNullError(nameof(gameName)); + + return; + } + + // Apparently steam expects client to decode sent HTML + gameName = WebUtility.HtmlDecode(gameName); + Items[packageID] = gameName; } } } diff --git a/ArchiSteamFarm/Steam/Integration/Callbacks/UserNotificationsCallback.cs b/ArchiSteamFarm/Steam/Integration/Callbacks/UserNotificationsCallback.cs index bbfd1cde0..a53c6ebf2 100644 --- a/ArchiSteamFarm/Steam/Integration/Callbacks/UserNotificationsCallback.cs +++ b/ArchiSteamFarm/Steam/Integration/Callbacks/UserNotificationsCallback.cs @@ -28,94 +28,94 @@ using JetBrains.Annotations; using SteamKit2; using SteamKit2.Internal; -namespace ArchiSteamFarm.Steam.Integration.Callbacks { - public sealed class UserNotificationsCallback : CallbackMsg { - internal readonly Dictionary Notifications; +namespace ArchiSteamFarm.Steam.Integration.Callbacks; - internal UserNotificationsCallback(JobID jobID, CMsgClientUserNotifications msg) { - if (jobID == null) { - throw new ArgumentNullException(nameof(jobID)); - } +public sealed class UserNotificationsCallback : CallbackMsg { + internal readonly Dictionary Notifications; - if (msg == null) { - throw new ArgumentNullException(nameof(msg)); - } - - JobID = jobID; - - // We might get null body here, and that means there are no notifications related to trading - // TODO: Check if this workaround is still needed - Notifications = new Dictionary { { EUserNotification.Trading, 0 } }; - - if (msg.notifications == null) { - return; - } - - foreach (CMsgClientUserNotifications.Notification notification in msg.notifications) { - EUserNotification type = (EUserNotification) notification.user_notification_type; - - switch (type) { - case EUserNotification.AccountAlerts: - case EUserNotification.Chat: - case EUserNotification.Comments: - case EUserNotification.GameTurns: - case EUserNotification.Gifts: - case EUserNotification.HelpRequestReplies: - case EUserNotification.Invites: - case EUserNotification.Items: - case EUserNotification.ModeratorMessages: - case EUserNotification.Trading: - break; - default: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(type), type)); - - break; - } - - Notifications[type] = notification.count; - } + internal UserNotificationsCallback(JobID jobID, CMsgClientUserNotifications msg) { + if (jobID == null) { + throw new ArgumentNullException(nameof(jobID)); } - internal UserNotificationsCallback(JobID jobID, CMsgClientItemAnnouncements msg) { - if (jobID == null) { - throw new ArgumentNullException(nameof(jobID)); - } - - if (msg == null) { - throw new ArgumentNullException(nameof(msg)); - } - - JobID = jobID; - Notifications = new Dictionary(1) { { EUserNotification.Items, msg.count_new_items } }; + if (msg == null) { + throw new ArgumentNullException(nameof(msg)); } - internal UserNotificationsCallback(JobID jobID, CMsgClientCommentNotifications msg) { - if (jobID == null) { - throw new ArgumentNullException(nameof(jobID)); - } + JobID = jobID; - if (msg == null) { - throw new ArgumentNullException(nameof(msg)); - } + // We might get null body here, and that means there are no notifications related to trading + // TODO: Check if this workaround is still needed + Notifications = new Dictionary { { EUserNotification.Trading, 0 } }; - JobID = jobID; - Notifications = new Dictionary(1) { { EUserNotification.Comments, msg.count_new_comments + msg.count_new_comments_owner + msg.count_new_comments_subscriptions } }; + if (msg.notifications == null) { + return; } - [PublicAPI] - public enum EUserNotification : byte { - Unknown, - Trading, - GameTurns, - ModeratorMessages, - Comments, - Items, - Invites, - Unknown7, // Unknown type of notification, never seen in the wild - Gifts, - Chat, - HelpRequestReplies, - AccountAlerts + foreach (CMsgClientUserNotifications.Notification notification in msg.notifications) { + EUserNotification type = (EUserNotification) notification.user_notification_type; + + switch (type) { + case EUserNotification.AccountAlerts: + case EUserNotification.Chat: + case EUserNotification.Comments: + case EUserNotification.GameTurns: + case EUserNotification.Gifts: + case EUserNotification.HelpRequestReplies: + case EUserNotification.Invites: + case EUserNotification.Items: + case EUserNotification.ModeratorMessages: + case EUserNotification.Trading: + break; + default: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(type), type)); + + break; + } + + Notifications[type] = notification.count; } } + + internal UserNotificationsCallback(JobID jobID, CMsgClientItemAnnouncements msg) { + if (jobID == null) { + throw new ArgumentNullException(nameof(jobID)); + } + + if (msg == null) { + throw new ArgumentNullException(nameof(msg)); + } + + JobID = jobID; + Notifications = new Dictionary(1) { { EUserNotification.Items, msg.count_new_items } }; + } + + internal UserNotificationsCallback(JobID jobID, CMsgClientCommentNotifications msg) { + if (jobID == null) { + throw new ArgumentNullException(nameof(jobID)); + } + + if (msg == null) { + throw new ArgumentNullException(nameof(msg)); + } + + JobID = jobID; + Notifications = new Dictionary(1) { { EUserNotification.Comments, msg.count_new_comments + msg.count_new_comments_owner + msg.count_new_comments_subscriptions } }; + } + + [PublicAPI] + public enum EUserNotification : byte { + Unknown, + Trading, + GameTurns, + ModeratorMessages, + Comments, + Items, + Invites, + Unknown7, // Unknown type of notification, never seen in the wild + Gifts, + Chat, + HelpRequestReplies, + AccountAlerts + } } diff --git a/ArchiSteamFarm/Steam/Integration/SteamChatMessage.cs b/ArchiSteamFarm/Steam/Integration/SteamChatMessage.cs index ac8be9d14..d1aa618f7 100644 --- a/ArchiSteamFarm/Steam/Integration/SteamChatMessage.cs +++ b/ArchiSteamFarm/Steam/Integration/SteamChatMessage.cs @@ -26,150 +26,67 @@ using System.IO; using System.Linq; using System.Text; -namespace ArchiSteamFarm.Steam.Integration { - internal static class SteamChatMessage { - internal const char ContinuationCharacter = '…'; // A character used for indicating that the next newline part is a continuation of the previous line - internal const byte ContinuationCharacterBytes = 3; // The continuation character specified above uses 3 bytes in UTF-8 - internal const ushort MaxMessageBytesForLimitedAccounts = 1945; // This is a limitation enforced by Steam - internal const ushort MaxMessageBytesForUnlimitedAccounts = 6340; // This is a limitation enforced by Steam - internal const ushort MaxMessagePrefixBytes = MaxMessageBytesForLimitedAccounts - ReservedContinuationMessageBytes - ReservedEscapeMessageBytes; // Simplified calculation, nobody should be using prefixes even close to that anyway - internal const byte NewlineWeight = 61; // This defines how much weight a newline character is adding to the output, limitation enforced by Steam - internal const char ParagraphCharacter = '¶'; // A character used for indicating that this is not the last part of message (2 bytes, so it fits in ContinuationCharacterBytes) - internal const byte ReservedContinuationMessageBytes = ContinuationCharacterBytes * 2; // Up to 2 optional continuation characters - internal const byte ReservedEscapeMessageBytes = 5; // 2 characters total, escape one '\' of 1 byte and real one of up to 4 bytes +namespace ArchiSteamFarm.Steam.Integration; - internal static async IAsyncEnumerable GetMessageParts(string message, string? steamMessagePrefix = null, bool isAccountLimited = false) { - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); +internal static class SteamChatMessage { + internal const char ContinuationCharacter = '…'; // A character used for indicating that the next newline part is a continuation of the previous line + internal const byte ContinuationCharacterBytes = 3; // The continuation character specified above uses 3 bytes in UTF-8 + internal const ushort MaxMessageBytesForLimitedAccounts = 1945; // This is a limitation enforced by Steam + internal const ushort MaxMessageBytesForUnlimitedAccounts = 6340; // This is a limitation enforced by Steam + internal const ushort MaxMessagePrefixBytes = MaxMessageBytesForLimitedAccounts - ReservedContinuationMessageBytes - ReservedEscapeMessageBytes; // Simplified calculation, nobody should be using prefixes even close to that anyway + internal const byte NewlineWeight = 61; // This defines how much weight a newline character is adding to the output, limitation enforced by Steam + internal const char ParagraphCharacter = '¶'; // A character used for indicating that this is not the last part of message (2 bytes, so it fits in ContinuationCharacterBytes) + internal const byte ReservedContinuationMessageBytes = ContinuationCharacterBytes * 2; // Up to 2 optional continuation characters + internal const byte ReservedEscapeMessageBytes = 5; // 2 characters total, escape one '\' of 1 byte and real one of up to 4 bytes + + internal static async IAsyncEnumerable GetMessageParts(string message, string? steamMessagePrefix = null, bool isAccountLimited = false) { + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + int prefixBytes = 0; + int prefixLength = 0; + + if (!string.IsNullOrEmpty(steamMessagePrefix)) { + // We must escape our message prefix if needed + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + steamMessagePrefix = Escape(steamMessagePrefix!); + + prefixBytes = GetMessagePrefixBytes(steamMessagePrefix); + + if (prefixBytes > MaxMessagePrefixBytes) { + throw new ArgumentOutOfRangeException(nameof(steamMessagePrefix)); } - int prefixBytes = 0; - int prefixLength = 0; + prefixLength = steamMessagePrefix.Length; + } - if (!string.IsNullOrEmpty(steamMessagePrefix)) { - // We must escape our message prefix if needed - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - steamMessagePrefix = Escape(steamMessagePrefix!); + int maxMessageBytes = (isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts) - ReservedContinuationMessageBytes; - prefixBytes = GetMessagePrefixBytes(steamMessagePrefix); + // We must escape our message prior to sending it + message = Escape(message); - if (prefixBytes > MaxMessagePrefixBytes) { - throw new ArgumentOutOfRangeException(nameof(steamMessagePrefix)); + int messagePartBytes = prefixBytes; + StringBuilder messagePart = new(steamMessagePrefix); + + Decoder decoder = Encoding.UTF8.GetDecoder(); + ArrayPool charPool = ArrayPool.Shared; + + using StringReader stringReader = new(message); + + string? line; + + while ((line = await stringReader.ReadLineAsync().ConfigureAwait(false)) != null) { + // Special case for empty newline + if (line.Length == 0) { + if (messagePart.Length > prefixLength) { + messagePartBytes += NewlineWeight; + messagePart.AppendLine(); } - prefixLength = steamMessagePrefix.Length; - } - - int maxMessageBytes = (isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts) - ReservedContinuationMessageBytes; - - // We must escape our message prior to sending it - message = Escape(message); - - int messagePartBytes = prefixBytes; - StringBuilder messagePart = new(steamMessagePrefix); - - Decoder decoder = Encoding.UTF8.GetDecoder(); - ArrayPool charPool = ArrayPool.Shared; - - using StringReader stringReader = new(message); - - string? line; - - while ((line = await stringReader.ReadLineAsync().ConfigureAwait(false)) != null) { - // Special case for empty newline - if (line.Length == 0) { - if (messagePart.Length > prefixLength) { - messagePartBytes += NewlineWeight; - messagePart.AppendLine(); - } - - // Check if we reached the limit for one message - if (messagePartBytes + NewlineWeight + ReservedEscapeMessageBytes > maxMessageBytes) { - if (stringReader.Peek() >= 0) { - messagePart.Append(ParagraphCharacter); - } - - yield return messagePart.ToString(); - - messagePartBytes = prefixBytes; - messagePart.Clear(); - messagePart.Append(steamMessagePrefix); - } - - // Move on to the next line - continue; - } - - byte[] lineBytes = Encoding.UTF8.GetBytes(line); - - for (int lineBytesRead = 0; lineBytesRead < lineBytes.Length;) { - if (messagePart.Length > prefixLength) { - if (messagePartBytes + NewlineWeight + lineBytes.Length > maxMessageBytes) { - messagePart.Append(ParagraphCharacter); - - yield return messagePart.ToString(); - - messagePartBytes = prefixBytes; - messagePart.Clear(); - messagePart.Append(steamMessagePrefix); - } else { - messagePartBytes += NewlineWeight; - messagePart.AppendLine(); - } - } - - int bytesToTake = Math.Min(maxMessageBytes - messagePartBytes, lineBytes.Length - lineBytesRead); - - // We can never have more characters than bytes used, so this covers the worst case of 1-byte characters exclusively - char[] lineChunk = charPool.Rent(bytesToTake); - - try { - // We have to reset the decoder prior to using it, as we must discard any amount of bytes read from previous incomplete character - decoder.Reset(); - - int charsUsed = decoder.GetChars(lineBytes, lineBytesRead, bytesToTake, lineChunk, 0, false); - - switch (charsUsed) { - case <= 0: - throw new InvalidOperationException(nameof(charsUsed)); - case >= 2 when (lineChunk[charsUsed - 1] == '\\') && (lineChunk[charsUsed - 2] != '\\'): - // If our message is of max length and ends with a single '\' then we can't split it here, because it escapes the next character - // Instead, we'll cut this message one char short and include the rest in the next iteration - charsUsed--; - - break; - } - - int bytesUsed = Encoding.UTF8.GetByteCount(lineChunk, 0, charsUsed); - - if (lineBytesRead > 0) { - messagePartBytes += ContinuationCharacterBytes; - messagePart.Append(ContinuationCharacter); - } - - lineBytesRead += bytesUsed; - - messagePartBytes += bytesUsed; - messagePart.Append(lineChunk, 0, charsUsed); - } finally { - charPool.Return(lineChunk); - } - - bool midLineSplitting = false; - - if (lineBytesRead < lineBytes.Length) { - midLineSplitting = true; - - messagePartBytes += ContinuationCharacterBytes; - messagePart.Append(ContinuationCharacter); - } - - // Check if we still have room for one more line - if (messagePartBytes + NewlineWeight + ReservedEscapeMessageBytes <= maxMessageBytes) { - continue; - } - - if (!midLineSplitting && (stringReader.Peek() >= 0)) { + // Check if we reached the limit for one message + if (messagePartBytes + NewlineWeight + ReservedEscapeMessageBytes > maxMessageBytes) { + if (stringReader.Peek() >= 0) { messagePart.Append(ParagraphCharacter); } @@ -179,47 +96,130 @@ namespace ArchiSteamFarm.Steam.Integration { messagePart.Clear(); messagePart.Append(steamMessagePrefix); } + + // Move on to the next line + continue; } - if (messagePart.Length <= prefixLength) { - yield break; - } + byte[] lineBytes = Encoding.UTF8.GetBytes(line); - yield return messagePart.ToString(); + for (int lineBytesRead = 0; lineBytesRead < lineBytes.Length;) { + if (messagePart.Length > prefixLength) { + if (messagePartBytes + NewlineWeight + lineBytes.Length > maxMessageBytes) { + messagePart.Append(ParagraphCharacter); + + yield return messagePart.ToString(); + + messagePartBytes = prefixBytes; + messagePart.Clear(); + messagePart.Append(steamMessagePrefix); + } else { + messagePartBytes += NewlineWeight; + messagePart.AppendLine(); + } + } + + int bytesToTake = Math.Min(maxMessageBytes - messagePartBytes, lineBytes.Length - lineBytesRead); + + // We can never have more characters than bytes used, so this covers the worst case of 1-byte characters exclusively + char[] lineChunk = charPool.Rent(bytesToTake); + + try { + // We have to reset the decoder prior to using it, as we must discard any amount of bytes read from previous incomplete character + decoder.Reset(); + + int charsUsed = decoder.GetChars(lineBytes, lineBytesRead, bytesToTake, lineChunk, 0, false); + + switch (charsUsed) { + case <= 0: + throw new InvalidOperationException(nameof(charsUsed)); + case >= 2 when (lineChunk[charsUsed - 1] == '\\') && (lineChunk[charsUsed - 2] != '\\'): + // If our message is of max length and ends with a single '\' then we can't split it here, because it escapes the next character + // Instead, we'll cut this message one char short and include the rest in the next iteration + charsUsed--; + + break; + } + + int bytesUsed = Encoding.UTF8.GetByteCount(lineChunk, 0, charsUsed); + + if (lineBytesRead > 0) { + messagePartBytes += ContinuationCharacterBytes; + messagePart.Append(ContinuationCharacter); + } + + lineBytesRead += bytesUsed; + + messagePartBytes += bytesUsed; + messagePart.Append(lineChunk, 0, charsUsed); + } finally { + charPool.Return(lineChunk); + } + + bool midLineSplitting = false; + + if (lineBytesRead < lineBytes.Length) { + midLineSplitting = true; + + messagePartBytes += ContinuationCharacterBytes; + messagePart.Append(ContinuationCharacter); + } + + // Check if we still have room for one more line + if (messagePartBytes + NewlineWeight + ReservedEscapeMessageBytes <= maxMessageBytes) { + continue; + } + + if (!midLineSplitting && (stringReader.Peek() >= 0)) { + messagePart.Append(ParagraphCharacter); + } + + yield return messagePart.ToString(); + + messagePartBytes = prefixBytes; + messagePart.Clear(); + messagePart.Append(steamMessagePrefix); + } } - internal static bool IsValidPrefix(string steamMessagePrefix) { - if (string.IsNullOrEmpty(steamMessagePrefix)) { - throw new ArgumentNullException(nameof(steamMessagePrefix)); - } - - return GetMessagePrefixBytes(Escape(steamMessagePrefix)) <= MaxMessagePrefixBytes; + if (messagePart.Length <= prefixLength) { + yield break; } - internal static string Unescape(string message) { - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } + yield return messagePart.ToString(); + } - return message.Replace("\\[", "[", StringComparison.Ordinal).Replace("\\\\", "\\", StringComparison.Ordinal); + internal static bool IsValidPrefix(string steamMessagePrefix) { + if (string.IsNullOrEmpty(steamMessagePrefix)) { + throw new ArgumentNullException(nameof(steamMessagePrefix)); } - private static string Escape(string message) { - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } + return GetMessagePrefixBytes(Escape(steamMessagePrefix)) <= MaxMessagePrefixBytes; + } - return message.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("[", "\\[", StringComparison.Ordinal); + internal static string Unescape(string message) { + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); } - private static int GetMessagePrefixBytes(string escapedSteamMessagePrefix) { - if (string.IsNullOrEmpty(escapedSteamMessagePrefix)) { - throw new ArgumentNullException(nameof(escapedSteamMessagePrefix)); - } + return message.Replace("\\[", "[", StringComparison.Ordinal).Replace("\\\\", "\\", StringComparison.Ordinal); + } - string[] prefixLines = escapedSteamMessagePrefix.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); - - return prefixLines.Where(static prefixLine => prefixLine.Length > 0).Sum(Encoding.UTF8.GetByteCount) + ((prefixLines.Length - 1) * NewlineWeight); + private static string Escape(string message) { + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); } + + return message.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("[", "\\[", StringComparison.Ordinal); + } + + private static int GetMessagePrefixBytes(string escapedSteamMessagePrefix) { + if (string.IsNullOrEmpty(escapedSteamMessagePrefix)) { + throw new ArgumentNullException(nameof(escapedSteamMessagePrefix)); + } + + string[] prefixLines = escapedSteamMessagePrefix.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); + + return prefixLines.Where(static prefixLine => prefixLine.Length > 0).Sum(Encoding.UTF8.GetByteCount) + ((prefixLines.Length - 1) * NewlineWeight); } } diff --git a/ArchiSteamFarm/Steam/Integration/SteamPICSChanges.cs b/ArchiSteamFarm/Steam/Integration/SteamPICSChanges.cs index 7e8fcdee2..520a6b893 100644 --- a/ArchiSteamFarm/Steam/Integration/SteamPICSChanges.cs +++ b/ArchiSteamFarm/Steam/Integration/SteamPICSChanges.cs @@ -28,102 +28,102 @@ using ArchiSteamFarm.Plugins; using ArchiSteamFarm.Web; using SteamKit2; -namespace ArchiSteamFarm.Steam.Integration { - internal static class SteamPICSChanges { - private const byte RefreshTimerInMinutes = 5; +namespace ArchiSteamFarm.Steam.Integration; - internal static bool LiveUpdate { get; private set; } +internal static class SteamPICSChanges { + private const byte RefreshTimerInMinutes = 5; - private static readonly SemaphoreSlim RefreshSemaphore = new(1, 1); - private static readonly Timer RefreshTimer = new(RefreshChanges); + internal static bool LiveUpdate { get; private set; } - private static uint LastChangeNumber; - private static bool TimerAlreadySet; + private static readonly SemaphoreSlim RefreshSemaphore = new(1, 1); + private static readonly Timer RefreshTimer = new(RefreshChanges); - internal static void Init(uint changeNumberToStartFrom) => LastChangeNumber = changeNumberToStartFrom; + private static uint LastChangeNumber; + private static bool TimerAlreadySet; - internal static void OnBotLoggedOn() { + internal static void Init(uint changeNumberToStartFrom) => LastChangeNumber = changeNumberToStartFrom; + + internal static void OnBotLoggedOn() { + if (TimerAlreadySet) { + return; + } + + // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that + lock (RefreshSemaphore) { if (TimerAlreadySet) { return; } - // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that - lock (RefreshSemaphore) { - if (TimerAlreadySet) { + TimerAlreadySet = true; + RefreshTimer.Change(TimeSpan.Zero, TimeSpan.FromMinutes(RefreshTimerInMinutes)); + } + } + + private static async void RefreshChanges(object? state = null) { + if (!await RefreshSemaphore.WaitAsync(0).ConfigureAwait(false)) { + return; + } + + try { + Bot? refreshBot = null; + SteamApps.PICSChangesCallback? picsChanges = null; + + for (byte i = 0; (i < WebBrowser.MaxTries) && (picsChanges == null); i++) { + refreshBot = Bot.Bots?.Values.FirstOrDefault(static bot => bot.IsConnectedAndLoggedOn); + + if (refreshBot == null) { + LiveUpdate = false; + return; } - TimerAlreadySet = true; - RefreshTimer.Change(TimeSpan.Zero, TimeSpan.FromMinutes(RefreshTimerInMinutes)); + try { + picsChanges = await refreshBot.SteamApps.PICSGetChangesSince(LastChangeNumber, true, true).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + refreshBot.ArchiLogger.LogGenericWarningException(e); + } } - } - private static async void RefreshChanges(object? state = null) { - if (!await RefreshSemaphore.WaitAsync(0).ConfigureAwait(false)) { + if ((refreshBot == null) || (picsChanges == null)) { + LiveUpdate = false; + ASF.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + return; } - try { - Bot? refreshBot = null; - SteamApps.PICSChangesCallback? picsChanges = null; + if (picsChanges.CurrentChangeNumber == picsChanges.LastChangeNumber) { + LiveUpdate = true; - for (byte i = 0; (i < WebBrowser.MaxTries) && (picsChanges == null); i++) { - refreshBot = Bot.Bots?.Values.FirstOrDefault(static bot => bot.IsConnectedAndLoggedOn); + return; + } - if (refreshBot == null) { - LiveUpdate = false; + LastChangeNumber = picsChanges.CurrentChangeNumber; - return; - } - - try { - picsChanges = await refreshBot.SteamApps.PICSGetChangesSince(LastChangeNumber, true, true).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - refreshBot.ArchiLogger.LogGenericWarningException(e); - } - } - - if ((refreshBot == null) || (picsChanges == null)) { - LiveUpdate = false; - ASF.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return; - } - - if (picsChanges.CurrentChangeNumber == picsChanges.LastChangeNumber) { - LiveUpdate = true; - - return; - } - - LastChangeNumber = picsChanges.CurrentChangeNumber; - - if (picsChanges.RequiresFullAppUpdate || picsChanges.RequiresFullPackageUpdate) { - if (ASF.GlobalDatabase != null) { - await ASF.GlobalDatabase.OnPICSChangesRestart(picsChanges.CurrentChangeNumber).ConfigureAwait(false); - } - - LiveUpdate = true; - - await PluginsCore.OnPICSChangesRestart(picsChanges.CurrentChangeNumber).ConfigureAwait(false); - - return; + if (picsChanges.RequiresFullAppUpdate || picsChanges.RequiresFullPackageUpdate) { + if (ASF.GlobalDatabase != null) { + await ASF.GlobalDatabase.OnPICSChangesRestart(picsChanges.CurrentChangeNumber).ConfigureAwait(false); } LiveUpdate = true; - if (ASF.GlobalDatabase != null) { - ASF.GlobalDatabase.LastChangeNumber = picsChanges.CurrentChangeNumber; + await PluginsCore.OnPICSChangesRestart(picsChanges.CurrentChangeNumber).ConfigureAwait(false); - if (picsChanges.PackageChanges.Count > 0) { - await ASF.GlobalDatabase.RefreshPackages(refreshBot, picsChanges.PackageChanges.ToDictionary(static package => package.Key, static package => package.Value.ChangeNumber)).ConfigureAwait(false); - } - } - - await PluginsCore.OnPICSChanges(picsChanges.CurrentChangeNumber, picsChanges.AppChanges, picsChanges.PackageChanges).ConfigureAwait(false); - } finally { - RefreshSemaphore.Release(); + return; } + + LiveUpdate = true; + + if (ASF.GlobalDatabase != null) { + ASF.GlobalDatabase.LastChangeNumber = picsChanges.CurrentChangeNumber; + + if (picsChanges.PackageChanges.Count > 0) { + await ASF.GlobalDatabase.RefreshPackages(refreshBot, picsChanges.PackageChanges.ToDictionary(static package => package.Key, static package => package.Value.ChangeNumber)).ConfigureAwait(false); + } + } + + await PluginsCore.OnPICSChanges(picsChanges.CurrentChangeNumber, picsChanges.AppChanges, picsChanges.PackageChanges).ConfigureAwait(false); + } finally { + RefreshSemaphore.Release(); } } } diff --git a/ArchiSteamFarm/Steam/Integration/SteamSaleEvent.cs b/ArchiSteamFarm/Steam/Integration/SteamSaleEvent.cs index a8ce4abb8..0fe1c9e41 100644 --- a/ArchiSteamFarm/Steam/Integration/SteamSaleEvent.cs +++ b/ArchiSteamFarm/Steam/Integration/SteamSaleEvent.cs @@ -28,92 +28,92 @@ using AngleSharp.Dom; using ArchiSteamFarm.Core; using ArchiSteamFarm.Localization; -namespace ArchiSteamFarm.Steam.Integration { - internal sealed class SteamSaleEvent : IAsyncDisposable { - private const byte MaxSingleQueuesDaily = 3; // This is only a failsafe for infinite queue clearing (in case IsDiscoveryQueueAvailable() would fail us) +namespace ArchiSteamFarm.Steam.Integration; - private readonly Bot Bot; +internal sealed class SteamSaleEvent : IAsyncDisposable { + private const byte MaxSingleQueuesDaily = 3; // This is only a failsafe for infinite queue clearing (in case IsDiscoveryQueueAvailable() would fail us) + + private readonly Bot Bot; #pragma warning disable CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private readonly Timer SaleEventTimer; + private readonly Timer SaleEventTimer; #pragma warning restore CA2213 // False positive, .NET Framework can't understand DisposeAsync() - internal SteamSaleEvent(Bot bot) { - Bot = bot ?? throw new ArgumentNullException(nameof(bot)); + internal SteamSaleEvent(Bot bot) { + Bot = bot ?? throw new ArgumentNullException(nameof(bot)); - SaleEventTimer = new Timer( - ExploreDiscoveryQueue, - null, - TimeSpan.FromHours(1.1) + TimeSpan.FromSeconds(ASF.LoadBalancingDelay * Bot.Bots?.Count ?? 0), // Delay - TimeSpan.FromHours(8.1) // Period - ); + SaleEventTimer = new Timer( + ExploreDiscoveryQueue, + null, + TimeSpan.FromHours(1.1) + TimeSpan.FromSeconds(ASF.LoadBalancingDelay * Bot.Bots?.Count ?? 0), // Delay + TimeSpan.FromHours(8.1) // Period + ); + } + + public ValueTask DisposeAsync() => SaleEventTimer.DisposeAsync(); + + private async void ExploreDiscoveryQueue(object? state = null) { + if (!Bot.IsConnectedAndLoggedOn) { + return; } - public ValueTask DisposeAsync() => SaleEventTimer.DisposeAsync(); + Bot.ArchiLogger.LogGenericTrace(Strings.Starting); + + for (byte i = 0; (i < MaxSingleQueuesDaily) && Bot.IsConnectedAndLoggedOn && (await IsDiscoveryQueueAvailable().ConfigureAwait(false)).GetValueOrDefault(); i++) { + ImmutableHashSet? queue = await Bot.ArchiWebHandler.GenerateNewDiscoveryQueue().ConfigureAwait(false); + + if ((queue == null) || (queue.Count == 0)) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(queue))); + + break; + } + + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.ClearingDiscoveryQueue, i)); + + // We could in theory do this in parallel, but who knows what would happen... + foreach (uint queuedAppID in queue) { + if (await Bot.ArchiWebHandler.ClearFromDiscoveryQueue(queuedAppID).ConfigureAwait(false)) { + continue; + } + + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - private async void ExploreDiscoveryQueue(object? state = null) { - if (!Bot.IsConnectedAndLoggedOn) { return; } - Bot.ArchiLogger.LogGenericTrace(Strings.Starting); - - for (byte i = 0; (i < MaxSingleQueuesDaily) && Bot.IsConnectedAndLoggedOn && (await IsDiscoveryQueueAvailable().ConfigureAwait(false)).GetValueOrDefault(); i++) { - ImmutableHashSet? queue = await Bot.ArchiWebHandler.GenerateNewDiscoveryQueue().ConfigureAwait(false); - - if ((queue == null) || (queue.Count == 0)) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(queue))); - - break; - } - - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.ClearingDiscoveryQueue, i)); - - // We could in theory do this in parallel, but who knows what would happen... - foreach (uint queuedAppID in queue) { - if (await Bot.ArchiWebHandler.ClearFromDiscoveryQueue(queuedAppID).ConfigureAwait(false)) { - continue; - } - - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return; - } - - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.DoneClearingDiscoveryQueue, i)); - } - - Bot.ArchiLogger.LogGenericTrace(Strings.Done); + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.DoneClearingDiscoveryQueue, i)); } - private async Task IsDiscoveryQueueAvailable() { - using IDocument? htmlDocument = await Bot.ArchiWebHandler.GetDiscoveryQueuePage().ConfigureAwait(false); + Bot.ArchiLogger.LogGenericTrace(Strings.Done); + } - if (htmlDocument == null) { - return null; - } + private async Task IsDiscoveryQueueAvailable() { + using IDocument? htmlDocument = await Bot.ArchiWebHandler.GetDiscoveryQueuePage().ConfigureAwait(false); - IElement? htmlNode = htmlDocument.SelectSingleNode("//div[@class='subtext']"); - - if (htmlNode == null) { - // Valid, no cards for exploring the queue available - return false; - } - - string text = htmlNode.TextContent; - - if (string.IsNullOrEmpty(text)) { - Bot.ArchiLogger.LogNullError(nameof(text)); - - return null; - } - - if (Debugging.IsUserDebugging) { - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, text)); - } - - // It'd make more sense to check against "Come back tomorrow", but it might not cover out-of-the-event queue - return text.StartsWith("You can get ", StringComparison.Ordinal); + if (htmlDocument == null) { + return null; } + + IElement? htmlNode = htmlDocument.SelectSingleNode("//div[@class='subtext']"); + + if (htmlNode == null) { + // Valid, no cards for exploring the queue available + return false; + } + + string text = htmlNode.TextContent; + + if (string.IsNullOrEmpty(text)) { + Bot.ArchiLogger.LogNullError(nameof(text)); + + return null; + } + + if (Debugging.IsUserDebugging) { + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, text)); + } + + // It'd make more sense to check against "Come back tomorrow", but it might not cover out-of-the-event queue + return text.StartsWith("You can get ", StringComparison.Ordinal); } } diff --git a/ArchiSteamFarm/Steam/Interaction/Actions.cs b/ArchiSteamFarm/Steam/Interaction/Actions.cs index dd0636a0f..a7ee3d1af 100644 --- a/ArchiSteamFarm/Steam/Interaction/Actions.cs +++ b/ArchiSteamFarm/Steam/Interaction/Actions.cs @@ -42,504 +42,504 @@ using ArchiSteamFarm.Web; using JetBrains.Annotations; using SteamKit2; -namespace ArchiSteamFarm.Steam.Interaction { - public sealed class Actions : IAsyncDisposable { - private static readonly SemaphoreSlim GiftCardsSemaphore = new(1, 1); +namespace ArchiSteamFarm.Steam.Interaction; - private readonly Bot Bot; - private readonly ConcurrentHashSet HandledGifts = new(); - private readonly SemaphoreSlim TradingSemaphore = new(1, 1); +public sealed class Actions : IAsyncDisposable { + private static readonly SemaphoreSlim GiftCardsSemaphore = new(1, 1); + + private readonly Bot Bot; + private readonly ConcurrentHashSet HandledGifts = new(); + private readonly SemaphoreSlim TradingSemaphore = new(1, 1); #pragma warning disable CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private Timer? CardsFarmerResumeTimer; + private Timer? CardsFarmerResumeTimer; #pragma warning restore CA2213 // False positive, .NET Framework can't understand DisposeAsync() - private bool ProcessingGiftsScheduled; - private bool TradingScheduled; + private bool ProcessingGiftsScheduled; + private bool TradingScheduled; - internal Actions(Bot bot) => Bot = bot ?? throw new ArgumentNullException(nameof(bot)); + internal Actions(Bot bot) => Bot = bot ?? throw new ArgumentNullException(nameof(bot)); - public async ValueTask DisposeAsync() { - // Those are objects that are always being created if constructor doesn't throw exception - TradingSemaphore.Dispose(); + public async ValueTask DisposeAsync() { + // Those are objects that are always being created if constructor doesn't throw exception + TradingSemaphore.Dispose(); - // Those are objects that might be null and the check should be in-place - if (CardsFarmerResumeTimer != null) { - await CardsFarmerResumeTimer.DisposeAsync().ConfigureAwait(false); - } + // Those are objects that might be null and the check should be in-place + if (CardsFarmerResumeTimer != null) { + await CardsFarmerResumeTimer.DisposeAsync().ConfigureAwait(false); + } + } + + [PublicAPI] + public static string? Encrypt(ArchiCryptoHelper.ECryptoMethod cryptoMethod, string stringToEncrypt) { + if (!Enum.IsDefined(typeof(ArchiCryptoHelper.ECryptoMethod), cryptoMethod)) { + throw new InvalidEnumArgumentException(nameof(cryptoMethod), (int) cryptoMethod, typeof(ArchiCryptoHelper.ECryptoMethod)); } - [PublicAPI] - public static string? Encrypt(ArchiCryptoHelper.ECryptoMethod cryptoMethod, string stringToEncrypt) { - if (!Enum.IsDefined(typeof(ArchiCryptoHelper.ECryptoMethod), cryptoMethod)) { - throw new InvalidEnumArgumentException(nameof(cryptoMethod), (int) cryptoMethod, typeof(ArchiCryptoHelper.ECryptoMethod)); - } - - if (string.IsNullOrEmpty(stringToEncrypt)) { - throw new ArgumentNullException(nameof(stringToEncrypt)); - } - - return ArchiCryptoHelper.Encrypt(cryptoMethod, stringToEncrypt); + if (string.IsNullOrEmpty(stringToEncrypt)) { + throw new ArgumentNullException(nameof(stringToEncrypt)); } - [PublicAPI] - public static (bool Success, string Message) Exit() { - // Schedule the task after some time so user can receive response - Utilities.InBackground( - static async () => { - await Task.Delay(1000).ConfigureAwait(false); - await Program.Exit().ConfigureAwait(false); - } - ); + return ArchiCryptoHelper.Encrypt(cryptoMethod, stringToEncrypt); + } - return (true, Strings.Done); + [PublicAPI] + public static (bool Success, string Message) Exit() { + // Schedule the task after some time so user can receive response + Utilities.InBackground( + static async () => { + await Task.Delay(1000).ConfigureAwait(false); + await Program.Exit().ConfigureAwait(false); + } + ); + + return (true, Strings.Done); + } + + [PublicAPI] + public async Task<(bool Success, string? Token, string Message)> GenerateTwoFactorAuthenticationToken() { + if (Bot.BotDatabase.MobileAuthenticator == null) { + return (false, null, Strings.BotNoASFAuthenticator); } - [PublicAPI] - public async Task<(bool Success, string? Token, string Message)> GenerateTwoFactorAuthenticationToken() { - if (Bot.BotDatabase.MobileAuthenticator == null) { - return (false, null, Strings.BotNoASFAuthenticator); - } + string? token = await Bot.BotDatabase.MobileAuthenticator.GenerateToken().ConfigureAwait(false); - string? token = await Bot.BotDatabase.MobileAuthenticator.GenerateToken().ConfigureAwait(false); + bool success = !string.IsNullOrEmpty(token); - bool success = !string.IsNullOrEmpty(token); + return (success, token, success ? Strings.Success : Strings.WarningFailed); + } - return (success, token, success ? Strings.Success : Strings.WarningFailed); + [PublicAPI] + public async Task GetTradingLock() { + await TradingSemaphore.WaitAsync().ConfigureAwait(false); + + return new SemaphoreLock(TradingSemaphore); + } + + [PublicAPI] + public async Task<(bool Success, IReadOnlyCollection? HandledConfirmations, string Message)> HandleTwoFactorAuthenticationConfirmations(bool accept, Confirmation.EType? acceptedType = null, IReadOnlyCollection? acceptedCreatorIDs = null, bool waitIfNeeded = false) { + if (Bot.BotDatabase.MobileAuthenticator == null) { + return (false, null, Strings.BotNoASFAuthenticator); } - [PublicAPI] - public async Task GetTradingLock() { - await TradingSemaphore.WaitAsync().ConfigureAwait(false); - - return new SemaphoreLock(TradingSemaphore); + if (!Bot.IsConnectedAndLoggedOn) { + return (false, null, Strings.BotNotConnected); } - [PublicAPI] - public async Task<(bool Success, IReadOnlyCollection? HandledConfirmations, string Message)> HandleTwoFactorAuthenticationConfirmations(bool accept, Confirmation.EType? acceptedType = null, IReadOnlyCollection? acceptedCreatorIDs = null, bool waitIfNeeded = false) { - if (Bot.BotDatabase.MobileAuthenticator == null) { - return (false, null, Strings.BotNoASFAuthenticator); + Dictionary? handledConfirmations = null; + + for (byte i = 0; (i == 0) || ((i < WebBrowser.MaxTries) && waitIfNeeded); i++) { + if (i > 0) { + await Task.Delay(1000).ConfigureAwait(false); } - if (!Bot.IsConnectedAndLoggedOn) { - return (false, null, Strings.BotNotConnected); + HashSet? confirmations = await Bot.BotDatabase.MobileAuthenticator.GetConfirmations().ConfigureAwait(false); + + if ((confirmations == null) || (confirmations.Count == 0)) { + continue; } - Dictionary? handledConfirmations = null; - - for (byte i = 0; (i == 0) || ((i < WebBrowser.MaxTries) && waitIfNeeded); i++) { - if (i > 0) { - await Task.Delay(1000).ConfigureAwait(false); - } - - HashSet? confirmations = await Bot.BotDatabase.MobileAuthenticator.GetConfirmations().ConfigureAwait(false); - - if ((confirmations == null) || (confirmations.Count == 0)) { - continue; - } - - if (acceptedType.HasValue) { - if (confirmations.RemoveWhere(confirmation => confirmation.Type != acceptedType.Value) > 0) { - if (confirmations.Count == 0) { - continue; - } - } - } - - if (acceptedCreatorIDs?.Count > 0) { - if (confirmations.RemoveWhere(confirmation => !acceptedCreatorIDs.Contains(confirmation.Creator)) > 0) { - if (confirmations.Count == 0) { - continue; - } - } - } - - if (!await Bot.BotDatabase.MobileAuthenticator.HandleConfirmations(confirmations, accept).ConfigureAwait(false)) { - return (false, handledConfirmations?.Values, Strings.WarningFailed); - } - - handledConfirmations ??= new Dictionary(); - - foreach (Confirmation? confirmation in confirmations) { - handledConfirmations[confirmation.Creator] = confirmation; - } - - if (acceptedCreatorIDs?.Count > 0) { - // Check if those are all that we were expected to confirm - if ((handledConfirmations.Count >= acceptedCreatorIDs.Count) && acceptedCreatorIDs.All(handledConfirmations.ContainsKey)) { - return (true, handledConfirmations.Values, string.Format(CultureInfo.CurrentCulture, Strings.BotHandledConfirmations, handledConfirmations.Count)); + if (acceptedType.HasValue) { + if (confirmations.RemoveWhere(confirmation => confirmation.Type != acceptedType.Value) > 0) { + if (confirmations.Count == 0) { + continue; } } } - bool success = !waitIfNeeded || ((handledConfirmations?.Count > 0) && ((acceptedCreatorIDs == null) || (acceptedCreatorIDs.Count == 0))); - - return (success, handledConfirmations?.Values, success ? string.Format(CultureInfo.CurrentCulture, Strings.BotHandledConfirmations, handledConfirmations?.Count ?? 0) : string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); - } - - [PublicAPI] - public static string Hash(ArchiCryptoHelper.EHashingMethod hashingMethod, string stringToHash) { - if (!Enum.IsDefined(typeof(ArchiCryptoHelper.EHashingMethod), hashingMethod)) { - throw new InvalidEnumArgumentException(nameof(hashingMethod), (int) hashingMethod, typeof(ArchiCryptoHelper.EHashingMethod)); - } - - if (string.IsNullOrEmpty(stringToHash)) { - throw new ArgumentNullException(nameof(stringToHash)); - } - - return ArchiCryptoHelper.Hash(hashingMethod, stringToHash); - } - - [PublicAPI] - public async Task<(bool Success, string Message)> Pause(bool permanent, ushort resumeInSeconds = 0) { - if (Bot.CardsFarmer.Paused) { - return (false, Strings.BotAutomaticIdlingPausedAlready); - } - - await Bot.CardsFarmer.Pause(permanent).ConfigureAwait(false); - - if (!permanent && (Bot.BotConfig.GamesPlayedWhileIdle.Count > 0)) { - // We want to let family sharing users access our library, and in this case we must also stop GamesPlayedWhileIdle - // We add extra delay because OnFarmingStopped() also executes PlayGames() - // Despite of proper order on our end, Steam network might not respect it - await Task.Delay(Bot.CallbackSleep).ConfigureAwait(false); - await Bot.ArchiHandler.PlayGames(Array.Empty(), Bot.BotConfig.CustomGamePlayedWhileIdle).ConfigureAwait(false); - } - - if (resumeInSeconds > 0) { - if (CardsFarmerResumeTimer == null) { - CardsFarmerResumeTimer = new Timer( - _ => Resume(), - null, - TimeSpan.FromSeconds(resumeInSeconds), // Delay - Timeout.InfiniteTimeSpan // Period - ); - } else { - CardsFarmerResumeTimer.Change(TimeSpan.FromSeconds(resumeInSeconds), Timeout.InfiniteTimeSpan); + if (acceptedCreatorIDs?.Count > 0) { + if (confirmations.RemoveWhere(confirmation => !acceptedCreatorIDs.Contains(confirmation.Creator)) > 0) { + if (confirmations.Count == 0) { + continue; + } } } - return (true, Strings.BotAutomaticIdlingNowPaused); - } - - [PublicAPI] - public async Task<(bool Success, string Message)> Play(IReadOnlyCollection gameIDs, string? gameName = null) { - if (gameIDs == null) { - throw new ArgumentNullException(nameof(gameIDs)); + if (!await Bot.BotDatabase.MobileAuthenticator.HandleConfirmations(confirmations, accept).ConfigureAwait(false)) { + return (false, handledConfirmations?.Values, Strings.WarningFailed); } - if (!Bot.IsConnectedAndLoggedOn) { - return (false, Strings.BotNotConnected); + handledConfirmations ??= new Dictionary(); + + foreach (Confirmation? confirmation in confirmations) { + handledConfirmations[confirmation.Creator] = confirmation; } - if (!Bot.CardsFarmer.Paused) { - await Bot.CardsFarmer.Pause(true).ConfigureAwait(false); - } - - await Bot.ArchiHandler.PlayGames(gameIDs, gameName).ConfigureAwait(false); - - return (true, gameIDs.Count > 0 ? string.Format(CultureInfo.CurrentCulture, Strings.BotIdlingSelectedGames, nameof(gameIDs), string.Join(", ", gameIDs)) : Strings.Done); - } - - [PublicAPI] - public async Task RedeemKey(string key) { - await LimitGiftsRequestsAsync().ConfigureAwait(false); - - return await Bot.ArchiHandler.RedeemKey(key).ConfigureAwait(false); - } - - [PublicAPI] - public static (bool Success, string Message) Restart() { - if (!Program.RestartAllowed) { - return (false, $"!{nameof(Program.RestartAllowed)}"); - } - - // Schedule the task after some time so user can receive response - Utilities.InBackground( - static async () => { - await Task.Delay(1000).ConfigureAwait(false); - await Program.Restart().ConfigureAwait(false); + if (acceptedCreatorIDs?.Count > 0) { + // Check if those are all that we were expected to confirm + if ((handledConfirmations.Count >= acceptedCreatorIDs.Count) && acceptedCreatorIDs.All(handledConfirmations.ContainsKey)) { + return (true, handledConfirmations.Values, string.Format(CultureInfo.CurrentCulture, Strings.BotHandledConfirmations, handledConfirmations.Count)); } - ); - - return (true, Strings.Done); + } } - [PublicAPI] - public (bool Success, string Message) Resume() { - if (!Bot.CardsFarmer.Paused) { - return (false, Strings.BotAutomaticIdlingResumedAlready); - } + bool success = !waitIfNeeded || ((handledConfirmations?.Count > 0) && ((acceptedCreatorIDs == null) || (acceptedCreatorIDs.Count == 0))); - Utilities.InBackground(() => Bot.CardsFarmer.Resume(true)); + return (success, handledConfirmations?.Values, success ? string.Format(CultureInfo.CurrentCulture, Strings.BotHandledConfirmations, handledConfirmations?.Count ?? 0) : string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)); + } - return (true, Strings.BotAutomaticIdlingNowResumed); + [PublicAPI] + public static string Hash(ArchiCryptoHelper.EHashingMethod hashingMethod, string stringToHash) { + if (!Enum.IsDefined(typeof(ArchiCryptoHelper.EHashingMethod), hashingMethod)) { + throw new InvalidEnumArgumentException(nameof(hashingMethod), (int) hashingMethod, typeof(ArchiCryptoHelper.EHashingMethod)); } - [PublicAPI] - public async Task<(bool Success, string Message)> SendInventory(IReadOnlyCollection items, ulong targetSteamID = 0, string? tradeToken = null, ushort itemsPerTrade = Trading.MaxItemsPerTrade) { - if ((items == null) || (items.Count == 0)) { - throw new ArgumentNullException(nameof(items)); - } + if (string.IsNullOrEmpty(stringToHash)) { + throw new ArgumentNullException(nameof(stringToHash)); + } - if (itemsPerTrade < 2) { - throw new ArgumentOutOfRangeException(nameof(itemsPerTrade)); - } + return ArchiCryptoHelper.Hash(hashingMethod, stringToHash); + } - if (!Bot.IsConnectedAndLoggedOn) { - return (false, Strings.BotNotConnected); + [PublicAPI] + public async Task<(bool Success, string Message)> Pause(bool permanent, ushort resumeInSeconds = 0) { + if (Bot.CardsFarmer.Paused) { + return (false, Strings.BotAutomaticIdlingPausedAlready); + } + + await Bot.CardsFarmer.Pause(permanent).ConfigureAwait(false); + + if (!permanent && (Bot.BotConfig.GamesPlayedWhileIdle.Count > 0)) { + // We want to let family sharing users access our library, and in this case we must also stop GamesPlayedWhileIdle + // We add extra delay because OnFarmingStopped() also executes PlayGames() + // Despite of proper order on our end, Steam network might not respect it + await Task.Delay(Bot.CallbackSleep).ConfigureAwait(false); + await Bot.ArchiHandler.PlayGames(Array.Empty(), Bot.BotConfig.CustomGamePlayedWhileIdle).ConfigureAwait(false); + } + + if (resumeInSeconds > 0) { + if (CardsFarmerResumeTimer == null) { + CardsFarmerResumeTimer = new Timer( + _ => Resume(), + null, + TimeSpan.FromSeconds(resumeInSeconds), // Delay + Timeout.InfiniteTimeSpan // Period + ); + } else { + CardsFarmerResumeTimer.Change(TimeSpan.FromSeconds(resumeInSeconds), Timeout.InfiniteTimeSpan); } + } + + return (true, Strings.BotAutomaticIdlingNowPaused); + } + + [PublicAPI] + public async Task<(bool Success, string Message)> Play(IReadOnlyCollection gameIDs, string? gameName = null) { + if (gameIDs == null) { + throw new ArgumentNullException(nameof(gameIDs)); + } + + if (!Bot.IsConnectedAndLoggedOn) { + return (false, Strings.BotNotConnected); + } + + if (!Bot.CardsFarmer.Paused) { + await Bot.CardsFarmer.Pause(true).ConfigureAwait(false); + } + + await Bot.ArchiHandler.PlayGames(gameIDs, gameName).ConfigureAwait(false); + + return (true, gameIDs.Count > 0 ? string.Format(CultureInfo.CurrentCulture, Strings.BotIdlingSelectedGames, nameof(gameIDs), string.Join(", ", gameIDs)) : Strings.Done); + } + + [PublicAPI] + public async Task RedeemKey(string key) { + await LimitGiftsRequestsAsync().ConfigureAwait(false); + + return await Bot.ArchiHandler.RedeemKey(key).ConfigureAwait(false); + } + + [PublicAPI] + public static (bool Success, string Message) Restart() { + if (!Program.RestartAllowed) { + return (false, $"!{nameof(Program.RestartAllowed)}"); + } + + // Schedule the task after some time so user can receive response + Utilities.InBackground( + static async () => { + await Task.Delay(1000).ConfigureAwait(false); + await Program.Restart().ConfigureAwait(false); + } + ); + + return (true, Strings.Done); + } + + [PublicAPI] + public (bool Success, string Message) Resume() { + if (!Bot.CardsFarmer.Paused) { + return (false, Strings.BotAutomaticIdlingResumedAlready); + } + + Utilities.InBackground(() => Bot.CardsFarmer.Resume(true)); + + return (true, Strings.BotAutomaticIdlingNowResumed); + } + + [PublicAPI] + public async Task<(bool Success, string Message)> SendInventory(IReadOnlyCollection items, ulong targetSteamID = 0, string? tradeToken = null, ushort itemsPerTrade = Trading.MaxItemsPerTrade) { + if ((items == null) || (items.Count == 0)) { + throw new ArgumentNullException(nameof(items)); + } + + if (itemsPerTrade < 2) { + throw new ArgumentOutOfRangeException(nameof(itemsPerTrade)); + } + + if (!Bot.IsConnectedAndLoggedOn) { + return (false, Strings.BotNotConnected); + } + + if (targetSteamID == 0) { + targetSteamID = GetFirstSteamMasterID(); if (targetSteamID == 0) { - targetSteamID = GetFirstSteamMasterID(); - - if (targetSteamID == 0) { - return (false, Strings.BotLootingMasterNotDefined); - } - - if (string.IsNullOrEmpty(tradeToken) && !string.IsNullOrEmpty(Bot.BotConfig.SteamTradeToken)) { - tradeToken = Bot.BotConfig.SteamTradeToken; - } - } else if (!new SteamID(targetSteamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(targetSteamID)); + return (false, Strings.BotLootingMasterNotDefined); } - if (targetSteamID == Bot.SteamID) { - return (false, Strings.BotSendingTradeToYourself); + if (string.IsNullOrEmpty(tradeToken) && !string.IsNullOrEmpty(Bot.BotConfig.SteamTradeToken)) { + tradeToken = Bot.BotConfig.SteamTradeToken; } + } else if (!new SteamID(targetSteamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(targetSteamID)); + } - if (!await Bot.ArchiWebHandler.MarkSentTrades().ConfigureAwait(false)) { + if (targetSteamID == Bot.SteamID) { + return (false, Strings.BotSendingTradeToYourself); + } + + if (!await Bot.ArchiWebHandler.MarkSentTrades().ConfigureAwait(false)) { + return (false, Strings.BotLootingFailed); + } + + if (string.IsNullOrEmpty(tradeToken) && (Bot.SteamFriends.GetFriendRelationship(targetSteamID) != EFriendRelationship.Friend)) { + Bot? targetBot = Bot.Bots?.Values.FirstOrDefault(bot => bot.SteamID == targetSteamID); + + if (targetBot?.IsConnectedAndLoggedOn == true) { + tradeToken = await targetBot.ArchiHandler.GetTradeToken().ConfigureAwait(false); + } + } + + (bool success, HashSet? mobileTradeOfferIDs) = await Bot.ArchiWebHandler.SendTradeOffer(targetSteamID, items, token: tradeToken, itemsPerTrade: itemsPerTrade).ConfigureAwait(false); + + if ((mobileTradeOfferIDs?.Count > 0) && Bot.HasMobileAuthenticator) { + (bool twoFactorSuccess, _, _) = await HandleTwoFactorAuthenticationConfirmations(true, Confirmation.EType.Trade, mobileTradeOfferIDs, true).ConfigureAwait(false); + + if (!twoFactorSuccess) { return (false, Strings.BotLootingFailed); } - - if (string.IsNullOrEmpty(tradeToken) && (Bot.SteamFriends.GetFriendRelationship(targetSteamID) != EFriendRelationship.Friend)) { - Bot? targetBot = Bot.Bots?.Values.FirstOrDefault(bot => bot.SteamID == targetSteamID); - - if (targetBot?.IsConnectedAndLoggedOn == true) { - tradeToken = await targetBot.ArchiHandler.GetTradeToken().ConfigureAwait(false); - } - } - - (bool success, HashSet? mobileTradeOfferIDs) = await Bot.ArchiWebHandler.SendTradeOffer(targetSteamID, items, token: tradeToken, itemsPerTrade: itemsPerTrade).ConfigureAwait(false); - - if ((mobileTradeOfferIDs?.Count > 0) && Bot.HasMobileAuthenticator) { - (bool twoFactorSuccess, _, _) = await HandleTwoFactorAuthenticationConfirmations(true, Confirmation.EType.Trade, mobileTradeOfferIDs, true).ConfigureAwait(false); - - if (!twoFactorSuccess) { - return (false, Strings.BotLootingFailed); - } - } - - return success ? (true, Strings.BotLootingSuccess) : (false, Strings.BotLootingFailed); } - [PublicAPI] - public async Task<(bool Success, string Message)> SendInventory(uint appID = Asset.SteamAppID, ulong contextID = Asset.SteamCommunityContextID, ulong targetSteamID = 0, string? tradeToken = null, Func? filterFunction = null, ushort itemsPerTrade = Trading.MaxItemsPerTrade) { - if (appID == 0) { - throw new ArgumentOutOfRangeException(nameof(appID)); + return success ? (true, Strings.BotLootingSuccess) : (false, Strings.BotLootingFailed); + } + + [PublicAPI] + public async Task<(bool Success, string Message)> SendInventory(uint appID = Asset.SteamAppID, ulong contextID = Asset.SteamCommunityContextID, ulong targetSteamID = 0, string? tradeToken = null, Func? filterFunction = null, ushort itemsPerTrade = Trading.MaxItemsPerTrade) { + if (appID == 0) { + throw new ArgumentOutOfRangeException(nameof(appID)); + } + + if (contextID == 0) { + throw new ArgumentOutOfRangeException(nameof(contextID)); + } + + if (!Bot.IsConnectedAndLoggedOn) { + return (false, Strings.BotNotConnected); + } + + filterFunction ??= static _ => true; + + HashSet inventory; + + // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that + lock (TradingSemaphore) { + if (TradingScheduled) { + return (false, Strings.ErrorAborted); } - if (contextID == 0) { - throw new ArgumentOutOfRangeException(nameof(contextID)); - } + TradingScheduled = true; + } - if (!Bot.IsConnectedAndLoggedOn) { - return (false, Strings.BotNotConnected); - } - - filterFunction ??= static _ => true; - - HashSet inventory; + await TradingSemaphore.WaitAsync().ConfigureAwait(false); + try { // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that lock (TradingSemaphore) { - if (TradingScheduled) { - return (false, Strings.ErrorAborted); - } - - TradingScheduled = true; + TradingScheduled = false; } - await TradingSemaphore.WaitAsync().ConfigureAwait(false); + inventory = await Bot.ArchiWebHandler.GetInventoryAsync(appID: appID, contextID: contextID).Where(item => item.Tradable && filterFunction(item)).ToHashSetAsync().ConfigureAwait(false); + } catch (HttpRequestException e) { + Bot.ArchiLogger.LogGenericWarningException(e); - try { - // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that - lock (TradingSemaphore) { - TradingScheduled = false; - } + return (false, string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, e.Message)); + } catch (Exception e) { + Bot.ArchiLogger.LogGenericException(e); - inventory = await Bot.ArchiWebHandler.GetInventoryAsync(appID: appID, contextID: contextID).Where(item => item.Tradable && filterFunction(item)).ToHashSetAsync().ConfigureAwait(false); - } catch (HttpRequestException e) { - Bot.ArchiLogger.LogGenericWarningException(e); - - return (false, string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, e.Message)); - } catch (Exception e) { - Bot.ArchiLogger.LogGenericException(e); - - return (false, string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, e.Message)); - } finally { - TradingSemaphore.Release(); - } - - if (inventory.Count == 0) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(inventory))); - } - - return await SendInventory(inventory, targetSteamID, tradeToken, itemsPerTrade).ConfigureAwait(false); + return (false, string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, e.Message)); + } finally { + TradingSemaphore.Release(); } - [PublicAPI] - public (bool Success, string Message) Start() { - if (Bot.KeepRunning) { - return (false, Strings.BotAlreadyRunning); - } - - Utilities.InBackground(Bot.Start); - - return (true, Strings.Done); + if (inventory.Count == 0) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(inventory))); } - [PublicAPI] - public (bool Success, string Message) Stop() { - if (!Bot.KeepRunning) { - return (false, Strings.BotAlreadyStopped); - } + return await SendInventory(inventory, targetSteamID, tradeToken, itemsPerTrade).ConfigureAwait(false); + } - Bot.Stop(); - - return (true, Strings.Done); + [PublicAPI] + public (bool Success, string Message) Start() { + if (Bot.KeepRunning) { + return (false, Strings.BotAlreadyRunning); } - [PublicAPI] - public static async Task<(bool Success, string? Message, Version? Version)> Update() { - Version? version = await ASF.Update(true).ConfigureAwait(false); + Utilities.InBackground(Bot.Start); - if (version == null) { - return (false, null, null); - } + return (true, Strings.Done); + } - if (SharedInfo.Version >= version) { - return (false, $"V{SharedInfo.Version} ≥ V{version}", version); - } - - Utilities.InBackground(ASF.RestartOrExit); - - return (true, null, version); + [PublicAPI] + public (bool Success, string Message) Stop() { + if (!Bot.KeepRunning) { + return (false, Strings.BotAlreadyStopped); } - internal async Task AcceptDigitalGiftCards() { - if (!Bot.IsConnectedAndLoggedOn) { + Bot.Stop(); + + return (true, Strings.Done); + } + + [PublicAPI] + public static async Task<(bool Success, string? Message, Version? Version)> Update() { + Version? version = await ASF.Update(true).ConfigureAwait(false); + + if (version == null) { + return (false, null, null); + } + + if (SharedInfo.Version >= version) { + return (false, $"V{SharedInfo.Version} ≥ V{version}", version); + } + + Utilities.InBackground(ASF.RestartOrExit); + + return (true, null, version); + } + + internal async Task AcceptDigitalGiftCards() { + if (!Bot.IsConnectedAndLoggedOn) { + return; + } + + // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that + lock (GiftCardsSemaphore) { + if (ProcessingGiftsScheduled) { return; } + ProcessingGiftsScheduled = true; + } + + await GiftCardsSemaphore.WaitAsync().ConfigureAwait(false); + + try { // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that lock (GiftCardsSemaphore) { - if (ProcessingGiftsScheduled) { - return; - } - - ProcessingGiftsScheduled = true; - } - - await GiftCardsSemaphore.WaitAsync().ConfigureAwait(false); - - try { - // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that - lock (GiftCardsSemaphore) { - ProcessingGiftsScheduled = false; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return; - } - - HashSet? giftCardIDs = await Bot.ArchiWebHandler.GetDigitalGiftCards().ConfigureAwait(false); - - if ((giftCardIDs == null) || (giftCardIDs.Count == 0)) { - return; - } - - foreach (ulong giftCardID in giftCardIDs.Where(gid => !HandledGifts.Contains(gid))) { - HandledGifts.Add(giftCardID); - - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotAcceptingGift, giftCardID)); - await LimitGiftsRequestsAsync().ConfigureAwait(false); - - bool result = await Bot.ArchiWebHandler.AcceptDigitalGiftCard(giftCardID).ConfigureAwait(false); - - if (result) { - Bot.ArchiLogger.LogGenericInfo(Strings.Success); - } else { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - } - } - } finally { - GiftCardsSemaphore.Release(); - } - } - - internal async Task AcceptGuestPasses(IReadOnlyCollection guestPassIDs) { - if ((guestPassIDs == null) || (guestPassIDs.Count == 0)) { - throw new ArgumentNullException(nameof(guestPassIDs)); + ProcessingGiftsScheduled = false; } if (!Bot.IsConnectedAndLoggedOn) { return; } - foreach (ulong guestPassID in guestPassIDs.Where(guestPassID => !HandledGifts.Contains(guestPassID))) { - HandledGifts.Add(guestPassID); + HashSet? giftCardIDs = await Bot.ArchiWebHandler.GetDigitalGiftCards().ConfigureAwait(false); - Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotAcceptingGift, guestPassID)); + if ((giftCardIDs == null) || (giftCardIDs.Count == 0)) { + return; + } + + foreach (ulong giftCardID in giftCardIDs.Where(gid => !HandledGifts.Contains(gid))) { + HandledGifts.Add(giftCardID); + + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotAcceptingGift, giftCardID)); await LimitGiftsRequestsAsync().ConfigureAwait(false); - ArchiHandler.RedeemGuestPassResponseCallback? response = await Bot.ArchiHandler.RedeemGuestPass(guestPassID).ConfigureAwait(false); + bool result = await Bot.ArchiWebHandler.AcceptDigitalGiftCard(giftCardID).ConfigureAwait(false); - if (response != null) { - if (response.Result == EResult.OK) { - Bot.ArchiLogger.LogGenericInfo(Strings.Success); - } else { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.Result)); - } + if (result) { + Bot.ArchiLogger.LogGenericInfo(Strings.Success); } else { Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); } } - } - - internal void OnDisconnected() => HandledGifts.Clear(); - - private ulong GetFirstSteamMasterID() { - ulong steamMasterID = Bot.BotConfig.SteamUserPermissions.Where(kv => (kv.Key > 0) && (kv.Key != Bot.SteamID) && new SteamID(kv.Key).IsIndividualAccount && (kv.Value == BotConfig.EAccess.Master)).Select(static kv => kv.Key).OrderBy(static steamID => steamID).FirstOrDefault(); - - if (steamMasterID > 0) { - return steamMasterID; - } - - ulong steamOwnerID = ASF.GlobalConfig?.SteamOwnerID ?? GlobalConfig.DefaultSteamOwnerID; - - return (steamOwnerID > 0) && new SteamID(steamOwnerID).IsIndividualAccount ? steamOwnerID : 0; - } - - private static async Task LimitGiftsRequestsAsync() { - if (ASF.GiftsSemaphore == null) { - throw new InvalidOperationException(nameof(ASF.GiftsSemaphore)); - } - - byte giftsLimiterDelay = ASF.GlobalConfig?.GiftsLimiterDelay ?? GlobalConfig.DefaultGiftsLimiterDelay; - - if (giftsLimiterDelay == 0) { - return; - } - - await ASF.GiftsSemaphore.WaitAsync().ConfigureAwait(false); - - Utilities.InBackground( - async () => { - await Task.Delay(giftsLimiterDelay * 1000).ConfigureAwait(false); - ASF.GiftsSemaphore.Release(); - } - ); + } finally { + GiftCardsSemaphore.Release(); } } + + internal async Task AcceptGuestPasses(IReadOnlyCollection guestPassIDs) { + if ((guestPassIDs == null) || (guestPassIDs.Count == 0)) { + throw new ArgumentNullException(nameof(guestPassIDs)); + } + + if (!Bot.IsConnectedAndLoggedOn) { + return; + } + + foreach (ulong guestPassID in guestPassIDs.Where(guestPassID => !HandledGifts.Contains(guestPassID))) { + HandledGifts.Add(guestPassID); + + Bot.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotAcceptingGift, guestPassID)); + await LimitGiftsRequestsAsync().ConfigureAwait(false); + + ArchiHandler.RedeemGuestPassResponseCallback? response = await Bot.ArchiHandler.RedeemGuestPass(guestPassID).ConfigureAwait(false); + + if (response != null) { + if (response.Result == EResult.OK) { + Bot.ArchiLogger.LogGenericInfo(Strings.Success); + } else { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, response.Result)); + } + } else { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + } + } + } + + internal void OnDisconnected() => HandledGifts.Clear(); + + private ulong GetFirstSteamMasterID() { + ulong steamMasterID = Bot.BotConfig.SteamUserPermissions.Where(kv => (kv.Key > 0) && (kv.Key != Bot.SteamID) && new SteamID(kv.Key).IsIndividualAccount && (kv.Value == BotConfig.EAccess.Master)).Select(static kv => kv.Key).OrderBy(static steamID => steamID).FirstOrDefault(); + + if (steamMasterID > 0) { + return steamMasterID; + } + + ulong steamOwnerID = ASF.GlobalConfig?.SteamOwnerID ?? GlobalConfig.DefaultSteamOwnerID; + + return (steamOwnerID > 0) && new SteamID(steamOwnerID).IsIndividualAccount ? steamOwnerID : 0; + } + + private static async Task LimitGiftsRequestsAsync() { + if (ASF.GiftsSemaphore == null) { + throw new InvalidOperationException(nameof(ASF.GiftsSemaphore)); + } + + byte giftsLimiterDelay = ASF.GlobalConfig?.GiftsLimiterDelay ?? GlobalConfig.DefaultGiftsLimiterDelay; + + if (giftsLimiterDelay == 0) { + return; + } + + await ASF.GiftsSemaphore.WaitAsync().ConfigureAwait(false); + + Utilities.InBackground( + async () => { + await Task.Delay(giftsLimiterDelay * 1000).ConfigureAwait(false); + ASF.GiftsSemaphore.Release(); + } + ); + } } diff --git a/ArchiSteamFarm/Steam/Interaction/Commands.cs b/ArchiSteamFarm/Steam/Interaction/Commands.cs index 40d9bfdd6..f0e40785d 100644 --- a/ArchiSteamFarm/Steam/Interaction/Commands.cs +++ b/ArchiSteamFarm/Steam/Interaction/Commands.cs @@ -40,3431 +40,3431 @@ using ArchiSteamFarm.Storage; using JetBrains.Annotations; using SteamKit2; -namespace ArchiSteamFarm.Steam.Interaction { - public sealed class Commands { - private const ushort SteamTypingStatusDelay = 10 * 1000; // Steam client broadcasts typing status each 10 seconds +namespace ArchiSteamFarm.Steam.Interaction; - private readonly Bot Bot; - private readonly Dictionary CachedGamesOwned = new(); +public sealed class Commands { + private const ushort SteamTypingStatusDelay = 10 * 1000; // Steam client broadcasts typing status each 10 seconds - internal Commands(Bot bot) => Bot = bot ?? throw new ArgumentNullException(nameof(bot)); + private readonly Bot Bot; + private readonly Dictionary CachedGamesOwned = new(); - [PublicAPI] - public static string FormatBotResponse(string response, string botName) { - if (string.IsNullOrEmpty(response)) { - throw new ArgumentNullException(nameof(response)); - } + internal Commands(Bot bot) => Bot = bot ?? throw new ArgumentNullException(nameof(bot)); - if (string.IsNullOrEmpty(botName)) { - throw new ArgumentNullException(nameof(botName)); - } - - return $"{Environment.NewLine}<{botName}> {response}"; + [PublicAPI] + public static string FormatBotResponse(string response, string botName) { + if (string.IsNullOrEmpty(response)) { + throw new ArgumentNullException(nameof(response)); } - [PublicAPI] - public string FormatBotResponse(string response) { - if (string.IsNullOrEmpty(response)) { - throw new ArgumentNullException(nameof(response)); - } - - return $"<{Bot.BotName}> {response}"; + if (string.IsNullOrEmpty(botName)) { + throw new ArgumentNullException(nameof(botName)); } - [PublicAPI] - public static string FormatStaticResponse(string response) { - if (string.IsNullOrEmpty(response)) { - throw new ArgumentNullException(nameof(response)); - } + return $"{Environment.NewLine}<{botName}> {response}"; + } - return $"<{SharedInfo.ASF}> {response}"; + [PublicAPI] + public string FormatBotResponse(string response) { + if (string.IsNullOrEmpty(response)) { + throw new ArgumentNullException(nameof(response)); } - [PublicAPI] - public async Task Response(ulong steamID, string message) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + return $"<{Bot.BotName}> {response}"; + } - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } + [PublicAPI] + public static string FormatStaticResponse(string response) { + if (string.IsNullOrEmpty(response)) { + throw new ArgumentNullException(nameof(response)); + } - string[] args = message.Split(Array.Empty(), StringSplitOptions.RemoveEmptyEntries); + return $"<{SharedInfo.ASF}> {response}"; + } - switch (args.Length) { - case 0: - throw new InvalidOperationException(nameof(args.Length)); - case 1: - switch (args[0].ToUpperInvariant()) { - case "2FA": - return await Response2FA(steamID).ConfigureAwait(false); - case "2FANO": - return await Response2FAConfirm(steamID, false).ConfigureAwait(false); - case "2FAOK": - return await Response2FAConfirm(steamID, true).ConfigureAwait(false); - case "BALANCE": - return ResponseWalletBalance(steamID); - case "BGR": - return ResponseBackgroundGamesRedeemer(steamID); - case "BL": - return ResponseBlacklist(steamID); - case "EXIT": - return ResponseExit(steamID); - case "FARM": - return await ResponseFarm(steamID).ConfigureAwait(false); - case "HELP": - return ResponseHelp(steamID); - case "IB": - return ResponseIdleBlacklist(steamID); - case "IQ": - return ResponseIdleQueue(steamID); - case "MAB": - return ResponseMatchActivelyBlacklist(steamID); - case "LEVEL": - return await ResponseLevel(steamID).ConfigureAwait(false); - case "LOOT": - return await ResponseLoot(steamID).ConfigureAwait(false); - case "PASSWORD": - return ResponsePassword(steamID); - case "PAUSE": - return await ResponsePause(steamID, true).ConfigureAwait(false); - case "PAUSE~": - return await ResponsePause(steamID, false).ConfigureAwait(false); - case "POINTS": - return await ResponsePointsBalance(steamID).ConfigureAwait(false); - case "RESET": - return await ResponseReset(steamID).ConfigureAwait(false); - case "RESUME": - return ResponseResume(steamID); - case "RESTART": - return ResponseRestart(steamID); - case "SA": - return await ResponseStatus(steamID, SharedInfo.ASF).ConfigureAwait(false); - case "START": - return ResponseStart(steamID); - case "STATS": - return ResponseStats(steamID); - case "STATUS": - return ResponseStatus(steamID).Response; - case "STOP": - return ResponseStop(steamID); - case "UNPACK": - return await ResponseUnpackBoosters(steamID).ConfigureAwait(false); - case "UPDATE": - return await ResponseUpdate(steamID).ConfigureAwait(false); - case "VERSION": - return ResponseVersion(steamID); - default: - string? pluginsResponse = await PluginsCore.OnBotCommand(Bot, steamID, message, args).ConfigureAwait(false); + [PublicAPI] + public async Task Response(ulong steamID, string message) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } - return !string.IsNullOrEmpty(pluginsResponse) ? pluginsResponse : ResponseUnknown(steamID); + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + string[] args = message.Split(Array.Empty(), StringSplitOptions.RemoveEmptyEntries); + + switch (args.Length) { + case 0: + throw new InvalidOperationException(nameof(args.Length)); + case 1: + switch (args[0].ToUpperInvariant()) { + case "2FA": + return await Response2FA(steamID).ConfigureAwait(false); + case "2FANO": + return await Response2FAConfirm(steamID, false).ConfigureAwait(false); + case "2FAOK": + return await Response2FAConfirm(steamID, true).ConfigureAwait(false); + case "BALANCE": + return ResponseWalletBalance(steamID); + case "BGR": + return ResponseBackgroundGamesRedeemer(steamID); + case "BL": + return ResponseBlacklist(steamID); + case "EXIT": + return ResponseExit(steamID); + case "FARM": + return await ResponseFarm(steamID).ConfigureAwait(false); + case "HELP": + return ResponseHelp(steamID); + case "IB": + return ResponseIdleBlacklist(steamID); + case "IQ": + return ResponseIdleQueue(steamID); + case "MAB": + return ResponseMatchActivelyBlacklist(steamID); + case "LEVEL": + return await ResponseLevel(steamID).ConfigureAwait(false); + case "LOOT": + return await ResponseLoot(steamID).ConfigureAwait(false); + case "PASSWORD": + return ResponsePassword(steamID); + case "PAUSE": + return await ResponsePause(steamID, true).ConfigureAwait(false); + case "PAUSE~": + return await ResponsePause(steamID, false).ConfigureAwait(false); + case "POINTS": + return await ResponsePointsBalance(steamID).ConfigureAwait(false); + case "RESET": + return await ResponseReset(steamID).ConfigureAwait(false); + case "RESUME": + return ResponseResume(steamID); + case "RESTART": + return ResponseRestart(steamID); + case "SA": + return await ResponseStatus(steamID, SharedInfo.ASF).ConfigureAwait(false); + case "START": + return ResponseStart(steamID); + case "STATS": + return ResponseStats(steamID); + case "STATUS": + return ResponseStatus(steamID).Response; + case "STOP": + return ResponseStop(steamID); + case "UNPACK": + return await ResponseUnpackBoosters(steamID).ConfigureAwait(false); + case "UPDATE": + return await ResponseUpdate(steamID).ConfigureAwait(false); + case "VERSION": + return ResponseVersion(steamID); + default: + string? pluginsResponse = await PluginsCore.OnBotCommand(Bot, steamID, message, args).ConfigureAwait(false); + + return !string.IsNullOrEmpty(pluginsResponse) ? pluginsResponse : ResponseUnknown(steamID); + } + default: + switch (args[0].ToUpperInvariant()) { + case "2FA": + return await Response2FA(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "2FANO": + return await Response2FAConfirm(steamID, Utilities.GetArgsAsText(args, 1, ","), false).ConfigureAwait(false); + case "2FAOK": + return await Response2FAConfirm(steamID, Utilities.GetArgsAsText(args, 1, ","), true).ConfigureAwait(false); + case "ADDLICENSE" when args.Length > 2: + return await ResponseAddLicense(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); + case "ADDLICENSE": + return await ResponseAddLicense(steamID, args[1]).ConfigureAwait(false); + case "BALANCE": + return await ResponseWalletBalance(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "BGR": + return await ResponseBackgroundGamesRedeemer(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "BL": + return await ResponseBlacklist(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "BLADD" when args.Length > 2: + return await ResponseBlacklistAdd(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); + case "BLADD": + return ResponseBlacklistAdd(steamID, args[1]); + case "BLRM" when args.Length > 2: + return await ResponseBlacklistRemove(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); + case "BLRM": + return ResponseBlacklistRemove(steamID, args[1]); + case "ENCRYPT" when args.Length > 2: + return ResponseEncrypt(steamID, args[1], Utilities.GetArgsAsText(message, 2)); + case "FARM": + return await ResponseFarm(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "HASH" when args.Length > 2: + return ResponseHash(steamID, args[1], Utilities.GetArgsAsText(message, 2)); + case "INPUT" when args.Length > 3: + return await ResponseInput(steamID, args[1], args[2], Utilities.GetArgsAsText(message, 3)).ConfigureAwait(false); + case "INPUT" when args.Length > 2: + return ResponseInput(steamID, args[1], args[2]); + case "IB": + return await ResponseIdleBlacklist(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "IBADD" when args.Length > 2: + return await ResponseIdleBlacklistAdd(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); + case "IBADD": + return ResponseIdleBlacklistAdd(steamID, args[1]); + case "IBRM" when args.Length > 2: + return await ResponseIdleBlacklistRemove(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); + case "IBRM": + return ResponseIdleBlacklistRemove(steamID, args[1]); + case "IQ": + return await ResponseIdleQueue(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "IQADD" when args.Length > 2: + return await ResponseIdleQueueAdd(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); + case "IQADD": + return ResponseIdleQueueAdd(steamID, args[1]); + case "IQRM" when args.Length > 2: + return await ResponseIdleQueueRemove(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); + case "IQRM": + return ResponseIdleQueueRemove(steamID, args[1]); + case "LEVEL": + return await ResponseLevel(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "LOOT": + return await ResponseLoot(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "LOOT^" when args.Length > 3: + return await ResponseAdvancedLoot(steamID, args[1], args[2], Utilities.GetArgsAsText(message, 3)).ConfigureAwait(false); + case "LOOT^" when args.Length > 2: + return await ResponseAdvancedLoot(steamID, args[1], args[2]).ConfigureAwait(false); + case "LOOT@" when args.Length > 2: + return await ResponseLootByRealAppIDs(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); + case "LOOT@": + return await ResponseLootByRealAppIDs(steamID, args[1]).ConfigureAwait(false); + case "LOOT%" when args.Length > 2: + return await ResponseLootByRealAppIDs(steamID, args[1], Utilities.GetArgsAsText(args, 2, ","), true).ConfigureAwait(false); + case "LOOT%": + return await ResponseLootByRealAppIDs(steamID, args[1], true).ConfigureAwait(false); + case "MAB": + return await ResponseMatchActivelyBlacklist(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "MABADD" when args.Length > 2: + return await ResponseMatchActivelyBlacklistAdd(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); + case "MABADD": + return ResponseMatchActivelyBlacklistAdd(steamID, args[1]); + case "MABRM" when args.Length > 2: + return await ResponseMatchActivelyBlacklistRemove(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); + case "MABRM": + return ResponseMatchActivelyBlacklistRemove(steamID, args[1]); + case "NICKNAME" when args.Length > 2: + return await ResponseNickname(steamID, args[1], Utilities.GetArgsAsText(message, 2)).ConfigureAwait(false); + case "NICKNAME": + return ResponseNickname(steamID, args[1]); + case "OA": + return await ResponseOwns(steamID, SharedInfo.ASF, Utilities.GetArgsAsText(message, 1)).ConfigureAwait(false); + case "OWNS" when args.Length > 2: + return await ResponseOwns(steamID, args[1], Utilities.GetArgsAsText(message, 2)).ConfigureAwait(false); + case "OWNS": + return (await ResponseOwns(steamID, args[1]).ConfigureAwait(false)).Response; + case "PASSWORD": + return await ResponsePassword(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "PAUSE": + return await ResponsePause(steamID, Utilities.GetArgsAsText(args, 1, ","), true).ConfigureAwait(false); + case "PAUSE~": + return await ResponsePause(steamID, Utilities.GetArgsAsText(args, 1, ","), false).ConfigureAwait(false); + case "PAUSE&" when args.Length > 2: + return await ResponsePause(steamID, args[1], true, Utilities.GetArgsAsText(message, 2)).ConfigureAwait(false); + case "PAUSE&": + return await ResponsePause(steamID, true, args[1]).ConfigureAwait(false); + case "PLAY" when args.Length > 2: + return await ResponsePlay(steamID, args[1], Utilities.GetArgsAsText(message, 2)).ConfigureAwait(false); + case "PLAY": + return await ResponsePlay(steamID, args[1]).ConfigureAwait(false); + case "POINTS": + return await ResponsePointsBalance(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "PRIVACY" when args.Length > 2: + return await ResponsePrivacy(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); + case "PRIVACY": + return await ResponsePrivacy(steamID, args[1]).ConfigureAwait(false); + case "R" when args.Length > 2: + case "REDEEM" when args.Length > 2: + return await ResponseRedeem(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); + case "R": + case "REDEEM": + return await ResponseRedeem(steamID, args[1]).ConfigureAwait(false); + case "R^" when args.Length > 3: + case "REDEEM^" when args.Length > 3: + return await ResponseAdvancedRedeem(steamID, args[1], args[2], Utilities.GetArgsAsText(args, 3, ",")).ConfigureAwait(false); + case "R^" when args.Length > 2: + case "REDEEM^" when args.Length > 2: + return await ResponseAdvancedRedeem(steamID, args[1], args[2]).ConfigureAwait(false); + case "RESET": + return await ResponseReset(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "RESUME": + return await ResponseResume(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "START": + return await ResponseStart(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "STATUS": + return await ResponseStatus(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "STOP": + return await ResponseStop(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + case "TRANSFER" when args.Length > 2: + return await ResponseTransfer(steamID, args[1], Utilities.GetArgsAsText(message, 2)).ConfigureAwait(false); + case "TRANSFER": + return await ResponseTransfer(steamID, args[1]).ConfigureAwait(false); + case "TRANSFER^" when args.Length > 4: + return await ResponseAdvancedTransfer(steamID, args[1], args[2], args[3], Utilities.GetArgsAsText(message, 4)).ConfigureAwait(false); + case "TRANSFER^" when args.Length > 3: + return await ResponseAdvancedTransfer(steamID, args[1], args[2], args[3]).ConfigureAwait(false); + case "TRANSFER@" when args.Length > 3: + return await ResponseTransferByRealAppIDs(steamID, args[1], args[2], Utilities.GetArgsAsText(message, 3)).ConfigureAwait(false); + case "TRANSFER@" when args.Length > 2: + return await ResponseTransferByRealAppIDs(steamID, args[1], args[2]).ConfigureAwait(false); + case "TRANSFER%" when args.Length > 3: + return await ResponseTransferByRealAppIDs(steamID, args[1], args[2], Utilities.GetArgsAsText(message, 3), true).ConfigureAwait(false); + case "TRANSFER%" when args.Length > 2: + return await ResponseTransferByRealAppIDs(steamID, args[1], args[2], true).ConfigureAwait(false); + case "UNPACK": + return await ResponseUnpackBoosters(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); + default: + string? pluginsResponse = await PluginsCore.OnBotCommand(Bot, steamID, message, args).ConfigureAwait(false); + + return !string.IsNullOrEmpty(pluginsResponse) ? pluginsResponse : ResponseUnknown(steamID); + } + } + } + + internal async Task HandleMessage(ulong steamID, string message) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + string? commandPrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.CommandPrefix : GlobalConfig.DefaultCommandPrefix; + + if (!string.IsNullOrEmpty(commandPrefix)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (!message.StartsWith(commandPrefix!, StringComparison.Ordinal)) { + string? pluginsResponse = await PluginsCore.OnBotMessage(Bot, steamID, message).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(pluginsResponse)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (!await Bot.SendMessage(steamID, pluginsResponse!).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendMessage))); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, pluginsResponse)); } - default: - switch (args[0].ToUpperInvariant()) { - case "2FA": - return await Response2FA(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "2FANO": - return await Response2FAConfirm(steamID, Utilities.GetArgsAsText(args, 1, ","), false).ConfigureAwait(false); - case "2FAOK": - return await Response2FAConfirm(steamID, Utilities.GetArgsAsText(args, 1, ","), true).ConfigureAwait(false); - case "ADDLICENSE" when args.Length > 2: - return await ResponseAddLicense(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); - case "ADDLICENSE": - return await ResponseAddLicense(steamID, args[1]).ConfigureAwait(false); - case "BALANCE": - return await ResponseWalletBalance(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "BGR": - return await ResponseBackgroundGamesRedeemer(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "BL": - return await ResponseBlacklist(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "BLADD" when args.Length > 2: - return await ResponseBlacklistAdd(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); - case "BLADD": - return ResponseBlacklistAdd(steamID, args[1]); - case "BLRM" when args.Length > 2: - return await ResponseBlacklistRemove(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); - case "BLRM": - return ResponseBlacklistRemove(steamID, args[1]); - case "ENCRYPT" when args.Length > 2: - return ResponseEncrypt(steamID, args[1], Utilities.GetArgsAsText(message, 2)); - case "FARM": - return await ResponseFarm(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "HASH" when args.Length > 2: - return ResponseHash(steamID, args[1], Utilities.GetArgsAsText(message, 2)); - case "INPUT" when args.Length > 3: - return await ResponseInput(steamID, args[1], args[2], Utilities.GetArgsAsText(message, 3)).ConfigureAwait(false); - case "INPUT" when args.Length > 2: - return ResponseInput(steamID, args[1], args[2]); - case "IB": - return await ResponseIdleBlacklist(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "IBADD" when args.Length > 2: - return await ResponseIdleBlacklistAdd(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); - case "IBADD": - return ResponseIdleBlacklistAdd(steamID, args[1]); - case "IBRM" when args.Length > 2: - return await ResponseIdleBlacklistRemove(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); - case "IBRM": - return ResponseIdleBlacklistRemove(steamID, args[1]); - case "IQ": - return await ResponseIdleQueue(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "IQADD" when args.Length > 2: - return await ResponseIdleQueueAdd(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); - case "IQADD": - return ResponseIdleQueueAdd(steamID, args[1]); - case "IQRM" when args.Length > 2: - return await ResponseIdleQueueRemove(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); - case "IQRM": - return ResponseIdleQueueRemove(steamID, args[1]); - case "LEVEL": - return await ResponseLevel(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "LOOT": - return await ResponseLoot(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "LOOT^" when args.Length > 3: - return await ResponseAdvancedLoot(steamID, args[1], args[2], Utilities.GetArgsAsText(message, 3)).ConfigureAwait(false); - case "LOOT^" when args.Length > 2: - return await ResponseAdvancedLoot(steamID, args[1], args[2]).ConfigureAwait(false); - case "LOOT@" when args.Length > 2: - return await ResponseLootByRealAppIDs(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); - case "LOOT@": - return await ResponseLootByRealAppIDs(steamID, args[1]).ConfigureAwait(false); - case "LOOT%" when args.Length > 2: - return await ResponseLootByRealAppIDs(steamID, args[1], Utilities.GetArgsAsText(args, 2, ","), true).ConfigureAwait(false); - case "LOOT%": - return await ResponseLootByRealAppIDs(steamID, args[1], true).ConfigureAwait(false); - case "MAB": - return await ResponseMatchActivelyBlacklist(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "MABADD" when args.Length > 2: - return await ResponseMatchActivelyBlacklistAdd(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); - case "MABADD": - return ResponseMatchActivelyBlacklistAdd(steamID, args[1]); - case "MABRM" when args.Length > 2: - return await ResponseMatchActivelyBlacklistRemove(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); - case "MABRM": - return ResponseMatchActivelyBlacklistRemove(steamID, args[1]); - case "NICKNAME" when args.Length > 2: - return await ResponseNickname(steamID, args[1], Utilities.GetArgsAsText(message, 2)).ConfigureAwait(false); - case "NICKNAME": - return ResponseNickname(steamID, args[1]); - case "OA": - return await ResponseOwns(steamID, SharedInfo.ASF, Utilities.GetArgsAsText(message, 1)).ConfigureAwait(false); - case "OWNS" when args.Length > 2: - return await ResponseOwns(steamID, args[1], Utilities.GetArgsAsText(message, 2)).ConfigureAwait(false); - case "OWNS": - return (await ResponseOwns(steamID, args[1]).ConfigureAwait(false)).Response; - case "PASSWORD": - return await ResponsePassword(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "PAUSE": - return await ResponsePause(steamID, Utilities.GetArgsAsText(args, 1, ","), true).ConfigureAwait(false); - case "PAUSE~": - return await ResponsePause(steamID, Utilities.GetArgsAsText(args, 1, ","), false).ConfigureAwait(false); - case "PAUSE&" when args.Length > 2: - return await ResponsePause(steamID, args[1], true, Utilities.GetArgsAsText(message, 2)).ConfigureAwait(false); - case "PAUSE&": - return await ResponsePause(steamID, true, args[1]).ConfigureAwait(false); - case "PLAY" when args.Length > 2: - return await ResponsePlay(steamID, args[1], Utilities.GetArgsAsText(message, 2)).ConfigureAwait(false); - case "PLAY": - return await ResponsePlay(steamID, args[1]).ConfigureAwait(false); - case "POINTS": - return await ResponsePointsBalance(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "PRIVACY" when args.Length > 2: - return await ResponsePrivacy(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); - case "PRIVACY": - return await ResponsePrivacy(steamID, args[1]).ConfigureAwait(false); - case "R" when args.Length > 2: - case "REDEEM" when args.Length > 2: - return await ResponseRedeem(steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")).ConfigureAwait(false); - case "R": - case "REDEEM": - return await ResponseRedeem(steamID, args[1]).ConfigureAwait(false); - case "R^" when args.Length > 3: - case "REDEEM^" when args.Length > 3: - return await ResponseAdvancedRedeem(steamID, args[1], args[2], Utilities.GetArgsAsText(args, 3, ",")).ConfigureAwait(false); - case "R^" when args.Length > 2: - case "REDEEM^" when args.Length > 2: - return await ResponseAdvancedRedeem(steamID, args[1], args[2]).ConfigureAwait(false); - case "RESET": - return await ResponseReset(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "RESUME": - return await ResponseResume(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "START": - return await ResponseStart(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "STATUS": - return await ResponseStatus(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "STOP": - return await ResponseStop(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - case "TRANSFER" when args.Length > 2: - return await ResponseTransfer(steamID, args[1], Utilities.GetArgsAsText(message, 2)).ConfigureAwait(false); - case "TRANSFER": - return await ResponseTransfer(steamID, args[1]).ConfigureAwait(false); - case "TRANSFER^" when args.Length > 4: - return await ResponseAdvancedTransfer(steamID, args[1], args[2], args[3], Utilities.GetArgsAsText(message, 4)).ConfigureAwait(false); - case "TRANSFER^" when args.Length > 3: - return await ResponseAdvancedTransfer(steamID, args[1], args[2], args[3]).ConfigureAwait(false); - case "TRANSFER@" when args.Length > 3: - return await ResponseTransferByRealAppIDs(steamID, args[1], args[2], Utilities.GetArgsAsText(message, 3)).ConfigureAwait(false); - case "TRANSFER@" when args.Length > 2: - return await ResponseTransferByRealAppIDs(steamID, args[1], args[2]).ConfigureAwait(false); - case "TRANSFER%" when args.Length > 3: - return await ResponseTransferByRealAppIDs(steamID, args[1], args[2], Utilities.GetArgsAsText(message, 3), true).ConfigureAwait(false); - case "TRANSFER%" when args.Length > 2: - return await ResponseTransferByRealAppIDs(steamID, args[1], args[2], true).ConfigureAwait(false); - case "UNPACK": - return await ResponseUnpackBoosters(steamID, Utilities.GetArgsAsText(args, 1, ",")).ConfigureAwait(false); - default: - string? pluginsResponse = await PluginsCore.OnBotCommand(Bot, steamID, message, args).ConfigureAwait(false); - - return !string.IsNullOrEmpty(pluginsResponse) ? pluginsResponse : ResponseUnknown(steamID); - } - } - } - - internal async Task HandleMessage(ulong steamID, string message) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } - - string? commandPrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.CommandPrefix : GlobalConfig.DefaultCommandPrefix; - - if (!string.IsNullOrEmpty(commandPrefix)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (!message.StartsWith(commandPrefix!, StringComparison.Ordinal)) { - string? pluginsResponse = await PluginsCore.OnBotMessage(Bot, steamID, message).ConfigureAwait(false); - - if (!string.IsNullOrEmpty(pluginsResponse)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (!await Bot.SendMessage(steamID, pluginsResponse!).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendMessage))); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, pluginsResponse)); - } - } - - return; } - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (message.Length == commandPrefix!.Length) { - // If the message starts with command prefix and is of the same length as command prefix, then it's just empty command trigger, useless - return; - } - - message = message[commandPrefix.Length..]; + return; } - Task responseTask = Response(steamID, message); + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (message.Length == commandPrefix!.Length) { + // If the message starts with command prefix and is of the same length as command prefix, then it's just empty command trigger, useless + return; + } - bool feedback = Bot.HasAccess(steamID, BotConfig.EAccess.FamilySharing); + message = message[commandPrefix.Length..]; + } - if (feedback && !responseTask.IsCompleted) { + Task responseTask = Response(steamID, message); + + bool feedback = Bot.HasAccess(steamID, BotConfig.EAccess.FamilySharing); + + if (feedback && !responseTask.IsCompleted) { + if (!await Bot.SendTypingMessage(steamID).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendTypingMessage))); + } + + while (!responseTask.IsCompleted && (await Task.WhenAny(responseTask, Task.Delay(SteamTypingStatusDelay)).ConfigureAwait(false) != responseTask)) { if (!await Bot.SendTypingMessage(steamID).ConfigureAwait(false)) { Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendTypingMessage))); } - - while (!responseTask.IsCompleted && (await Task.WhenAny(responseTask, Task.Delay(SteamTypingStatusDelay)).ConfigureAwait(false) != responseTask)) { - if (!await Bot.SendTypingMessage(steamID).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendTypingMessage))); - } - } - } - - string? response = await responseTask.ConfigureAwait(false); - - if (string.IsNullOrEmpty(response)) { - if (!feedback) { - return; - } - - response = FormatBotResponse(Strings.ErrorAccessDenied); - } - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (!await Bot.SendMessage(steamID, response!).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendMessage))); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, response)); } } - internal async Task HandleMessage(ulong chatGroupID, ulong chatID, ulong steamID, string message) { - if (chatGroupID == 0) { - throw new ArgumentOutOfRangeException(nameof(chatGroupID)); + string? response = await responseTask.ConfigureAwait(false); + + if (string.IsNullOrEmpty(response)) { + if (!feedback) { + return; } - if (chatID == 0) { - throw new ArgumentOutOfRangeException(nameof(chatID)); - } + response = FormatBotResponse(Strings.ErrorAccessDenied); + } - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (!await Bot.SendMessage(steamID, response!).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendMessage))); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, response)); + } + } - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } + internal async Task HandleMessage(ulong chatGroupID, ulong chatID, ulong steamID, string message) { + if (chatGroupID == 0) { + throw new ArgumentOutOfRangeException(nameof(chatGroupID)); + } - string? commandPrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.CommandPrefix : GlobalConfig.DefaultCommandPrefix; + if (chatID == 0) { + throw new ArgumentOutOfRangeException(nameof(chatID)); + } - if (!string.IsNullOrEmpty(commandPrefix)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (!message.StartsWith(commandPrefix!, StringComparison.Ordinal)) { - string? pluginsResponse = await PluginsCore.OnBotMessage(Bot, steamID, message).ConfigureAwait(false); + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } - if (!string.IsNullOrEmpty(pluginsResponse)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (!await Bot.SendMessage(chatGroupID, chatID, pluginsResponse!).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendMessage))); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, pluginsResponse)); - } + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + string? commandPrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.CommandPrefix : GlobalConfig.DefaultCommandPrefix; + + if (!string.IsNullOrEmpty(commandPrefix)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (!message.StartsWith(commandPrefix!, StringComparison.Ordinal)) { + string? pluginsResponse = await PluginsCore.OnBotMessage(Bot, steamID, message).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(pluginsResponse)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (!await Bot.SendMessage(chatGroupID, chatID, pluginsResponse!).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendMessage))); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, pluginsResponse)); } - - return; } - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (message.Length == commandPrefix!.Length) { - // If the message starts with command prefix and is of the same length as command prefix, then it's just empty command trigger, useless - return; - } - - message = message[commandPrefix.Length..]; + return; } - Task responseTask = Response(steamID, message); + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (message.Length == commandPrefix!.Length) { + // If the message starts with command prefix and is of the same length as command prefix, then it's just empty command trigger, useless + return; + } - bool feedback = Bot.HasAccess(steamID, BotConfig.EAccess.FamilySharing); + message = message[commandPrefix.Length..]; + } - if (feedback && !responseTask.IsCompleted) { - string pleaseWaitMessage = FormatBotResponse(Strings.PleaseWait); + Task responseTask = Response(steamID, message); + bool feedback = Bot.HasAccess(steamID, BotConfig.EAccess.FamilySharing); + + if (feedback && !responseTask.IsCompleted) { + string pleaseWaitMessage = FormatBotResponse(Strings.PleaseWait); + + if (!await Bot.SendMessage(chatGroupID, chatID, pleaseWaitMessage).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendMessage))); + } + + while (!responseTask.IsCompleted && (await Task.WhenAny(responseTask, Task.Delay(SteamTypingStatusDelay)).ConfigureAwait(false) != responseTask)) { if (!await Bot.SendMessage(chatGroupID, chatID, pleaseWaitMessage).ConfigureAwait(false)) { Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendMessage))); } - - while (!responseTask.IsCompleted && (await Task.WhenAny(responseTask, Task.Delay(SteamTypingStatusDelay)).ConfigureAwait(false) != responseTask)) { - if (!await Bot.SendMessage(chatGroupID, chatID, pleaseWaitMessage).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendMessage))); - } - } - } - - string? response = await responseTask.ConfigureAwait(false); - - if (string.IsNullOrEmpty(response)) { - if (!feedback) { - return; - } - - response = FormatBotResponse(Strings.ErrorAccessDenied); - } - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (!await Bot.SendMessage(chatGroupID, chatID, response!).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendMessage))); - Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, response)); } } - internal void OnNewLicenseList() { + string? response = await responseTask.ConfigureAwait(false); + + if (string.IsNullOrEmpty(response)) { + if (!feedback) { + return; + } + + response = FormatBotResponse(Strings.ErrorAccessDenied); + } + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (!await Bot.SendMessage(chatGroupID, chatID, response!).ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(Bot.SendMessage))); + Bot.ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, response)); + } + } + + internal void OnNewLicenseList() { + lock (CachedGamesOwned) { + CachedGamesOwned.Clear(); + CachedGamesOwned.TrimExcess(); + } + } + + private async Task?> FetchGamesOwned(bool cachedOnly = false) { + lock (CachedGamesOwned) { + if (CachedGamesOwned.Count > 0) { + return new Dictionary(CachedGamesOwned); + } + } + + if (cachedOnly) { + return null; + } + + Dictionary? gamesOwned = await Bot.ArchiHandler.GetOwnedGames(Bot.SteamID).ConfigureAwait(false); + + if (gamesOwned?.Count > 0) { lock (CachedGamesOwned) { - CachedGamesOwned.Clear(); - CachedGamesOwned.TrimExcess(); - } - } - - private async Task?> FetchGamesOwned(bool cachedOnly = false) { - lock (CachedGamesOwned) { - if (CachedGamesOwned.Count > 0) { - return new Dictionary(CachedGamesOwned); - } - } - - if (cachedOnly) { - return null; - } - - Dictionary? gamesOwned = await Bot.ArchiHandler.GetOwnedGames(Bot.SteamID).ConfigureAwait(false); - - if (gamesOwned?.Count > 0) { - lock (CachedGamesOwned) { - if (CachedGamesOwned.Count == 0) { - foreach ((uint appID, string gameName) in gamesOwned) { - CachedGamesOwned[appID] = gameName; - } - - CachedGamesOwned.TrimExcess(); - } - } - } - - return gamesOwned; - } - - private async Task Response2FA(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - (bool success, string? token, string message) = await Bot.Actions.GenerateTwoFactorAuthenticationToken().ConfigureAwait(false); - - return FormatBotResponse(success && !string.IsNullOrEmpty(token) ? string.Format(CultureInfo.CurrentCulture, Strings.BotAuthenticatorToken, token) : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); - } - - private static async Task Response2FA(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.Response2FA(steamID))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private async Task Response2FAConfirm(ulong steamID, bool confirm) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - if (!Bot.HasMobileAuthenticator) { - return FormatBotResponse(Strings.BotNoASFAuthenticator); - } - - (bool success, _, string message) = await Bot.Actions.HandleTwoFactorAuthenticationConfirmations(confirm).ConfigureAwait(false); - - return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); - } - - private static async Task Response2FAConfirm(ulong steamID, string botNames, bool confirm) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.Response2FAConfirm(steamID, confirm))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private async Task ResponseAddLicense(ulong steamID, string query) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(query)) { - throw new ArgumentNullException(nameof(query)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Operator)) { - return null; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - StringBuilder response = new(); - - string[] entries = query.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - foreach (string entry in entries) { - uint gameID; - string type; - - int index = entry.IndexOf('/', StringComparison.Ordinal); - - if ((index > 0) && (entry.Length > index + 1)) { - if (!uint.TryParse(entry[(index + 1)..], out gameID) || (gameID == 0)) { - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(gameID)))); - - continue; + if (CachedGamesOwned.Count == 0) { + foreach ((uint appID, string gameName) in gamesOwned) { + CachedGamesOwned[appID] = gameName; } - type = entry[..index]; - } else if (uint.TryParse(entry, out gameID) && (gameID > 0)) { - type = "SUB"; - } else { + CachedGamesOwned.TrimExcess(); + } + } + } + + return gamesOwned; + } + + private async Task Response2FA(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + (bool success, string? token, string message) = await Bot.Actions.GenerateTwoFactorAuthenticationToken().ConfigureAwait(false); + + return FormatBotResponse(success && !string.IsNullOrEmpty(token) ? string.Format(CultureInfo.CurrentCulture, Strings.BotAuthenticatorToken, token) : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + } + + private static async Task Response2FA(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.Response2FA(steamID))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task Response2FAConfirm(ulong steamID, bool confirm) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + if (!Bot.HasMobileAuthenticator) { + return FormatBotResponse(Strings.BotNoASFAuthenticator); + } + + (bool success, _, string message) = await Bot.Actions.HandleTwoFactorAuthenticationConfirmations(confirm).ConfigureAwait(false); + + return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + } + + private static async Task Response2FAConfirm(ulong steamID, string botNames, bool confirm) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.Response2FAConfirm(steamID, confirm))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task ResponseAddLicense(ulong steamID, string query) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(query)) { + throw new ArgumentNullException(nameof(query)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Operator)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + StringBuilder response = new(); + + string[] entries = query.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (string entry in entries) { + uint gameID; + string type; + + int index = entry.IndexOf('/', StringComparison.Ordinal); + + if ((index > 0) && (entry.Length > index + 1)) { + if (!uint.TryParse(entry[(index + 1)..], out gameID) || (gameID == 0)) { response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(gameID)))); continue; } - switch (type.ToUpperInvariant()) { - case "A": - case "APP": - SteamApps.FreeLicenseCallback callback; + type = entry[..index]; + } else if (uint.TryParse(entry, out gameID) && (gameID > 0)) { + type = "SUB"; + } else { + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(gameID)))); - try { - callback = await Bot.SteamApps.RequestFreeLicense(gameID).ToLongRunningTask().ConfigureAwait(false); - } catch (Exception e) { - Bot.ArchiLogger.LogGenericWarningException(e); - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotAddLicense, $"app/{gameID}", EResult.Timeout))); + continue; + } - break; - } + switch (type.ToUpperInvariant()) { + case "A": + case "APP": + SteamApps.FreeLicenseCallback callback; - response.AppendLine(FormatBotResponse((callback.GrantedApps.Count > 0) || (callback.GrantedPackages.Count > 0) ? string.Format(CultureInfo.CurrentCulture, Strings.BotAddLicenseWithItems, $"app/{gameID}", callback.Result, string.Join(", ", callback.GrantedApps.Select(static appID => $"app/{appID}").Union(callback.GrantedPackages.Select(static subID => $"sub/{subID}")))) : string.Format(CultureInfo.CurrentCulture, Strings.BotAddLicense, $"app/{gameID}", callback.Result))); + try { + callback = await Bot.SteamApps.RequestFreeLicense(gameID).ToLongRunningTask().ConfigureAwait(false); + } catch (Exception e) { + Bot.ArchiLogger.LogGenericWarningException(e); + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotAddLicense, $"app/{gameID}", EResult.Timeout))); break; - default: - (EResult result, EPurchaseResultDetail purchaseResult) = await Bot.ArchiWebHandler.AddFreeLicense(gameID).ConfigureAwait(false); + } - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotAddLicense, $"sub/{gameID}", $"{result}/{purchaseResult}"))); + response.AppendLine(FormatBotResponse((callback.GrantedApps.Count > 0) || (callback.GrantedPackages.Count > 0) ? string.Format(CultureInfo.CurrentCulture, Strings.BotAddLicenseWithItems, $"app/{gameID}", callback.Result, string.Join(", ", callback.GrantedApps.Select(static appID => $"app/{appID}").Union(callback.GrantedPackages.Select(static subID => $"sub/{subID}")))) : string.Format(CultureInfo.CurrentCulture, Strings.BotAddLicense, $"app/{gameID}", callback.Result))); - break; + break; + default: + (EResult result, EPurchaseResultDetail purchaseResult) = await Bot.ArchiWebHandler.AddFreeLicense(gameID).ConfigureAwait(false); + + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotAddLicense, $"sub/{gameID}", $"{result}/{purchaseResult}"))); + + break; + } + } + + return response.Length > 0 ? response.ToString() : null; + } + + private static async Task ResponseAddLicense(ulong steamID, string botNames, string query) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (string.IsNullOrEmpty(query)) { + throw new ArgumentNullException(nameof(query)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseAddLicense(steamID, query))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task ResponseAdvancedLoot(ulong steamID, string targetAppID, string targetContextID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(targetAppID)) { + throw new ArgumentNullException(nameof(targetAppID)); + } + + if (string.IsNullOrEmpty(targetContextID)) { + throw new ArgumentNullException(nameof(targetContextID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + if (!uint.TryParse(targetAppID, out uint appID) || (appID == 0)) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(appID))); + } + + if (!ulong.TryParse(targetContextID, out ulong contextID) || (contextID == 0)) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(contextID))); + } + + (bool success, string message) = await Bot.Actions.SendInventory(appID, contextID).ConfigureAwait(false); + + return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + } + + private static async Task ResponseAdvancedLoot(ulong steamID, string botNames, string appID, string contextID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (string.IsNullOrEmpty(appID)) { + throw new ArgumentNullException(nameof(appID)); + } + + if (string.IsNullOrEmpty(contextID)) { + throw new ArgumentNullException(nameof(contextID)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseAdvancedLoot(steamID, appID, contextID))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task ResponseAdvancedRedeem(ulong steamID, string options, string keys) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(options)) { + throw new ArgumentNullException(nameof(options)); + } + + if (string.IsNullOrEmpty(keys)) { + throw new ArgumentNullException(nameof(keys)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Operator)) { + return null; + } + + string[] flags = options.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (flags.Length == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(flags))); + } + + ERedeemFlags redeemFlags = ERedeemFlags.None; + + foreach (string flag in flags) { + switch (flag.ToUpperInvariant()) { + case "FAWK": + case "FORCEASSUMEWALLETKEY": + redeemFlags |= ERedeemFlags.ForceAssumeWalletKeyOnBadActivationCode; + + break; + case "FD": + case "FORCEDISTRIBUTING": + redeemFlags |= ERedeemFlags.ForceDistributing; + + break; + case "FF": + case "FORCEFORWARDING": + redeemFlags |= ERedeemFlags.ForceForwarding; + + break; + case "FKMG": + case "FORCEKEEPMISSINGGAMES": + redeemFlags |= ERedeemFlags.ForceKeepMissingGames; + + break; + case "SAWK": + case "SKIPASSUMEWALLETKEY": + redeemFlags |= ERedeemFlags.SkipAssumeWalletKeyOnBadActivationCode; + + break; + case "SD": + case "SKIPDISTRIBUTING": + redeemFlags |= ERedeemFlags.SkipDistributing; + + break; + case "SF": + case "SKIPFORWARDING": + redeemFlags |= ERedeemFlags.SkipForwarding; + + break; + case "SI": + case "SKIPINITIAL": + redeemFlags |= ERedeemFlags.SkipInitial; + + break; + case "SKMG": + case "SKIPKEEPMISSINGGAMES": + redeemFlags |= ERedeemFlags.SkipKeepMissingGames; + + break; + case "V": + case "VALIDATE": + redeemFlags |= ERedeemFlags.Validate; + + break; + default: + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, flag)); + } + } + + return await ResponseRedeem(steamID, keys, redeemFlags).ConfigureAwait(false); + } + + private static async Task ResponseAdvancedRedeem(ulong steamID, string botNames, string options, string keys) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (string.IsNullOrEmpty(options)) { + throw new ArgumentNullException(nameof(options)); + } + + if (string.IsNullOrEmpty(keys)) { + throw new ArgumentNullException(nameof(keys)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseAdvancedRedeem(steamID, options, keys))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task ResponseAdvancedTransfer(ulong steamID, uint appID, ulong contextID, Bot targetBot) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (appID == 0) { + throw new ArgumentOutOfRangeException(nameof(appID)); + } + + if (contextID == 0) { + throw new ArgumentOutOfRangeException(nameof(contextID)); + } + + if (targetBot == null) { + throw new ArgumentNullException(nameof(targetBot)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + if (!targetBot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.TargetBotNotConnected); + } + + (bool success, string message) = await Bot.Actions.SendInventory(appID, contextID, targetBot.SteamID).ConfigureAwait(false); + + return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + } + + private async Task ResponseAdvancedTransfer(ulong steamID, string targetAppID, string targetContextID, string botNameTo) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(targetAppID)) { + throw new ArgumentNullException(nameof(targetAppID)); + } + + if (string.IsNullOrEmpty(targetContextID)) { + throw new ArgumentNullException(nameof(targetContextID)); + } + + if (string.IsNullOrEmpty(botNameTo)) { + throw new ArgumentNullException(nameof(botNameTo)); + } + + Bot? targetBot = Bot.GetBot(botNameTo); + + if (targetBot == null) { + return ASF.IsOwner(steamID) ? FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNameTo)) : null; + } + + if (!uint.TryParse(targetAppID, out uint appID) || (appID == 0)) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(appID))); + } + + if (!ulong.TryParse(targetContextID, out ulong contextID) || (contextID == 0)) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(contextID))); + } + + return await ResponseAdvancedTransfer(steamID, appID, contextID, targetBot).ConfigureAwait(false); + } + + private static async Task ResponseAdvancedTransfer(ulong steamID, string botNames, string targetAppID, string targetContextID, string botNameTo) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (string.IsNullOrEmpty(targetAppID)) { + throw new ArgumentNullException(nameof(targetAppID)); + } + + if (string.IsNullOrEmpty(targetContextID)) { + throw new ArgumentNullException(nameof(targetContextID)); + } + + if (string.IsNullOrEmpty(botNameTo)) { + throw new ArgumentNullException(nameof(botNameTo)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + if (!uint.TryParse(targetAppID, out uint appID) || (appID == 0)) { + return FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(appID))); + } + + if (!ulong.TryParse(targetContextID, out ulong contextID) || (contextID == 0)) { + return FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(contextID))); + } + + Bot? targetBot = Bot.GetBot(botNameTo); + + if (targetBot == null) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNameTo)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseAdvancedTransfer(steamID, appID, contextID, targetBot))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private string? ResponseBackgroundGamesRedeemer(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + uint count = Bot.GamesToRedeemInBackgroundCount; + + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotGamesToRedeemInBackgroundCount, count)); + } + + private static async Task ResponseBackgroundGamesRedeemer(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseBackgroundGamesRedeemer(steamID)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private string? ResponseBlacklist(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + return !Bot.HasAccess(steamID, BotConfig.EAccess.Master) ? null : FormatBotResponse(Bot.BotDatabase.BlacklistedFromTradesSteamIDs.Count == 0 ? string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotDatabase.BlacklistedFromTradesSteamIDs)) : string.Join(", ", Bot.BotDatabase.BlacklistedFromTradesSteamIDs)); + } + + private static async Task ResponseBlacklist(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseBlacklist(steamID)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private string? ResponseBlacklistAdd(ulong steamID, string targetSteamIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(targetSteamIDs)) { + throw new ArgumentNullException(nameof(targetSteamIDs)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + string[] targets = targetSteamIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (targets.Length == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); + } + + HashSet targetIDs = new(); + + foreach (string target in targets) { + if (!ulong.TryParse(target, out ulong targetID) || (targetID == 0) || !new SteamID(targetID).IsIndividualAccount) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(targetID))); + } + + targetIDs.Add(targetID); + } + + return FormatBotResponse(Bot.BotDatabase.BlacklistedFromTradesSteamIDs.AddRange(targetIDs) ? Strings.Done : Strings.NothingFound); + } + + private static async Task ResponseBlacklistAdd(ulong steamID, string botNames, string targetSteamIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (string.IsNullOrEmpty(targetSteamIDs)) { + throw new ArgumentNullException(nameof(targetSteamIDs)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseBlacklistAdd(steamID, targetSteamIDs)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private string? ResponseBlacklistRemove(ulong steamID, string targetSteamIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(targetSteamIDs)) { + throw new ArgumentNullException(nameof(targetSteamIDs)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + string[] targets = targetSteamIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (targets.Length == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); + } + + HashSet targetIDs = new(); + + foreach (string target in targets) { + if (!ulong.TryParse(target, out ulong targetID) || (targetID == 0) || !new SteamID(targetID).IsIndividualAccount) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(targetID))); + } + + targetIDs.Add(targetID); + } + + return FormatBotResponse(Bot.BotDatabase.BlacklistedFromTradesSteamIDs.RemoveRange(targetIDs) ? Strings.Done : Strings.NothingFound); + } + + private static async Task ResponseBlacklistRemove(ulong steamID, string botNames, string targetSteamIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (string.IsNullOrEmpty(targetSteamIDs)) { + throw new ArgumentNullException(nameof(targetSteamIDs)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseBlacklistRemove(steamID, targetSteamIDs)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private static string? ResponseEncrypt(ulong steamID, string cryptoMethodText, string stringToEncrypt) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(cryptoMethodText)) { + throw new ArgumentNullException(nameof(cryptoMethodText)); + } + + if (string.IsNullOrEmpty(stringToEncrypt)) { + throw new ArgumentNullException(nameof(stringToEncrypt)); + } + + if (!ASF.IsOwner(steamID)) { + return null; + } + + if (!Enum.TryParse(cryptoMethodText, true, out ArchiCryptoHelper.ECryptoMethod cryptoMethod)) { + return FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(cryptoMethod))); + } + + string? encryptedString = Actions.Encrypt(cryptoMethod, stringToEncrypt); + + return FormatStaticResponse(!string.IsNullOrEmpty(encryptedString) ? string.Format(CultureInfo.CurrentCulture, Strings.Result, encryptedString) : Strings.WarningFailed); + } + + private static string? ResponseExit(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!ASF.IsOwner(steamID)) { + return null; + } + + (bool success, string message) = Actions.Exit(); + + return FormatStaticResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + } + + private async Task ResponseFarm(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + if (Bot.CardsFarmer.NowFarming) { + await Bot.CardsFarmer.StopFarming().ConfigureAwait(false); + } + + Utilities.InBackground(Bot.CardsFarmer.StartFarming); + + return FormatBotResponse(Strings.Done); + } + + private static async Task ResponseFarm(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseFarm(steamID))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private static string? ResponseHash(ulong steamID, string hashingMethodText, string stringToHash) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(hashingMethodText)) { + throw new ArgumentNullException(nameof(hashingMethodText)); + } + + if (string.IsNullOrEmpty(stringToHash)) { + throw new ArgumentNullException(nameof(stringToHash)); + } + + if (!ASF.IsOwner(steamID)) { + return null; + } + + if (!Enum.TryParse(hashingMethodText, true, out ArchiCryptoHelper.EHashingMethod hashingMethod)) { + return FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(hashingMethod))); + } + + string hash = Actions.Hash(hashingMethod, stringToHash); + + return FormatStaticResponse(!string.IsNullOrEmpty(hash) ? string.Format(CultureInfo.CurrentCulture, Strings.Result, hash) : Strings.WarningFailed); + } + + private string? ResponseHelp(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + return Bot.HasAccess(steamID, BotConfig.EAccess.FamilySharing) ? FormatBotResponse($"{SharedInfo.ProjectURL}/wiki/Commands") : null; + } + + private string? ResponseIdleBlacklist(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + return !Bot.HasAccess(steamID, BotConfig.EAccess.Master) ? null : FormatBotResponse(Bot.BotDatabase.IdlingBlacklistedAppIDs.Count == 0 ? string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotDatabase.IdlingBlacklistedAppIDs)) : string.Join(", ", Bot.BotDatabase.IdlingBlacklistedAppIDs)); + } + + private static async Task ResponseIdleBlacklist(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseIdleBlacklist(steamID)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private string? ResponseIdleBlacklistAdd(ulong steamID, string targetAppIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(targetAppIDs)) { + throw new ArgumentNullException(nameof(targetAppIDs)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + string[] targets = targetAppIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (targets.Length == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); + } + + HashSet appIDs = new(); + + foreach (string target in targets) { + if (!uint.TryParse(target, out uint appID) || (appID == 0)) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(appID))); + } + + appIDs.Add(appID); + } + + if (!Bot.BotDatabase.IdlingBlacklistedAppIDs.AddRange(appIDs)) { + return FormatBotResponse(Strings.NothingFound); + } + + if (Bot.CardsFarmer.NowFarming && Bot.CardsFarmer.GamesToFarmReadOnly.Any(game => appIDs.Contains(game.AppID))) { + Utilities.InBackground( + async () => { + await Bot.CardsFarmer.StopFarming().ConfigureAwait(false); + await Bot.CardsFarmer.StartFarming().ConfigureAwait(false); } - } - - return response.Length > 0 ? response.ToString() : null; + ); } - private static async Task ResponseAddLicense(ulong steamID, string botNames, string query) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + return FormatBotResponse(Strings.Done); + } - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(query)) { - throw new ArgumentNullException(nameof(query)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseAddLicense(steamID, query))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + private static async Task ResponseIdleBlacklistAdd(ulong steamID, string botNames, string targetAppIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - private async Task ResponseAdvancedLoot(ulong steamID, string targetAppID, string targetContextID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(targetAppID)) { - throw new ArgumentNullException(nameof(targetAppID)); - } - - if (string.IsNullOrEmpty(targetContextID)) { - throw new ArgumentNullException(nameof(targetContextID)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - if (!uint.TryParse(targetAppID, out uint appID) || (appID == 0)) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(appID))); - } - - if (!ulong.TryParse(targetContextID, out ulong contextID) || (contextID == 0)) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(contextID))); - } - - (bool success, string message) = await Bot.Actions.SendInventory(appID, contextID).ConfigureAwait(false); - - return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); } - private static async Task ResponseAdvancedLoot(ulong steamID, string botNames, string appID, string contextID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(appID)) { - throw new ArgumentNullException(nameof(appID)); - } - - if (string.IsNullOrEmpty(contextID)) { - throw new ArgumentNullException(nameof(contextID)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseAdvancedLoot(steamID, appID, contextID))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + if (string.IsNullOrEmpty(targetAppIDs)) { + throw new ArgumentNullException(nameof(targetAppIDs)); } - private async Task ResponseAdvancedRedeem(ulong steamID, string options, string keys) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + HashSet? bots = Bot.GetBots(botNames); - if (string.IsNullOrEmpty(options)) { - throw new ArgumentNullException(nameof(options)); - } - - if (string.IsNullOrEmpty(keys)) { - throw new ArgumentNullException(nameof(keys)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Operator)) { - return null; - } - - string[] flags = options.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (flags.Length == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(flags))); - } - - ERedeemFlags redeemFlags = ERedeemFlags.None; - - foreach (string flag in flags) { - switch (flag.ToUpperInvariant()) { - case "FAWK": - case "FORCEASSUMEWALLETKEY": - redeemFlags |= ERedeemFlags.ForceAssumeWalletKeyOnBadActivationCode; - - break; - case "FD": - case "FORCEDISTRIBUTING": - redeemFlags |= ERedeemFlags.ForceDistributing; - - break; - case "FF": - case "FORCEFORWARDING": - redeemFlags |= ERedeemFlags.ForceForwarding; - - break; - case "FKMG": - case "FORCEKEEPMISSINGGAMES": - redeemFlags |= ERedeemFlags.ForceKeepMissingGames; - - break; - case "SAWK": - case "SKIPASSUMEWALLETKEY": - redeemFlags |= ERedeemFlags.SkipAssumeWalletKeyOnBadActivationCode; - - break; - case "SD": - case "SKIPDISTRIBUTING": - redeemFlags |= ERedeemFlags.SkipDistributing; - - break; - case "SF": - case "SKIPFORWARDING": - redeemFlags |= ERedeemFlags.SkipForwarding; - - break; - case "SI": - case "SKIPINITIAL": - redeemFlags |= ERedeemFlags.SkipInitial; - - break; - case "SKMG": - case "SKIPKEEPMISSINGGAMES": - redeemFlags |= ERedeemFlags.SkipKeepMissingGames; - - break; - case "V": - case "VALIDATE": - redeemFlags |= ERedeemFlags.Validate; - - break; - default: - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, flag)); - } - } - - return await ResponseRedeem(steamID, keys, redeemFlags).ConfigureAwait(false); + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; } - private static async Task ResponseAdvancedRedeem(ulong steamID, string botNames, string options, string keys) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseIdleBlacklistAdd(steamID, targetAppIDs)))).ConfigureAwait(false); - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - if (string.IsNullOrEmpty(options)) { - throw new ArgumentNullException(nameof(options)); - } + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } - if (string.IsNullOrEmpty(keys)) { - throw new ArgumentNullException(nameof(keys)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseAdvancedRedeem(steamID, options, keys))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + private string? ResponseIdleBlacklistRemove(ulong steamID, string targetAppIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - private async Task ResponseAdvancedTransfer(ulong steamID, uint appID, ulong contextID, Bot targetBot) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (appID == 0) { - throw new ArgumentOutOfRangeException(nameof(appID)); - } - - if (contextID == 0) { - throw new ArgumentOutOfRangeException(nameof(contextID)); - } - - if (targetBot == null) { - throw new ArgumentNullException(nameof(targetBot)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - if (!targetBot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.TargetBotNotConnected); - } - - (bool success, string message) = await Bot.Actions.SendInventory(appID, contextID, targetBot.SteamID).ConfigureAwait(false); - - return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + if (string.IsNullOrEmpty(targetAppIDs)) { + throw new ArgumentNullException(nameof(targetAppIDs)); } - private async Task ResponseAdvancedTransfer(ulong steamID, string targetAppID, string targetContextID, string botNameTo) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(targetAppID)) { - throw new ArgumentNullException(nameof(targetAppID)); - } - - if (string.IsNullOrEmpty(targetContextID)) { - throw new ArgumentNullException(nameof(targetContextID)); - } - - if (string.IsNullOrEmpty(botNameTo)) { - throw new ArgumentNullException(nameof(botNameTo)); - } - - Bot? targetBot = Bot.GetBot(botNameTo); - - if (targetBot == null) { - return ASF.IsOwner(steamID) ? FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNameTo)) : null; - } - - if (!uint.TryParse(targetAppID, out uint appID) || (appID == 0)) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(appID))); - } - - if (!ulong.TryParse(targetContextID, out ulong contextID) || (contextID == 0)) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(contextID))); - } - - return await ResponseAdvancedTransfer(steamID, appID, contextID, targetBot).ConfigureAwait(false); + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; } - private static async Task ResponseAdvancedTransfer(ulong steamID, string botNames, string targetAppID, string targetContextID, string botNameTo) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + string[] targets = targetAppIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(targetAppID)) { - throw new ArgumentNullException(nameof(targetAppID)); - } - - if (string.IsNullOrEmpty(targetContextID)) { - throw new ArgumentNullException(nameof(targetContextID)); - } - - if (string.IsNullOrEmpty(botNameTo)) { - throw new ArgumentNullException(nameof(botNameTo)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - if (!uint.TryParse(targetAppID, out uint appID) || (appID == 0)) { - return FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(appID))); - } - - if (!ulong.TryParse(targetContextID, out ulong contextID) || (contextID == 0)) { - return FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(contextID))); - } - - Bot? targetBot = Bot.GetBot(botNameTo); - - if (targetBot == null) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNameTo)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseAdvancedTransfer(steamID, appID, contextID, targetBot))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + if (targets.Length == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); } - private string? ResponseBackgroundGamesRedeemer(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); + HashSet appIDs = new(); + + foreach (string target in targets) { + if (!uint.TryParse(target, out uint appID) || (appID == 0)) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(appID))); } - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - uint count = Bot.GamesToRedeemInBackgroundCount; - - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotGamesToRedeemInBackgroundCount, count)); + appIDs.Add(appID); } - private static async Task ResponseBackgroundGamesRedeemer(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseBackgroundGamesRedeemer(steamID)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + if (!Bot.BotDatabase.IdlingBlacklistedAppIDs.RemoveRange(appIDs)) { + return FormatBotResponse(Strings.NothingFound); } - private string? ResponseBlacklist(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - return !Bot.HasAccess(steamID, BotConfig.EAccess.Master) ? null : FormatBotResponse(Bot.BotDatabase.BlacklistedFromTradesSteamIDs.Count == 0 ? string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotDatabase.BlacklistedFromTradesSteamIDs)) : string.Join(", ", Bot.BotDatabase.BlacklistedFromTradesSteamIDs)); - } - - private static async Task ResponseBlacklist(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseBlacklist(steamID)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private string? ResponseBlacklistAdd(ulong steamID, string targetSteamIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(targetSteamIDs)) { - throw new ArgumentNullException(nameof(targetSteamIDs)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - string[] targets = targetSteamIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (targets.Length == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); - } - - HashSet targetIDs = new(); - - foreach (string target in targets) { - if (!ulong.TryParse(target, out ulong targetID) || (targetID == 0) || !new SteamID(targetID).IsIndividualAccount) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(targetID))); - } - - targetIDs.Add(targetID); - } - - return FormatBotResponse(Bot.BotDatabase.BlacklistedFromTradesSteamIDs.AddRange(targetIDs) ? Strings.Done : Strings.NothingFound); - } - - private static async Task ResponseBlacklistAdd(ulong steamID, string botNames, string targetSteamIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(targetSteamIDs)) { - throw new ArgumentNullException(nameof(targetSteamIDs)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseBlacklistAdd(steamID, targetSteamIDs)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private string? ResponseBlacklistRemove(ulong steamID, string targetSteamIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(targetSteamIDs)) { - throw new ArgumentNullException(nameof(targetSteamIDs)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - string[] targets = targetSteamIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (targets.Length == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); - } - - HashSet targetIDs = new(); - - foreach (string target in targets) { - if (!ulong.TryParse(target, out ulong targetID) || (targetID == 0) || !new SteamID(targetID).IsIndividualAccount) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(targetID))); - } - - targetIDs.Add(targetID); - } - - return FormatBotResponse(Bot.BotDatabase.BlacklistedFromTradesSteamIDs.RemoveRange(targetIDs) ? Strings.Done : Strings.NothingFound); - } - - private static async Task ResponseBlacklistRemove(ulong steamID, string botNames, string targetSteamIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(targetSteamIDs)) { - throw new ArgumentNullException(nameof(targetSteamIDs)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseBlacklistRemove(steamID, targetSteamIDs)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private static string? ResponseEncrypt(ulong steamID, string cryptoMethodText, string stringToEncrypt) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(cryptoMethodText)) { - throw new ArgumentNullException(nameof(cryptoMethodText)); - } - - if (string.IsNullOrEmpty(stringToEncrypt)) { - throw new ArgumentNullException(nameof(stringToEncrypt)); - } - - if (!ASF.IsOwner(steamID)) { - return null; - } - - if (!Enum.TryParse(cryptoMethodText, true, out ArchiCryptoHelper.ECryptoMethod cryptoMethod)) { - return FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(cryptoMethod))); - } - - string? encryptedString = Actions.Encrypt(cryptoMethod, stringToEncrypt); - - return FormatStaticResponse(!string.IsNullOrEmpty(encryptedString) ? string.Format(CultureInfo.CurrentCulture, Strings.Result, encryptedString) : Strings.WarningFailed); - } - - private static string? ResponseExit(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!ASF.IsOwner(steamID)) { - return null; - } - - (bool success, string message) = Actions.Exit(); - - return FormatStaticResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); - } - - private async Task ResponseFarm(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - if (Bot.CardsFarmer.NowFarming) { - await Bot.CardsFarmer.StopFarming().ConfigureAwait(false); - } - + if (!Bot.CardsFarmer.NowFarming) { Utilities.InBackground(Bot.CardsFarmer.StartFarming); - - return FormatBotResponse(Strings.Done); } - private static async Task ResponseFarm(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + return FormatBotResponse(Strings.Done); + } - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseFarm(steamID))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + private static async Task ResponseIdleBlacklistRemove(ulong steamID, string botNames, string targetAppIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - private static string? ResponseHash(ulong steamID, string hashingMethodText, string stringToHash) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(hashingMethodText)) { - throw new ArgumentNullException(nameof(hashingMethodText)); - } - - if (string.IsNullOrEmpty(stringToHash)) { - throw new ArgumentNullException(nameof(stringToHash)); - } - - if (!ASF.IsOwner(steamID)) { - return null; - } - - if (!Enum.TryParse(hashingMethodText, true, out ArchiCryptoHelper.EHashingMethod hashingMethod)) { - return FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(hashingMethod))); - } - - string hash = Actions.Hash(hashingMethod, stringToHash); - - return FormatStaticResponse(!string.IsNullOrEmpty(hash) ? string.Format(CultureInfo.CurrentCulture, Strings.Result, hash) : Strings.WarningFailed); + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); } - private string? ResponseHelp(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - return Bot.HasAccess(steamID, BotConfig.EAccess.FamilySharing) ? FormatBotResponse($"{SharedInfo.ProjectURL}/wiki/Commands") : null; + if (string.IsNullOrEmpty(targetAppIDs)) { + throw new ArgumentNullException(nameof(targetAppIDs)); } - private string? ResponseIdleBlacklist(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + HashSet? bots = Bot.GetBots(botNames); - return !Bot.HasAccess(steamID, BotConfig.EAccess.Master) ? null : FormatBotResponse(Bot.BotDatabase.IdlingBlacklistedAppIDs.Count == 0 ? string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotDatabase.IdlingBlacklistedAppIDs)) : string.Join(", ", Bot.BotDatabase.IdlingBlacklistedAppIDs)); + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; } - private static async Task ResponseIdleBlacklist(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseIdleBlacklistRemove(steamID, targetAppIDs)))).ConfigureAwait(false); - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - HashSet? bots = Bot.GetBots(botNames); + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseIdleBlacklist(steamID)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + private string? ResponseIdleQueue(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - private string? ResponseIdleBlacklistAdd(ulong steamID, string targetAppIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + return !Bot.HasAccess(steamID, BotConfig.EAccess.Master) ? null : FormatBotResponse(Bot.BotDatabase.IdlingPriorityAppIDs.Count == 0 ? string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotDatabase.IdlingPriorityAppIDs)) : string.Join(", ", Bot.BotDatabase.IdlingPriorityAppIDs)); + } - if (string.IsNullOrEmpty(targetAppIDs)) { - throw new ArgumentNullException(nameof(targetAppIDs)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - string[] targets = targetAppIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (targets.Length == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); - } - - HashSet appIDs = new(); - - foreach (string target in targets) { - if (!uint.TryParse(target, out uint appID) || (appID == 0)) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(appID))); - } - - appIDs.Add(appID); - } - - if (!Bot.BotDatabase.IdlingBlacklistedAppIDs.AddRange(appIDs)) { - return FormatBotResponse(Strings.NothingFound); - } - - if (Bot.CardsFarmer.NowFarming && Bot.CardsFarmer.GamesToFarmReadOnly.Any(game => appIDs.Contains(game.AppID))) { - Utilities.InBackground( - async () => { - await Bot.CardsFarmer.StopFarming().ConfigureAwait(false); - await Bot.CardsFarmer.StartFarming().ConfigureAwait(false); - } - ); - } - - return FormatBotResponse(Strings.Done); + private static async Task ResponseIdleQueue(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - private static async Task ResponseIdleBlacklistAdd(ulong steamID, string botNames, string targetAppIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(targetAppIDs)) { - throw new ArgumentNullException(nameof(targetAppIDs)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseIdleBlacklistAdd(steamID, targetAppIDs)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); } - private string? ResponseIdleBlacklistRemove(ulong steamID, string targetAppIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseIdleQueue(steamID)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private string? ResponseIdleQueueAdd(ulong steamID, string targetAppIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(targetAppIDs)) { + throw new ArgumentNullException(nameof(targetAppIDs)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + string[] targets = targetAppIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (targets.Length == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); + } + + HashSet appIDs = new(); + + foreach (string target in targets) { + if (!uint.TryParse(target, out uint appID) || (appID == 0)) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(appID))); } - if (string.IsNullOrEmpty(targetAppIDs)) { - throw new ArgumentNullException(nameof(targetAppIDs)); - } + appIDs.Add(appID); + } - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } + if (!Bot.BotDatabase.IdlingPriorityAppIDs.AddRange(appIDs)) { + return FormatBotResponse(Strings.NothingFound); + } - string[] targets = targetAppIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (targets.Length == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); - } - - HashSet appIDs = new(); - - foreach (string target in targets) { - if (!uint.TryParse(target, out uint appID) || (appID == 0)) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(appID))); - } - - appIDs.Add(appID); - } - - if (!Bot.BotDatabase.IdlingBlacklistedAppIDs.RemoveRange(appIDs)) { - return FormatBotResponse(Strings.NothingFound); - } - - if (!Bot.CardsFarmer.NowFarming) { + switch (Bot.CardsFarmer.NowFarming) { + case false when Bot.BotConfig.FarmPriorityQueueOnly: Utilities.InBackground(Bot.CardsFarmer.StartFarming); - } - return FormatBotResponse(Strings.Done); - } - - private static async Task ResponseIdleBlacklistRemove(ulong steamID, string botNames, string targetAppIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(targetAppIDs)) { - throw new ArgumentNullException(nameof(targetAppIDs)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseIdleBlacklistRemove(steamID, targetAppIDs)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private string? ResponseIdleQueue(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - return !Bot.HasAccess(steamID, BotConfig.EAccess.Master) ? null : FormatBotResponse(Bot.BotDatabase.IdlingPriorityAppIDs.Count == 0 ? string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotDatabase.IdlingPriorityAppIDs)) : string.Join(", ", Bot.BotDatabase.IdlingPriorityAppIDs)); - } - - private static async Task ResponseIdleQueue(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseIdleQueue(steamID)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private string? ResponseIdleQueueAdd(ulong steamID, string targetAppIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(targetAppIDs)) { - throw new ArgumentNullException(nameof(targetAppIDs)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - string[] targets = targetAppIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (targets.Length == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); - } - - HashSet appIDs = new(); - - foreach (string target in targets) { - if (!uint.TryParse(target, out uint appID) || (appID == 0)) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(appID))); - } - - appIDs.Add(appID); - } - - if (!Bot.BotDatabase.IdlingPriorityAppIDs.AddRange(appIDs)) { - return FormatBotResponse(Strings.NothingFound); - } - - switch (Bot.CardsFarmer.NowFarming) { - case false when Bot.BotConfig.FarmPriorityQueueOnly: - Utilities.InBackground(Bot.CardsFarmer.StartFarming); - - break; - case true when Bot.CardsFarmer.GamesToFarmReadOnly.Any(game => appIDs.Contains(game.AppID)): - Utilities.InBackground( - async () => { - await Bot.CardsFarmer.StopFarming().ConfigureAwait(false); - await Bot.CardsFarmer.StartFarming().ConfigureAwait(false); - } - ); - - break; - } - - return FormatBotResponse(Strings.Done); - } - - private static async Task ResponseIdleQueueAdd(ulong steamID, string botNames, string targetAppIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(targetAppIDs)) { - throw new ArgumentNullException(nameof(targetAppIDs)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseIdleQueueAdd(steamID, targetAppIDs)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private string? ResponseIdleQueueRemove(ulong steamID, string targetAppIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(targetAppIDs)) { - throw new ArgumentNullException(nameof(targetAppIDs)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - string[] targets = targetAppIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (targets.Length == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); - } - - HashSet appIDs = new(); - - foreach (string target in targets) { - if (!uint.TryParse(target, out uint appID) || (appID == 0)) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(appID))); - } - - appIDs.Add(appID); - } - - if (!Bot.BotDatabase.IdlingPriorityAppIDs.RemoveRange(appIDs)) { - return FormatBotResponse(Strings.NothingFound); - } - - if (Bot.CardsFarmer.NowFarming && Bot.CardsFarmer.GamesToFarmReadOnly.Any(game => appIDs.Contains(game.AppID))) { + break; + case true when Bot.CardsFarmer.GamesToFarmReadOnly.Any(game => appIDs.Contains(game.AppID)): Utilities.InBackground( async () => { await Bot.CardsFarmer.StopFarming().ConfigureAwait(false); await Bot.CardsFarmer.StartFarming().ConfigureAwait(false); } ); - } - return FormatBotResponse(Strings.Done); + break; } - private static async Task ResponseIdleQueueRemove(ulong steamID, string botNames, string targetAppIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + return FormatBotResponse(Strings.Done); + } - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(targetAppIDs)) { - throw new ArgumentNullException(nameof(targetAppIDs)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseIdleQueueRemove(steamID, targetAppIDs)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + private static async Task ResponseIdleQueueAdd(ulong steamID, string botNames, string targetAppIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - private string? ResponseInput(ulong steamID, string propertyName, string inputValue) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(propertyName)) { - throw new ArgumentNullException(nameof(propertyName)); - } - - if (string.IsNullOrEmpty(inputValue)) { - throw new ArgumentNullException(nameof(inputValue)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - bool headless = Program.Service || (ASF.GlobalConfig?.Headless ?? GlobalConfig.DefaultHeadless); - - if (!headless) { - return FormatBotResponse(Strings.ErrorFunctionOnlyInHeadlessMode); - } - - if (!Enum.TryParse(propertyName, true, out ASF.EUserInputType inputType) || (inputType == ASF.EUserInputType.None) || !Enum.IsDefined(typeof(ASF.EUserInputType), inputType)) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(inputType))); - } - - bool result = Bot.SetUserInput(inputType, inputValue); - - return FormatBotResponse(result ? Strings.Done : Strings.WarningFailed); + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); } - private static async Task ResponseInput(ulong steamID, string botNames, string propertyName, string inputValue) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(propertyName)) { - throw new ArgumentNullException(nameof(propertyName)); - } - - if (string.IsNullOrEmpty(inputValue)) { - throw new ArgumentNullException(nameof(inputValue)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseInput(steamID, propertyName, inputValue)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + if (string.IsNullOrEmpty(targetAppIDs)) { + throw new ArgumentNullException(nameof(targetAppIDs)); } - private async Task ResponseLevel(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + HashSet? bots = Bot.GetBots(botNames); - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - uint? level = await Bot.ArchiHandler.GetLevel().ConfigureAwait(false); - - return FormatBotResponse(level.HasValue ? string.Format(CultureInfo.CurrentCulture, Strings.BotLevel, level.Value) : Strings.WarningFailed); + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; } - private static async Task ResponseLevel(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseIdleQueueAdd(steamID, targetAppIDs)))).ConfigureAwait(false); - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - HashSet? bots = Bot.GetBots(botNames); + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseLevel(steamID))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + private string? ResponseIdleQueueRemove(ulong steamID, string targetAppIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - private async Task ResponseLoot(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - if (Bot.BotConfig.LootableTypes.Count == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotConfig.LootableTypes))); - } - - (bool success, string message) = await Bot.Actions.SendInventory(filterFunction: item => Bot.BotConfig.LootableTypes.Contains(item.Type)).ConfigureAwait(false); - - return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + if (string.IsNullOrEmpty(targetAppIDs)) { + throw new ArgumentNullException(nameof(targetAppIDs)); } - private static async Task ResponseLoot(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseLoot(steamID))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; } - private async Task ResponseLootByRealAppIDs(ulong steamID, string realAppIDsText, bool exclude = false) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); + string[] targets = targetAppIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (targets.Length == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); + } + + HashSet appIDs = new(); + + foreach (string target in targets) { + if (!uint.TryParse(target, out uint appID) || (appID == 0)) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(appID))); } - if (string.IsNullOrEmpty(realAppIDsText)) { - throw new ArgumentNullException(nameof(realAppIDsText)); - } + appIDs.Add(appID); + } - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } + if (!Bot.BotDatabase.IdlingPriorityAppIDs.RemoveRange(appIDs)) { + return FormatBotResponse(Strings.NothingFound); + } - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - if (Bot.BotConfig.LootableTypes.Count == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotConfig.LootableTypes))); - } - - string[] appIDTexts = realAppIDsText.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (appIDTexts.Length == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(appIDTexts))); - } - - HashSet realAppIDs = new(); - - foreach (string appIDText in appIDTexts) { - if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(appID))); + if (Bot.CardsFarmer.NowFarming && Bot.CardsFarmer.GamesToFarmReadOnly.Any(game => appIDs.Contains(game.AppID))) { + Utilities.InBackground( + async () => { + await Bot.CardsFarmer.StopFarming().ConfigureAwait(false); + await Bot.CardsFarmer.StartFarming().ConfigureAwait(false); } - - realAppIDs.Add(appID); - } - - (bool success, string message) = await Bot.Actions.SendInventory(filterFunction: item => Bot.BotConfig.LootableTypes.Contains(item.Type) && (exclude ^ realAppIDs.Contains(item.RealAppID))).ConfigureAwait(false); - - return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + ); } - private static async Task ResponseLootByRealAppIDs(ulong steamID, string botNames, string realAppIDsText, bool exclude = false) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + return FormatBotResponse(Strings.Done); + } - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(realAppIDsText)) { - throw new ArgumentNullException(nameof(realAppIDsText)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseLootByRealAppIDs(steamID, realAppIDsText, exclude))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + private static async Task ResponseIdleQueueRemove(ulong steamID, string botNames, string targetAppIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - private string? ResponseMatchActivelyBlacklist(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - return !Bot.HasAccess(steamID, BotConfig.EAccess.Master) ? null : FormatBotResponse(Bot.BotDatabase.MatchActivelyBlacklistedAppIDs.Count == 0 ? string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotDatabase.MatchActivelyBlacklistedAppIDs)) : string.Join(", ", Bot.BotDatabase.MatchActivelyBlacklistedAppIDs)); + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); } - private static async Task ResponseMatchActivelyBlacklist(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseMatchActivelyBlacklist(steamID)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + if (string.IsNullOrEmpty(targetAppIDs)) { + throw new ArgumentNullException(nameof(targetAppIDs)); } - private string? ResponseMatchActivelyBlacklistAdd(ulong steamID, string targetAppIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + HashSet? bots = Bot.GetBots(botNames); - if (string.IsNullOrEmpty(targetAppIDs)) { - throw new ArgumentNullException(nameof(targetAppIDs)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - string[] targets = targetAppIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (targets.Length == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); - } - - HashSet appIDs = new(); - - foreach (string target in targets) { - if (!uint.TryParse(target, out uint appID) || (appID == 0)) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(appID))); - } - - appIDs.Add(appID); - } - - return FormatBotResponse(Bot.BotDatabase.MatchActivelyBlacklistedAppIDs.AddRange(appIDs) ? Strings.Done : Strings.NothingFound); + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; } - private static async Task ResponseMatchActivelyBlacklistAdd(ulong steamID, string botNames, string targetAppIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseIdleQueueRemove(steamID, targetAppIDs)))).ConfigureAwait(false); - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - if (string.IsNullOrEmpty(targetAppIDs)) { - throw new ArgumentNullException(nameof(targetAppIDs)); - } + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseMatchActivelyBlacklistAdd(steamID, targetAppIDs)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + private string? ResponseInput(ulong steamID, string propertyName, string inputValue) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - private string? ResponseMatchActivelyBlacklistRemove(ulong steamID, string targetAppIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(targetAppIDs)) { - throw new ArgumentNullException(nameof(targetAppIDs)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - string[] targets = targetAppIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (targets.Length == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); - } - - HashSet appIDs = new(); - - foreach (string target in targets) { - if (!uint.TryParse(target, out uint appID) || (appID == 0)) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(appID))); - } - - appIDs.Add(appID); - } - - return FormatBotResponse(Bot.BotDatabase.MatchActivelyBlacklistedAppIDs.RemoveRange(appIDs) ? Strings.Done : Strings.NothingFound); + if (string.IsNullOrEmpty(propertyName)) { + throw new ArgumentNullException(nameof(propertyName)); } - private static async Task ResponseMatchActivelyBlacklistRemove(ulong steamID, string botNames, string targetAppIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(targetAppIDs)) { - throw new ArgumentNullException(nameof(targetAppIDs)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseMatchActivelyBlacklistRemove(steamID, targetAppIDs)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + if (string.IsNullOrEmpty(inputValue)) { + throw new ArgumentNullException(nameof(inputValue)); } - private string? ResponseNickname(ulong steamID, string nickname) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(nickname)) { - throw new ArgumentNullException(nameof(nickname)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - Bot.SteamFriends.SetPersonaName(nickname); - - return FormatBotResponse(Strings.Done); + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; } - private static async Task ResponseNickname(ulong steamID, string botNames, string nickname) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + bool headless = Program.Service || (ASF.GlobalConfig?.Headless ?? GlobalConfig.DefaultHeadless); - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(nickname)) { - throw new ArgumentNullException(nameof(nickname)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseNickname(steamID, nickname)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + if (!headless) { + return FormatBotResponse(Strings.ErrorFunctionOnlyInHeadlessMode); } - private async Task<(string? Response, Dictionary? OwnedGames)> ResponseOwns(ulong steamID, string query) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); + if (!Enum.TryParse(propertyName, true, out ASF.EUserInputType inputType) || (inputType == ASF.EUserInputType.None) || !Enum.IsDefined(typeof(ASF.EUserInputType), inputType)) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(inputType))); + } + + bool result = Bot.SetUserInput(inputType, inputValue); + + return FormatBotResponse(result ? Strings.Done : Strings.WarningFailed); + } + + private static async Task ResponseInput(ulong steamID, string botNames, string propertyName, string inputValue) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (string.IsNullOrEmpty(propertyName)) { + throw new ArgumentNullException(nameof(propertyName)); + } + + if (string.IsNullOrEmpty(inputValue)) { + throw new ArgumentNullException(nameof(inputValue)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseInput(steamID, propertyName, inputValue)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task ResponseLevel(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + uint? level = await Bot.ArchiHandler.GetLevel().ConfigureAwait(false); + + return FormatBotResponse(level.HasValue ? string.Format(CultureInfo.CurrentCulture, Strings.BotLevel, level.Value) : Strings.WarningFailed); + } + + private static async Task ResponseLevel(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseLevel(steamID))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task ResponseLoot(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + if (Bot.BotConfig.LootableTypes.Count == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotConfig.LootableTypes))); + } + + (bool success, string message) = await Bot.Actions.SendInventory(filterFunction: item => Bot.BotConfig.LootableTypes.Contains(item.Type)).ConfigureAwait(false); + + return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + } + + private static async Task ResponseLoot(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseLoot(steamID))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task ResponseLootByRealAppIDs(ulong steamID, string realAppIDsText, bool exclude = false) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(realAppIDsText)) { + throw new ArgumentNullException(nameof(realAppIDsText)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + if (Bot.BotConfig.LootableTypes.Count == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotConfig.LootableTypes))); + } + + string[] appIDTexts = realAppIDsText.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (appIDTexts.Length == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(appIDTexts))); + } + + HashSet realAppIDs = new(); + + foreach (string appIDText in appIDTexts) { + if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(appID))); } - if (string.IsNullOrEmpty(query)) { - throw new ArgumentNullException(nameof(query)); + realAppIDs.Add(appID); + } + + (bool success, string message) = await Bot.Actions.SendInventory(filterFunction: item => Bot.BotConfig.LootableTypes.Contains(item.Type) && (exclude ^ realAppIDs.Contains(item.RealAppID))).ConfigureAwait(false); + + return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + } + + private static async Task ResponseLootByRealAppIDs(ulong steamID, string botNames, string realAppIDsText, bool exclude = false) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (string.IsNullOrEmpty(realAppIDsText)) { + throw new ArgumentNullException(nameof(realAppIDsText)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseLootByRealAppIDs(steamID, realAppIDsText, exclude))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private string? ResponseMatchActivelyBlacklist(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + return !Bot.HasAccess(steamID, BotConfig.EAccess.Master) ? null : FormatBotResponse(Bot.BotDatabase.MatchActivelyBlacklistedAppIDs.Count == 0 ? string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotDatabase.MatchActivelyBlacklistedAppIDs)) : string.Join(", ", Bot.BotDatabase.MatchActivelyBlacklistedAppIDs)); + } + + private static async Task ResponseMatchActivelyBlacklist(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseMatchActivelyBlacklist(steamID)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private string? ResponseMatchActivelyBlacklistAdd(ulong steamID, string targetAppIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(targetAppIDs)) { + throw new ArgumentNullException(nameof(targetAppIDs)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + string[] targets = targetAppIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (targets.Length == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); + } + + HashSet appIDs = new(); + + foreach (string target in targets) { + if (!uint.TryParse(target, out uint appID) || (appID == 0)) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(appID))); } - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Operator)) { - return (null, null); + appIDs.Add(appID); + } + + return FormatBotResponse(Bot.BotDatabase.MatchActivelyBlacklistedAppIDs.AddRange(appIDs) ? Strings.Done : Strings.NothingFound); + } + + private static async Task ResponseMatchActivelyBlacklistAdd(ulong steamID, string botNames, string targetAppIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (string.IsNullOrEmpty(targetAppIDs)) { + throw new ArgumentNullException(nameof(targetAppIDs)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseMatchActivelyBlacklistAdd(steamID, targetAppIDs)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private string? ResponseMatchActivelyBlacklistRemove(ulong steamID, string targetAppIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(targetAppIDs)) { + throw new ArgumentNullException(nameof(targetAppIDs)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + string[] targets = targetAppIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (targets.Length == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(targets))); + } + + HashSet appIDs = new(); + + foreach (string target in targets) { + if (!uint.TryParse(target, out uint appID) || (appID == 0)) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorParsingObject, nameof(appID))); } - if (!Bot.IsConnectedAndLoggedOn) { - return (FormatBotResponse(Strings.BotNotConnected), null); + appIDs.Add(appID); + } + + return FormatBotResponse(Bot.BotDatabase.MatchActivelyBlacklistedAppIDs.RemoveRange(appIDs) ? Strings.Done : Strings.NothingFound); + } + + private static async Task ResponseMatchActivelyBlacklistRemove(ulong steamID, string botNames, string targetAppIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (string.IsNullOrEmpty(targetAppIDs)) { + throw new ArgumentNullException(nameof(targetAppIDs)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseMatchActivelyBlacklistRemove(steamID, targetAppIDs)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private string? ResponseNickname(ulong steamID, string nickname) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(nickname)) { + throw new ArgumentNullException(nameof(nickname)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + Bot.SteamFriends.SetPersonaName(nickname); + + return FormatBotResponse(Strings.Done); + } + + private static async Task ResponseNickname(ulong steamID, string botNames, string nickname) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (string.IsNullOrEmpty(nickname)) { + throw new ArgumentNullException(nameof(nickname)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseNickname(steamID, nickname)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task<(string? Response, Dictionary? OwnedGames)> ResponseOwns(ulong steamID, string query) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(query)) { + throw new ArgumentNullException(nameof(query)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Operator)) { + return (null, null); + } + + if (!Bot.IsConnectedAndLoggedOn) { + return (FormatBotResponse(Strings.BotNotConnected), null); + } + + Dictionary? gamesOwned = await FetchGamesOwned(true).ConfigureAwait(false); + + StringBuilder response = new(); + Dictionary result = new(StringComparer.Ordinal); + + string[] entries = query.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (string entry in entries) { + string game; + string type; + + int index = entry.IndexOf('/', StringComparison.Ordinal); + + if ((index > 0) && (entry.Length > index + 1)) { + game = entry[(index + 1)..]; + type = entry[..index]; + } else if (uint.TryParse(entry, out uint appID) && (appID > 0)) { + game = entry; + type = "APP"; + } else { + game = entry; + type = "NAME"; } - Dictionary? gamesOwned = await FetchGamesOwned(true).ConfigureAwait(false); + switch (type.ToUpperInvariant()) { + case "A" when uint.TryParse(game, out uint appID) && (appID > 0): + case "APP" when uint.TryParse(game, out appID) && (appID > 0): + HashSet? packageIDs = ASF.GlobalDatabase?.GetPackageIDs(appID, Bot.OwnedPackageIDs.Keys); - StringBuilder response = new(); - Dictionary result = new(StringComparer.Ordinal); - - string[] entries = query.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - foreach (string entry in entries) { - string game; - string type; - - int index = entry.IndexOf('/', StringComparison.Ordinal); - - if ((index > 0) && (entry.Length > index + 1)) { - game = entry[(index + 1)..]; - type = entry[..index]; - } else if (uint.TryParse(entry, out uint appID) && (appID > 0)) { - game = entry; - type = "APP"; - } else { - game = entry; - type = "NAME"; - } - - switch (type.ToUpperInvariant()) { - case "A" when uint.TryParse(game, out uint appID) && (appID > 0): - case "APP" when uint.TryParse(game, out appID) && (appID > 0): - HashSet? packageIDs = ASF.GlobalDatabase?.GetPackageIDs(appID, Bot.OwnedPackageIDs.Keys); - - if (packageIDs?.Count > 0) { - if ((gamesOwned != null) && gamesOwned.TryGetValue(appID, out string? cachedGameName)) { - result[$"app/{appID}"] = cachedGameName; - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotOwnedAlreadyWithName, $"app/{appID}", cachedGameName))); - } else { - result[$"app/{appID}"] = appID.ToString(CultureInfo.InvariantCulture); - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotOwnedAlready, $"app/{appID}"))); - } + if (packageIDs?.Count > 0) { + if ((gamesOwned != null) && gamesOwned.TryGetValue(appID, out string? cachedGameName)) { + result[$"app/{appID}"] = cachedGameName; + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotOwnedAlreadyWithName, $"app/{appID}", cachedGameName))); } else { + result[$"app/{appID}"] = appID.ToString(CultureInfo.InvariantCulture); + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotOwnedAlready, $"app/{appID}"))); + } + } else { + if (gamesOwned == null) { + gamesOwned = await FetchGamesOwned().ConfigureAwait(false); + if (gamesOwned == null) { - gamesOwned = await FetchGamesOwned().ConfigureAwait(false); + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(gamesOwned)))); - if (gamesOwned == null) { - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(gamesOwned)))); - - break; - } - } - - if (gamesOwned.TryGetValue(appID, out string? gameName)) { - result[$"app/{appID}"] = gameName; - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotOwnedAlreadyWithName, $"app/{appID}", gameName))); - } else { - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotOwnedYet, $"app/{appID}"))); + break; } } + if (gamesOwned.TryGetValue(appID, out string? gameName)) { + result[$"app/{appID}"] = gameName; + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotOwnedAlreadyWithName, $"app/{appID}", gameName))); + } else { + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotOwnedYet, $"app/{appID}"))); + } + } + + break; + case "R": + case "REGEX": + Regex regex; + + try { + regex = new Regex(game); + } catch (ArgumentException e) { + Bot.ArchiLogger.LogGenericWarningException(e); + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(regex)))); + break; - case "R": - case "REGEX": - Regex regex; + } - try { - regex = new Regex(game); - } catch (ArgumentException e) { - Bot.ArchiLogger.LogGenericWarningException(e); - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(regex)))); + if (gamesOwned == null) { + gamesOwned = await FetchGamesOwned().ConfigureAwait(false); + + if (gamesOwned == null) { + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(gamesOwned)))); break; } + } - if (gamesOwned == null) { - gamesOwned = await FetchGamesOwned().ConfigureAwait(false); + bool foundWithRegex = false; - if (gamesOwned == null) { - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(gamesOwned)))); + foreach ((uint appID, string gameName) in gamesOwned.Where(gameOwned => regex.IsMatch(gameOwned.Value))) { + foundWithRegex = true; - break; - } - } + result[$"app/{appID}"] = gameName; + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotOwnedAlreadyWithName, $"app/{appID}", gameName))); + } - bool foundWithRegex = false; - - foreach ((uint appID, string gameName) in gamesOwned.Where(gameOwned => regex.IsMatch(gameOwned.Value))) { - foundWithRegex = true; - - result[$"app/{appID}"] = gameName; - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotOwnedAlreadyWithName, $"app/{appID}", gameName))); - } - - if (!foundWithRegex) { - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotOwnedYet, entry))); - } - - continue; - case "S" when uint.TryParse(game, out uint packageID) && (packageID > 0): - case "SUB" when uint.TryParse(game, out packageID) && (packageID > 0): - if (Bot.OwnedPackageIDs.ContainsKey(packageID)) { - result[$"sub/{packageID}"] = packageID.ToString(CultureInfo.InvariantCulture); - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotOwnedAlready, $"sub/{packageID}"))); - } else { - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotOwnedYet, $"sub/{packageID}"))); - } - - break; - default: - if (gamesOwned == null) { - gamesOwned = await FetchGamesOwned().ConfigureAwait(false); - - if (gamesOwned == null) { - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(gamesOwned)))); - - break; - } - } - - bool foundWithName = false; - - foreach ((uint appID, string gameName) in gamesOwned.Where(gameOwned => gameOwned.Value.Contains(game, StringComparison.OrdinalIgnoreCase))) { - foundWithName = true; - - result[$"app/{appID}"] = gameName; - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotOwnedAlreadyWithName, $"app/{appID}", gameName))); - } - - if (!foundWithName) { - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotOwnedYet, entry))); - } - - break; - } - } - - return (response.Length > 0 ? response.ToString() : FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotOwnedYet, query)), result); - } - - private static async Task ResponseOwns(ulong steamID, string botNames, string query) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(query)) { - throw new ArgumentNullException(nameof(query)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList<(string? Response, Dictionary? OwnedGames)> results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseOwns(steamID, query))).ConfigureAwait(false); - - List<(string Response, Dictionary OwnedGames)> validResults = new(results.Where(static result => !string.IsNullOrEmpty(result.Response) && (result.OwnedGames != null))!); - - if (validResults.Count == 0) { - return null; - } - - Dictionary ownedGamesStats = new(StringComparer.Ordinal); - - foreach ((string gameID, string gameName) in validResults.Where(static validResult => validResult.OwnedGames.Count > 0).SelectMany(static validResult => validResult.OwnedGames)) { - if (ownedGamesStats.TryGetValue(gameID, out (ushort Count, string GameName) ownedGameStats)) { - ownedGameStats.Count++; - } else { - ownedGameStats.Count = 1; - } - - if (!string.IsNullOrEmpty(gameName)) { - ownedGameStats.GameName = gameName; - } - - ownedGamesStats[gameID] = ownedGameStats; - } - - IEnumerable extraResponses = ownedGamesStats.Select(kv => FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotOwnsOverviewPerGame, kv.Value.Count, validResults.Count, kv.Key + (!string.IsNullOrEmpty(kv.Value.GameName) ? $" | {kv.Value.GameName}" : "")))); - - return string.Join(Environment.NewLine, validResults.Select(static result => result.Response).Concat(extraResponses)); - } - - private string? ResponsePassword(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - if (string.IsNullOrEmpty(Bot.BotConfig.DecryptedSteamPassword)) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(BotConfig.DecryptedSteamPassword))); - } - - Dictionary encryptedPasswords = new(2) { - { ArchiCryptoHelper.ECryptoMethod.AES, ArchiCryptoHelper.Encrypt(ArchiCryptoHelper.ECryptoMethod.AES, Bot.BotConfig.DecryptedSteamPassword!) ?? "" }, - { ArchiCryptoHelper.ECryptoMethod.ProtectedDataForCurrentUser, ArchiCryptoHelper.Encrypt(ArchiCryptoHelper.ECryptoMethod.ProtectedDataForCurrentUser, Bot.BotConfig.DecryptedSteamPassword!) ?? "" } - }; - - return FormatBotResponse(string.Join(", ", encryptedPasswords.Where(static kv => !string.IsNullOrEmpty(kv.Value)).Select(static kv => string.Format(CultureInfo.CurrentCulture, Strings.BotEncryptedPassword, kv.Key, kv.Value)))); - } - - private static async Task ResponsePassword(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponsePassword(steamID)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private async Task ResponsePause(ulong steamID, bool permanent, string? resumeInSecondsText = null) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.FamilySharing)) { - return null; - } - - if (permanent && !Bot.HasAccess(steamID, BotConfig.EAccess.Operator)) { - return FormatBotResponse(Strings.ErrorAccessDenied); - } - - ushort resumeInSeconds = 0; - - if (!string.IsNullOrEmpty(resumeInSecondsText) && (!ushort.TryParse(resumeInSecondsText, out resumeInSeconds) || (resumeInSeconds == 0))) { - return string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(resumeInSecondsText)); - } - - (bool success, string message) = await Bot.Actions.Pause(permanent, resumeInSeconds).ConfigureAwait(false); - - return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); - } - - private static async Task ResponsePause(ulong steamID, string botNames, bool permanent, string? resumeInSecondsText = null) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponsePause(steamID, permanent, resumeInSecondsText))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private async Task ResponsePlay(ulong steamID, IReadOnlyCollection gameIDs, string? gameName = null) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (gameIDs == null) { - throw new ArgumentNullException(nameof(gameIDs)); - } - - if (gameIDs.Count > ArchiHandler.MaxGamesPlayedConcurrently) { - throw new ArgumentOutOfRangeException(nameof(gameIDs)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - (bool success, string message) = await Bot.Actions.Play(gameIDs, gameName).ConfigureAwait(false); - - return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); - } - - private async Task ResponsePlay(ulong steamID, string targetGameIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(targetGameIDs)) { - throw new ArgumentNullException(nameof(targetGameIDs)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - string[] games = targetGameIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (games.Length == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(games))); - } - - HashSet gamesToPlay = new(); - StringBuilder gameName = new(); - - foreach (string game in games) { - if (!uint.TryParse(game, out uint gameID) || (gameID == 0)) { - gameName.Append((gameName.Length > 0 ? " " : "") + game); + if (!foundWithRegex) { + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotOwnedYet, entry))); + } continue; - } + case "S" when uint.TryParse(game, out uint packageID) && (packageID > 0): + case "SUB" when uint.TryParse(game, out packageID) && (packageID > 0): + if (Bot.OwnedPackageIDs.ContainsKey(packageID)) { + result[$"sub/{packageID}"] = packageID.ToString(CultureInfo.InvariantCulture); + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotOwnedAlready, $"sub/{packageID}"))); + } else { + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotOwnedYet, $"sub/{packageID}"))); + } - if (gamesToPlay.Count >= ArchiHandler.MaxGamesPlayedConcurrently) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"{nameof(gamesToPlay)} > {ArchiHandler.MaxGamesPlayedConcurrently}")); - } + break; + default: + if (gamesOwned == null) { + gamesOwned = await FetchGamesOwned().ConfigureAwait(false); - gamesToPlay.Add(gameID); + if (gamesOwned == null) { + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(gamesOwned)))); + + break; + } + } + + bool foundWithName = false; + + foreach ((uint appID, string gameName) in gamesOwned.Where(gameOwned => gameOwned.Value.Contains(game, StringComparison.OrdinalIgnoreCase))) { + foundWithName = true; + + result[$"app/{appID}"] = gameName; + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotOwnedAlreadyWithName, $"app/{appID}", gameName))); + } + + if (!foundWithName) { + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotOwnedYet, entry))); + } + + break; } - - return await ResponsePlay(steamID, gamesToPlay, gameName.Length > 0 ? gameName.ToString() : null).ConfigureAwait(false); } - private static async Task ResponsePlay(ulong steamID, string botNames, string targetGameIDs) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + return (response.Length > 0 ? response.ToString() : FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotOwnedYet, query)), result); + } - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(targetGameIDs)) { - throw new ArgumentNullException(nameof(targetGameIDs)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponsePlay(steamID, targetGameIDs))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + private static async Task ResponseOwns(ulong steamID, string botNames, string query) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - private async Task ResponsePointsBalance(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - uint? points = await Bot.ArchiWebHandler.GetPointsBalance().ConfigureAwait(false); - - return FormatBotResponse(points.HasValue ? string.Format(CultureInfo.CurrentCulture, Strings.BotPointsBalance, points) : Strings.WarningFailed); + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); } - private static async Task ResponsePointsBalance(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponsePointsBalance(steamID))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + if (string.IsNullOrEmpty(query)) { + throw new ArgumentNullException(nameof(query)); } - private async Task ResponsePrivacy(ulong steamID, string privacySettingsText) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList<(string? Response, Dictionary? OwnedGames)> results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseOwns(steamID, query))).ConfigureAwait(false); + + List<(string Response, Dictionary OwnedGames)> validResults = new(results.Where(static result => !string.IsNullOrEmpty(result.Response) && (result.OwnedGames != null))!); + + if (validResults.Count == 0) { + return null; + } + + Dictionary ownedGamesStats = new(StringComparer.Ordinal); + + foreach ((string gameID, string gameName) in validResults.Where(static validResult => validResult.OwnedGames.Count > 0).SelectMany(static validResult => validResult.OwnedGames)) { + if (ownedGamesStats.TryGetValue(gameID, out (ushort Count, string GameName) ownedGameStats)) { + ownedGameStats.Count++; + } else { + ownedGameStats.Count = 1; } - if (string.IsNullOrEmpty(privacySettingsText)) { - throw new ArgumentNullException(nameof(privacySettingsText)); + if (!string.IsNullOrEmpty(gameName)) { + ownedGameStats.GameName = gameName; } - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; + ownedGamesStats[gameID] = ownedGameStats; + } + + IEnumerable extraResponses = ownedGamesStats.Select(kv => FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotOwnsOverviewPerGame, kv.Value.Count, validResults.Count, kv.Key + (!string.IsNullOrEmpty(kv.Value.GameName) ? $" | {kv.Value.GameName}" : "")))); + + return string.Join(Environment.NewLine, validResults.Select(static result => result.Response).Concat(extraResponses)); + } + + private string? ResponsePassword(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (string.IsNullOrEmpty(Bot.BotConfig.DecryptedSteamPassword)) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(BotConfig.DecryptedSteamPassword))); + } + + Dictionary encryptedPasswords = new(2) { + { ArchiCryptoHelper.ECryptoMethod.AES, ArchiCryptoHelper.Encrypt(ArchiCryptoHelper.ECryptoMethod.AES, Bot.BotConfig.DecryptedSteamPassword!) ?? "" }, + { ArchiCryptoHelper.ECryptoMethod.ProtectedDataForCurrentUser, ArchiCryptoHelper.Encrypt(ArchiCryptoHelper.ECryptoMethod.ProtectedDataForCurrentUser, Bot.BotConfig.DecryptedSteamPassword!) ?? "" } + }; + + return FormatBotResponse(string.Join(", ", encryptedPasswords.Where(static kv => !string.IsNullOrEmpty(kv.Value)).Select(static kv => string.Format(CultureInfo.CurrentCulture, Strings.BotEncryptedPassword, kv.Key, kv.Value)))); + } + + private static async Task ResponsePassword(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponsePassword(steamID)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task ResponsePause(ulong steamID, bool permanent, string? resumeInSecondsText = null) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.FamilySharing)) { + return null; + } + + if (permanent && !Bot.HasAccess(steamID, BotConfig.EAccess.Operator)) { + return FormatBotResponse(Strings.ErrorAccessDenied); + } + + ushort resumeInSeconds = 0; + + if (!string.IsNullOrEmpty(resumeInSecondsText) && (!ushort.TryParse(resumeInSecondsText, out resumeInSeconds) || (resumeInSeconds == 0))) { + return string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(resumeInSecondsText)); + } + + (bool success, string message) = await Bot.Actions.Pause(permanent, resumeInSeconds).ConfigureAwait(false); + + return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + } + + private static async Task ResponsePause(ulong steamID, string botNames, bool permanent, string? resumeInSecondsText = null) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponsePause(steamID, permanent, resumeInSecondsText))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task ResponsePlay(ulong steamID, IReadOnlyCollection gameIDs, string? gameName = null) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (gameIDs == null) { + throw new ArgumentNullException(nameof(gameIDs)); + } + + if (gameIDs.Count > ArchiHandler.MaxGamesPlayedConcurrently) { + throw new ArgumentOutOfRangeException(nameof(gameIDs)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + (bool success, string message) = await Bot.Actions.Play(gameIDs, gameName).ConfigureAwait(false); + + return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + } + + private async Task ResponsePlay(ulong steamID, string targetGameIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(targetGameIDs)) { + throw new ArgumentNullException(nameof(targetGameIDs)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + string[] games = targetGameIDs.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (games.Length == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(games))); + } + + HashSet gamesToPlay = new(); + StringBuilder gameName = new(); + + foreach (string game in games) { + if (!uint.TryParse(game, out uint gameID) || (gameID == 0)) { + gameName.Append((gameName.Length > 0 ? " " : "") + game); + + continue; } - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); + if (gamesToPlay.Count >= ArchiHandler.MaxGamesPlayedConcurrently) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, $"{nameof(gamesToPlay)} > {ArchiHandler.MaxGamesPlayedConcurrently}")); } - // There are only 7 privacy settings - const byte privacySettings = 7; + gamesToPlay.Add(gameID); + } - string[] privacySettingsArgs = privacySettingsText.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + return await ResponsePlay(steamID, gamesToPlay, gameName.Length > 0 ? gameName.ToString() : null).ConfigureAwait(false); + } - switch (privacySettingsArgs.Length) { + private static async Task ResponsePlay(ulong steamID, string botNames, string targetGameIDs) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (string.IsNullOrEmpty(targetGameIDs)) { + throw new ArgumentNullException(nameof(targetGameIDs)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponsePlay(steamID, targetGameIDs))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task ResponsePointsBalance(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + uint? points = await Bot.ArchiWebHandler.GetPointsBalance().ConfigureAwait(false); + + return FormatBotResponse(points.HasValue ? string.Format(CultureInfo.CurrentCulture, Strings.BotPointsBalance, points) : Strings.WarningFailed); + } + + private static async Task ResponsePointsBalance(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponsePointsBalance(steamID))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task ResponsePrivacy(ulong steamID, string privacySettingsText) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(privacySettingsText)) { + throw new ArgumentNullException(nameof(privacySettingsText)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + // There are only 7 privacy settings + const byte privacySettings = 7; + + string[] privacySettingsArgs = privacySettingsText.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + switch (privacySettingsArgs.Length) { + case 0: + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(privacySettingsArgs))); + case > privacySettings: + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(privacySettingsArgs))); + } + + ArchiHandler.EPrivacySetting profile = ArchiHandler.EPrivacySetting.Private; + ArchiHandler.EPrivacySetting ownedGames = ArchiHandler.EPrivacySetting.Private; + ArchiHandler.EPrivacySetting playtime = ArchiHandler.EPrivacySetting.Private; + ArchiHandler.EPrivacySetting friendsList = ArchiHandler.EPrivacySetting.Private; + ArchiHandler.EPrivacySetting inventory = ArchiHandler.EPrivacySetting.Private; + ArchiHandler.EPrivacySetting inventoryGifts = ArchiHandler.EPrivacySetting.Private; + UserPrivacy.ECommentPermission comments = UserPrivacy.ECommentPermission.Private; + + // Converting digits to enum + for (byte index = 0; index < privacySettingsArgs.Length; index++) { + if (!Enum.TryParse(privacySettingsArgs[index], true, out ArchiHandler.EPrivacySetting privacySetting) || (privacySetting == ArchiHandler.EPrivacySetting.Unknown) || !Enum.IsDefined(typeof(ArchiHandler.EPrivacySetting), privacySetting)) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(privacySettingsArgs))); + } + + // Child setting can't be less restrictive than its parent + switch (index) { case 0: - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(privacySettingsArgs))); - case > privacySettings: - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(privacySettingsArgs))); + // Profile + profile = privacySetting; + + break; + case 1: + // OwnedGames, child of Profile + if (profile < privacySetting) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(ownedGames))); + } + + ownedGames = privacySetting; + + break; + case 2: + // Playtime, child of OwnedGames + if (ownedGames < privacySetting) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(playtime))); + } + + playtime = privacySetting; + + break; + case 3: + // FriendsList, child of Profile + if (profile < privacySetting) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(ownedGames))); + } + + friendsList = privacySetting; + + break; + case 4: + // Inventory, child of Profile + if (profile < privacySetting) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(inventory))); + } + + inventory = privacySetting; + + break; + case 5: + // InventoryGifts, child of Inventory + if (inventory < privacySetting) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(inventoryGifts))); + } + + inventoryGifts = privacySetting; + + break; + case 6: + // Comments, child of Profile + if (profile < privacySetting) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(comments))); + } + + // Comments use different numbers than everything else, but we want to have this command consistent for end-user, so we'll map them + switch (privacySetting) { + case ArchiHandler.EPrivacySetting.FriendsOnly: + comments = UserPrivacy.ECommentPermission.FriendsOnly; + + break; + case ArchiHandler.EPrivacySetting.Private: + comments = UserPrivacy.ECommentPermission.Private; + + break; + case ArchiHandler.EPrivacySetting.Public: + comments = UserPrivacy.ECommentPermission.Public; + + break; + default: + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(privacySetting), privacySetting)); + + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(privacySetting))); + } + + break; + default: + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(index), index)); + + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(index))); } - - ArchiHandler.EPrivacySetting profile = ArchiHandler.EPrivacySetting.Private; - ArchiHandler.EPrivacySetting ownedGames = ArchiHandler.EPrivacySetting.Private; - ArchiHandler.EPrivacySetting playtime = ArchiHandler.EPrivacySetting.Private; - ArchiHandler.EPrivacySetting friendsList = ArchiHandler.EPrivacySetting.Private; - ArchiHandler.EPrivacySetting inventory = ArchiHandler.EPrivacySetting.Private; - ArchiHandler.EPrivacySetting inventoryGifts = ArchiHandler.EPrivacySetting.Private; - UserPrivacy.ECommentPermission comments = UserPrivacy.ECommentPermission.Private; - - // Converting digits to enum - for (byte index = 0; index < privacySettingsArgs.Length; index++) { - if (!Enum.TryParse(privacySettingsArgs[index], true, out ArchiHandler.EPrivacySetting privacySetting) || (privacySetting == ArchiHandler.EPrivacySetting.Unknown) || !Enum.IsDefined(typeof(ArchiHandler.EPrivacySetting), privacySetting)) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(privacySettingsArgs))); - } - - // Child setting can't be less restrictive than its parent - switch (index) { - case 0: - // Profile - profile = privacySetting; - - break; - case 1: - // OwnedGames, child of Profile - if (profile < privacySetting) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(ownedGames))); - } - - ownedGames = privacySetting; - - break; - case 2: - // Playtime, child of OwnedGames - if (ownedGames < privacySetting) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(playtime))); - } - - playtime = privacySetting; - - break; - case 3: - // FriendsList, child of Profile - if (profile < privacySetting) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(ownedGames))); - } - - friendsList = privacySetting; - - break; - case 4: - // Inventory, child of Profile - if (profile < privacySetting) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(inventory))); - } - - inventory = privacySetting; - - break; - case 5: - // InventoryGifts, child of Inventory - if (inventory < privacySetting) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(inventoryGifts))); - } - - inventoryGifts = privacySetting; - - break; - case 6: - // Comments, child of Profile - if (profile < privacySetting) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(comments))); - } - - // Comments use different numbers than everything else, but we want to have this command consistent for end-user, so we'll map them - switch (privacySetting) { - case ArchiHandler.EPrivacySetting.FriendsOnly: - comments = UserPrivacy.ECommentPermission.FriendsOnly; - - break; - case ArchiHandler.EPrivacySetting.Private: - comments = UserPrivacy.ECommentPermission.Private; - - break; - case ArchiHandler.EPrivacySetting.Public: - comments = UserPrivacy.ECommentPermission.Public; - - break; - default: - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(privacySetting), privacySetting)); - - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(privacySetting))); - } - - break; - default: - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(index), index)); - - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(index))); - } - } - - UserPrivacy userPrivacy = new(new UserPrivacy.PrivacySettings(profile, ownedGames, playtime, friendsList, inventory, inventoryGifts), comments); - - return FormatBotResponse(await Bot.ArchiWebHandler.ChangePrivacySettings(userPrivacy).ConfigureAwait(false) ? Strings.Success : Strings.WarningFailed); } - private static async Task ResponsePrivacy(ulong steamID, string botNames, string privacySettingsText) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + UserPrivacy userPrivacy = new(new UserPrivacy.PrivacySettings(profile, ownedGames, playtime, friendsList, inventory, inventoryGifts), comments); - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } + return FormatBotResponse(await Bot.ArchiWebHandler.ChangePrivacySettings(userPrivacy).ConfigureAwait(false) ? Strings.Success : Strings.WarningFailed); + } - if (string.IsNullOrEmpty(privacySettingsText)) { - throw new ArgumentNullException(nameof(privacySettingsText)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponsePrivacy(steamID, privacySettingsText))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + private static async Task ResponsePrivacy(ulong steamID, string botNames, string privacySettingsText) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - private async Task ResponseRedeem(ulong steamID, string keysText, ERedeemFlags redeemFlags = ERedeemFlags.None) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } - if (string.IsNullOrEmpty(keysText)) { - throw new ArgumentNullException(nameof(keysText)); - } + if (string.IsNullOrEmpty(privacySettingsText)) { + throw new ArgumentNullException(nameof(privacySettingsText)); + } - if (Bot.Bots == null) { - throw new InvalidOperationException(nameof(Bot.Bots)); - } + HashSet? bots = Bot.GetBots(botNames); - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Operator)) { - return null; - } + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponsePrivacy(steamID, privacySettingsText))).ConfigureAwait(false); - string[] keys = keysText.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - if (keys.Length == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(keys))); - } + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } - bool forward = !redeemFlags.HasFlag(ERedeemFlags.SkipForwarding) && (redeemFlags.HasFlag(ERedeemFlags.ForceForwarding) || Bot.BotConfig.RedeemingPreferences.HasFlag(BotConfig.ERedeemingPreferences.Forwarding)); - bool distribute = !redeemFlags.HasFlag(ERedeemFlags.SkipDistributing) && (redeemFlags.HasFlag(ERedeemFlags.ForceDistributing) || Bot.BotConfig.RedeemingPreferences.HasFlag(BotConfig.ERedeemingPreferences.Distributing)); - bool keepMissingGames = !redeemFlags.HasFlag(ERedeemFlags.SkipKeepMissingGames) && (redeemFlags.HasFlag(ERedeemFlags.ForceKeepMissingGames) || Bot.BotConfig.RedeemingPreferences.HasFlag(BotConfig.ERedeemingPreferences.KeepMissingGames)); - bool assumeWalletKeyOnBadActivationCode = !redeemFlags.HasFlag(ERedeemFlags.SkipAssumeWalletKeyOnBadActivationCode) && (redeemFlags.HasFlag(ERedeemFlags.ForceAssumeWalletKeyOnBadActivationCode) || Bot.BotConfig.RedeemingPreferences.HasFlag(BotConfig.ERedeemingPreferences.AssumeWalletKeyOnBadActivationCode)); + private async Task ResponseRedeem(ulong steamID, string keysText, ERedeemFlags redeemFlags = ERedeemFlags.None) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } - HashSet pendingKeys = keys.ToHashSet(StringComparer.Ordinal); - HashSet unusedKeys = pendingKeys.ToHashSet(StringComparer.Ordinal); + if (string.IsNullOrEmpty(keysText)) { + throw new ArgumentNullException(nameof(keysText)); + } - HashSet rateLimitedBots = new(); - HashSet triedBots = new(); + if (Bot.Bots == null) { + throw new InvalidOperationException(nameof(Bot.Bots)); + } - StringBuilder response = new(); + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Operator)) { + return null; + } - using (HashSet.Enumerator keysEnumerator = pendingKeys.GetEnumerator()) { - // Initial key - string? key = keysEnumerator.MoveNext() ? keysEnumerator.Current : null; - string? previousKey = key; + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } - while (!string.IsNullOrEmpty(key)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - string startingKey = key!; + string[] keys = keysText.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - using (IEnumerator botsEnumerator = Bot.Bots.Where(bot => (bot.Value != Bot) && bot.Value.IsConnectedAndLoggedOn && bot.Value.Commands.Bot.HasAccess(steamID, BotConfig.EAccess.Operator)).OrderByDescending(bot => Bot.BotsComparer?.Compare(bot.Key, Bot.BotName) > 0).ThenBy(static bot => bot.Key, Bot.BotsComparer).Select(static bot => bot.Value).GetEnumerator()) { - Bot? currentBot = Bot; + if (keys.Length == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(keys))); + } - while (!string.IsNullOrEmpty(key) && (currentBot != null)) { - if (previousKey != key) { - triedBots.Clear(); - previousKey = key; - } + bool forward = !redeemFlags.HasFlag(ERedeemFlags.SkipForwarding) && (redeemFlags.HasFlag(ERedeemFlags.ForceForwarding) || Bot.BotConfig.RedeemingPreferences.HasFlag(BotConfig.ERedeemingPreferences.Forwarding)); + bool distribute = !redeemFlags.HasFlag(ERedeemFlags.SkipDistributing) && (redeemFlags.HasFlag(ERedeemFlags.ForceDistributing) || Bot.BotConfig.RedeemingPreferences.HasFlag(BotConfig.ERedeemingPreferences.Distributing)); + bool keepMissingGames = !redeemFlags.HasFlag(ERedeemFlags.SkipKeepMissingGames) && (redeemFlags.HasFlag(ERedeemFlags.ForceKeepMissingGames) || Bot.BotConfig.RedeemingPreferences.HasFlag(BotConfig.ERedeemingPreferences.KeepMissingGames)); + bool assumeWalletKeyOnBadActivationCode = !redeemFlags.HasFlag(ERedeemFlags.SkipAssumeWalletKeyOnBadActivationCode) && (redeemFlags.HasFlag(ERedeemFlags.ForceAssumeWalletKeyOnBadActivationCode) || Bot.BotConfig.RedeemingPreferences.HasFlag(BotConfig.ERedeemingPreferences.AssumeWalletKeyOnBadActivationCode)); + + HashSet pendingKeys = keys.ToHashSet(StringComparer.Ordinal); + HashSet unusedKeys = pendingKeys.ToHashSet(StringComparer.Ordinal); + + HashSet rateLimitedBots = new(); + HashSet triedBots = new(); + + StringBuilder response = new(); + + using (HashSet.Enumerator keysEnumerator = pendingKeys.GetEnumerator()) { + // Initial key + string? key = keysEnumerator.MoveNext() ? keysEnumerator.Current : null; + string? previousKey = key; + + while (!string.IsNullOrEmpty(key)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + string startingKey = key!; + + using (IEnumerator botsEnumerator = Bot.Bots.Where(bot => (bot.Value != Bot) && bot.Value.IsConnectedAndLoggedOn && bot.Value.Commands.Bot.HasAccess(steamID, BotConfig.EAccess.Operator)).OrderByDescending(bot => Bot.BotsComparer?.Compare(bot.Key, Bot.BotName) > 0).ThenBy(static bot => bot.Key, Bot.BotsComparer).Select(static bot => bot.Value).GetEnumerator()) { + Bot? currentBot = Bot; + + while (!string.IsNullOrEmpty(key) && (currentBot != null)) { + if (previousKey != key) { + triedBots.Clear(); + previousKey = key; + } + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + if (redeemFlags.HasFlag(ERedeemFlags.Validate) && !Utilities.IsValidCdKey(key!)) { + // Next key + key = keysEnumerator.MoveNext() ? keysEnumerator.Current : null; + + // Keep current bot + continue; + } + + if ((currentBot == Bot) && redeemFlags.HasFlag(ERedeemFlags.SkipInitial)) { + // Either bot will be changed, or loop aborted + currentBot = null; + } else { + bool skipRequest = triedBots.Contains(currentBot) || rateLimitedBots.Contains(currentBot); // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - if (redeemFlags.HasFlag(ERedeemFlags.Validate) && !Utilities.IsValidCdKey(key!)) { - // Next key - key = keysEnumerator.MoveNext() ? keysEnumerator.Current : null; + PurchaseResponseCallback? result = skipRequest ? new PurchaseResponseCallback(EResult.Fail, EPurchaseResultDetail.CancelledByUser) : await currentBot.Actions.RedeemKey(key!).ConfigureAwait(false); - // Keep current bot - continue; - } + if (result == null) { + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotRedeem, key, EPurchaseResultDetail.Timeout), currentBot.BotName)); - if ((currentBot == Bot) && redeemFlags.HasFlag(ERedeemFlags.SkipInitial)) { // Either bot will be changed, or loop aborted currentBot = null; } else { - bool skipRequest = triedBots.Contains(currentBot) || rateLimitedBots.Contains(currentBot); + triedBots.Add(currentBot); - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - PurchaseResponseCallback? result = skipRequest ? new PurchaseResponseCallback(EResult.Fail, EPurchaseResultDetail.CancelledByUser) : await currentBot.Actions.RedeemKey(key!).ConfigureAwait(false); + if ((result.PurchaseResultDetail == EPurchaseResultDetail.CannotRedeemCodeFromClient) || ((result.PurchaseResultDetail == EPurchaseResultDetail.BadActivationCode) && assumeWalletKeyOnBadActivationCode)) { + if (Bot.WalletCurrency != ECurrencyCode.Invalid) { + // If it's a wallet code, we try to redeem it first, then handle the inner result as our primary one + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + (EResult Result, EPurchaseResultDetail? PurchaseResult)? walletResult = await currentBot.ArchiWebHandler.RedeemWalletKey(key!).ConfigureAwait(false); - if (result == null) { - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotRedeem, key, EPurchaseResultDetail.Timeout), currentBot.BotName)); - - // Either bot will be changed, or loop aborted - currentBot = null; - } else { - triedBots.Add(currentBot); - - if ((result.PurchaseResultDetail == EPurchaseResultDetail.CannotRedeemCodeFromClient) || ((result.PurchaseResultDetail == EPurchaseResultDetail.BadActivationCode) && assumeWalletKeyOnBadActivationCode)) { - if (Bot.WalletCurrency != ECurrencyCode.Invalid) { - // If it's a wallet code, we try to redeem it first, then handle the inner result as our primary one - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - (EResult Result, EPurchaseResultDetail? PurchaseResult)? walletResult = await currentBot.ArchiWebHandler.RedeemWalletKey(key!).ConfigureAwait(false); - - if (walletResult != null) { - result.Result = walletResult.Value.Result; - result.PurchaseResultDetail = walletResult.Value.PurchaseResult.GetValueOrDefault(walletResult.Value.Result == EResult.OK ? EPurchaseResultDetail.NoDetail : EPurchaseResultDetail.CannotRedeemCodeFromClient); - } else { - result.Result = EResult.Timeout; - result.PurchaseResultDetail = EPurchaseResultDetail.Timeout; - } + if (walletResult != null) { + result.Result = walletResult.Value.Result; + result.PurchaseResultDetail = walletResult.Value.PurchaseResult.GetValueOrDefault(walletResult.Value.Result == EResult.OK ? EPurchaseResultDetail.NoDetail : EPurchaseResultDetail.CannotRedeemCodeFromClient); } else { - // We're unable to redeem this code from the client due to missing currency information - result.PurchaseResultDetail = EPurchaseResultDetail.CannotRedeemCodeFromClient; + result.Result = EResult.Timeout; + result.PurchaseResultDetail = EPurchaseResultDetail.Timeout; } - } - - if (result.Items?.Count > 0) { - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotRedeemWithItems, key, $"{result.Result}/{result.PurchaseResultDetail}", string.Join(", ", result.Items)), currentBot.BotName)); - } else if (!skipRequest) { - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotRedeem, key, $"{result.Result}/{result.PurchaseResultDetail}"), currentBot.BotName)); - } - - switch (result.PurchaseResultDetail) { - case EPurchaseResultDetail.BadActivationCode: - case EPurchaseResultDetail.CannotRedeemCodeFromClient: - case EPurchaseResultDetail.DuplicateActivationCode: - case EPurchaseResultDetail.NoDetail: // OK - case EPurchaseResultDetail.Timeout: - if ((result.Result != EResult.Timeout) && (result.PurchaseResultDetail != EPurchaseResultDetail.Timeout)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - unusedKeys.Remove(key!); - } - - // Next key - key = keysEnumerator.MoveNext() ? keysEnumerator.Current : null; - - if (result.PurchaseResultDetail == EPurchaseResultDetail.NoDetail) { - // Next bot (if needed) - break; - } - - // Keep current bot - continue; - case EPurchaseResultDetail.AccountLocked: - case EPurchaseResultDetail.AlreadyPurchased: - case EPurchaseResultDetail.CancelledByUser: - case EPurchaseResultDetail.DoesNotOwnRequiredApp: - case EPurchaseResultDetail.RestrictedCountry: - if (!forward || (keepMissingGames && (result.PurchaseResultDetail != EPurchaseResultDetail.AlreadyPurchased))) { - // Next key - key = keysEnumerator.MoveNext() ? keysEnumerator.Current : null; - - // Next bot (if needed) - break; - } - - if (distribute) { - // Next bot, without changing key - break; - } - - Dictionary items = result.Items ?? new Dictionary(); - - bool alreadyHandled = false; - - foreach (Bot innerBot in Bot.Bots.Where(bot => (bot.Value != currentBot) && (!redeemFlags.HasFlag(ERedeemFlags.SkipInitial) || (bot.Value != Bot)) && !triedBots.Contains(bot.Value) && !rateLimitedBots.Contains(bot.Value) && bot.Value.IsConnectedAndLoggedOn && bot.Value.Commands.Bot.HasAccess(steamID, BotConfig.EAccess.Operator) && ((items.Count == 0) || items.Keys.Any(packageID => !bot.Value.OwnedPackageIDs.ContainsKey(packageID)))).OrderBy(static bot => bot.Key, Bot.BotsComparer).Select(static bot => bot.Value)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - PurchaseResponseCallback? otherResult = await innerBot.Actions.RedeemKey(key!).ConfigureAwait(false); - - if (otherResult == null) { - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotRedeem, key, $"{EResult.Timeout}/{EPurchaseResultDetail.Timeout}"), innerBot.BotName)); - - continue; - } - - triedBots.Add(innerBot); - - switch (otherResult.PurchaseResultDetail) { - case EPurchaseResultDetail.BadActivationCode: - case EPurchaseResultDetail.DuplicateActivationCode: - case EPurchaseResultDetail.NoDetail: // OK - // This key is already handled, as we either redeemed it or we're sure it's dupe/invalid - alreadyHandled = true; - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - unusedKeys.Remove(key!); - - break; - case EPurchaseResultDetail.RateLimited: - rateLimitedBots.Add(innerBot); - - break; - } - - response.AppendLine(FormatBotResponse(otherResult.Items?.Count > 0 ? string.Format(CultureInfo.CurrentCulture, Strings.BotRedeemWithItems, key, $"{otherResult.Result}/{otherResult.PurchaseResultDetail}", string.Join(", ", otherResult.Items)) : string.Format(CultureInfo.CurrentCulture, Strings.BotRedeem, key, $"{otherResult.Result}/{otherResult.PurchaseResultDetail}"), innerBot.BotName)); - - if (alreadyHandled) { - break; - } - - if (otherResult.Items == null) { - continue; - } - - foreach ((uint packageID, string packageName) in otherResult.Items.Where(item => !items.ContainsKey(item.Key))) { - items[packageID] = packageName; - } - } - - // Next key - key = keysEnumerator.MoveNext() ? keysEnumerator.Current : null; - - // Next bot (if needed) - break; - case EPurchaseResultDetail.RateLimited: - rateLimitedBots.Add(currentBot); - - goto case EPurchaseResultDetail.CancelledByUser; - default: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(result.PurchaseResultDetail), result.PurchaseResultDetail)); - - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - unusedKeys.Remove(key!); - - // Next key - key = keysEnumerator.MoveNext() ? keysEnumerator.Current : null; - - // Next bot (if needed) - break; + } else { + // We're unable to redeem this code from the client due to missing currency information + result.PurchaseResultDetail = EPurchaseResultDetail.CannotRedeemCodeFromClient; } } - } - // We want to change bot in two cases: - // a) When we have distribution enabled, obviously - // b) When we're skipping initial bot AND we have forwarding enabled, otherwise we won't get down to other accounts - if (distribute || (forward && redeemFlags.HasFlag(ERedeemFlags.SkipInitial))) { - currentBot = botsEnumerator.MoveNext() ? botsEnumerator.Current : null; + if (result.Items?.Count > 0) { + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotRedeemWithItems, key, $"{result.Result}/{result.PurchaseResultDetail}", string.Join(", ", result.Items)), currentBot.BotName)); + } else if (!skipRequest) { + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotRedeem, key, $"{result.Result}/{result.PurchaseResultDetail}"), currentBot.BotName)); + } + + switch (result.PurchaseResultDetail) { + case EPurchaseResultDetail.BadActivationCode: + case EPurchaseResultDetail.CannotRedeemCodeFromClient: + case EPurchaseResultDetail.DuplicateActivationCode: + case EPurchaseResultDetail.NoDetail: // OK + case EPurchaseResultDetail.Timeout: + if ((result.Result != EResult.Timeout) && (result.PurchaseResultDetail != EPurchaseResultDetail.Timeout)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + unusedKeys.Remove(key!); + } + + // Next key + key = keysEnumerator.MoveNext() ? keysEnumerator.Current : null; + + if (result.PurchaseResultDetail == EPurchaseResultDetail.NoDetail) { + // Next bot (if needed) + break; + } + + // Keep current bot + continue; + case EPurchaseResultDetail.AccountLocked: + case EPurchaseResultDetail.AlreadyPurchased: + case EPurchaseResultDetail.CancelledByUser: + case EPurchaseResultDetail.DoesNotOwnRequiredApp: + case EPurchaseResultDetail.RestrictedCountry: + if (!forward || (keepMissingGames && (result.PurchaseResultDetail != EPurchaseResultDetail.AlreadyPurchased))) { + // Next key + key = keysEnumerator.MoveNext() ? keysEnumerator.Current : null; + + // Next bot (if needed) + break; + } + + if (distribute) { + // Next bot, without changing key + break; + } + + Dictionary items = result.Items ?? new Dictionary(); + + bool alreadyHandled = false; + + foreach (Bot innerBot in Bot.Bots.Where(bot => (bot.Value != currentBot) && (!redeemFlags.HasFlag(ERedeemFlags.SkipInitial) || (bot.Value != Bot)) && !triedBots.Contains(bot.Value) && !rateLimitedBots.Contains(bot.Value) && bot.Value.IsConnectedAndLoggedOn && bot.Value.Commands.Bot.HasAccess(steamID, BotConfig.EAccess.Operator) && ((items.Count == 0) || items.Keys.Any(packageID => !bot.Value.OwnedPackageIDs.ContainsKey(packageID)))).OrderBy(static bot => bot.Key, Bot.BotsComparer).Select(static bot => bot.Value)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + PurchaseResponseCallback? otherResult = await innerBot.Actions.RedeemKey(key!).ConfigureAwait(false); + + if (otherResult == null) { + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotRedeem, key, $"{EResult.Timeout}/{EPurchaseResultDetail.Timeout}"), innerBot.BotName)); + + continue; + } + + triedBots.Add(innerBot); + + switch (otherResult.PurchaseResultDetail) { + case EPurchaseResultDetail.BadActivationCode: + case EPurchaseResultDetail.DuplicateActivationCode: + case EPurchaseResultDetail.NoDetail: // OK + // This key is already handled, as we either redeemed it or we're sure it's dupe/invalid + alreadyHandled = true; + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + unusedKeys.Remove(key!); + + break; + case EPurchaseResultDetail.RateLimited: + rateLimitedBots.Add(innerBot); + + break; + } + + response.AppendLine(FormatBotResponse(otherResult.Items?.Count > 0 ? string.Format(CultureInfo.CurrentCulture, Strings.BotRedeemWithItems, key, $"{otherResult.Result}/{otherResult.PurchaseResultDetail}", string.Join(", ", otherResult.Items)) : string.Format(CultureInfo.CurrentCulture, Strings.BotRedeem, key, $"{otherResult.Result}/{otherResult.PurchaseResultDetail}"), innerBot.BotName)); + + if (alreadyHandled) { + break; + } + + if (otherResult.Items == null) { + continue; + } + + foreach ((uint packageID, string packageName) in otherResult.Items.Where(item => !items.ContainsKey(item.Key))) { + items[packageID] = packageName; + } + } + + // Next key + key = keysEnumerator.MoveNext() ? keysEnumerator.Current : null; + + // Next bot (if needed) + break; + case EPurchaseResultDetail.RateLimited: + rateLimitedBots.Add(currentBot); + + goto case EPurchaseResultDetail.CancelledByUser; + default: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(result.PurchaseResultDetail), result.PurchaseResultDetail)); + + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + unusedKeys.Remove(key!); + + // Next key + key = keysEnumerator.MoveNext() ? keysEnumerator.Current : null; + + // Next bot (if needed) + break; + } } } - } - if (key == startingKey) { - // We ran out of bots to try for this key, so change it to avoid infinite loop, next key - key = keysEnumerator.MoveNext() ? keysEnumerator.Current : null; + // We want to change bot in two cases: + // a) When we have distribution enabled, obviously + // b) When we're skipping initial bot AND we have forwarding enabled, otherwise we won't get down to other accounts + if (distribute || (forward && redeemFlags.HasFlag(ERedeemFlags.SkipInitial))) { + currentBot = botsEnumerator.MoveNext() ? botsEnumerator.Current : null; + } } } - } - if (unusedKeys.Count > 0) { - response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.UnusedKeys, string.Join(", ", unusedKeys)))); - } - - return response.Length > 0 ? response.ToString() : null; - } - - private static async Task ResponseRedeem(ulong steamID, string botNames, string keysText, ERedeemFlags redeemFlags = ERedeemFlags.None) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(keysText)) { - throw new ArgumentNullException(nameof(keysText)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseRedeem(steamID, keysText, redeemFlags))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private async Task ResponseReset(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - await Bot.CheckOccupationStatus().ConfigureAwait(false); - - return FormatBotResponse(Strings.Done); - } - - private static async Task ResponseReset(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseReset(steamID))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private static string? ResponseRestart(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!ASF.IsOwner(steamID)) { - return null; - } - - (bool success, string message) = Actions.Restart(); - - return FormatStaticResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); - } - - private string? ResponseResume(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.FamilySharing)) { - return null; - } - - (bool success, string message) = Bot.Actions.Resume(); - - return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); - } - - private static async Task ResponseResume(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseResume(steamID)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private string? ResponseStart(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - (bool success, string message) = Bot.Actions.Start(); - - return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); - } - - private static async Task ResponseStart(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseStart(steamID)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private string? ResponseStats(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!ASF.IsOwner(steamID)) { - return null; - } - - ushort memoryInMegabytes = (ushort) (GC.GetTotalMemory(false) / 1024 / 1024); - TimeSpan uptime = DateTime.UtcNow.Subtract(OS.ProcessStartTime); - - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotStats, memoryInMegabytes, uptime.ToHumanReadable())); - } - - private (string? Response, Bot Bot) ResponseStatus(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.FamilySharing)) { - return (null, Bot); - } - - if (!Bot.IsConnectedAndLoggedOn) { - return (FormatBotResponse(Bot.KeepRunning ? Strings.BotStatusConnecting : Strings.BotStatusNotRunning), Bot); - } - - if (Bot.PlayingBlocked) { - return (FormatBotResponse(Strings.BotStatusPlayingNotAvailable), Bot); - } - - if (Bot.CardsFarmer.Paused) { - return (FormatBotResponse(Strings.BotStatusPaused), Bot); - } - - if (Bot.IsAccountLimited) { - return (FormatBotResponse(Strings.BotStatusLimited), Bot); - } - - if (Bot.IsAccountLocked) { - return (FormatBotResponse(Strings.BotStatusLocked), Bot); - } - - if (!Bot.CardsFarmer.NowFarming || (Bot.CardsFarmer.CurrentGamesFarmingReadOnly.Count == 0)) { - return (FormatBotResponse(Strings.BotStatusNotIdling), Bot); - } - - if (Bot.CardsFarmer.CurrentGamesFarmingReadOnly.Count > 1) { - return (FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotStatusIdlingList, string.Join(", ", Bot.CardsFarmer.CurrentGamesFarmingReadOnly.Select(static game => $"{game.AppID} ({game.GameName})")), Bot.CardsFarmer.GamesToFarmReadOnly.Count, Bot.CardsFarmer.GamesToFarmReadOnly.Sum(static game => game.CardsRemaining), Bot.CardsFarmer.TimeRemaining.ToHumanReadable())), Bot); - } - - Game soloGame = Bot.CardsFarmer.CurrentGamesFarmingReadOnly.First(); - - return (FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotStatusIdling, soloGame.AppID, soloGame.GameName, soloGame.CardsRemaining, Bot.CardsFarmer.GamesToFarmReadOnly.Count, Bot.CardsFarmer.GamesToFarmReadOnly.Sum(static game => game.CardsRemaining), Bot.CardsFarmer.TimeRemaining.ToHumanReadable())), Bot); - } - - private static async Task ResponseStatus(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList<(string? Response, Bot Bot)> results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseStatus(steamID)))).ConfigureAwait(false); - - List<(string Response, Bot Bot)> validResults = new(results.Where(static result => !string.IsNullOrEmpty(result.Response))!); - - if (validResults.Count == 0) { - return null; - } - - HashSet botsRunning = validResults.Where(static result => result.Bot.KeepRunning).Select(static result => result.Bot).ToHashSet(); - - string extraResponse = string.Format(CultureInfo.CurrentCulture, Strings.BotStatusOverview, botsRunning.Count, validResults.Count, botsRunning.Sum(static bot => bot.CardsFarmer.GamesToFarmReadOnly.Count), botsRunning.Sum(static bot => bot.CardsFarmer.GamesToFarmReadOnly.Sum(static game => game.CardsRemaining))); - - return string.Join(Environment.NewLine, validResults.Select(static result => result.Response).Union(extraResponse.ToEnumerable())); - } - - private string? ResponseStop(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - (bool success, string message) = Bot.Actions.Stop(); - - return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); - } - - private static async Task ResponseStop(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseStop(steamID)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private async Task ResponseTransfer(ulong steamID, string botNameTo) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNameTo)) { - throw new ArgumentNullException(nameof(botNameTo)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - if (Bot.BotConfig.TransferableTypes.Count == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotConfig.TransferableTypes))); - } - - Bot? targetBot = Bot.GetBot(botNameTo); - - if (targetBot == null) { - return ASF.IsOwner(steamID) ? FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNameTo)) : null; - } - - if (!targetBot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.TargetBotNotConnected); - } - - if (targetBot.SteamID == Bot.SteamID) { - return FormatBotResponse(Strings.BotSendingTradeToYourself); - } - - (bool success, string message) = await Bot.Actions.SendInventory(targetSteamID: targetBot.SteamID, filterFunction: item => Bot.BotConfig.TransferableTypes.Contains(item.Type)).ConfigureAwait(false); - - return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); - } - - private static async Task ResponseTransfer(ulong steamID, string botNames, string botNameTo) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - if (string.IsNullOrEmpty(botNameTo)) { - throw new ArgumentNullException(nameof(botNameTo)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseTransfer(steamID, botNameTo))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; - } - - private async Task ResponseTransferByRealAppIDs(ulong steamID, IReadOnlyCollection realAppIDs, Bot targetBot, bool exclude = false) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if ((realAppIDs == null) || (realAppIDs.Count == 0)) { - throw new ArgumentNullException(nameof(realAppIDs)); - } - - if (targetBot == null) { - throw new ArgumentNullException(nameof(targetBot)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - if (Bot.BotConfig.TransferableTypes.Count == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotConfig.TransferableTypes))); - } - - if (!targetBot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.TargetBotNotConnected); - } - - if (targetBot.SteamID == Bot.SteamID) { - return FormatBotResponse(Strings.BotSendingTradeToYourself); - } - - (bool success, string message) = await Bot.Actions.SendInventory(targetSteamID: targetBot.SteamID, filterFunction: item => Bot.BotConfig.TransferableTypes.Contains(item.Type) && (exclude ^ realAppIDs.Contains(item.RealAppID))).ConfigureAwait(false); - - return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); - } - - private async Task ResponseTransferByRealAppIDs(ulong steamID, string realAppIDsText, string botNameTo, bool exclude = false) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (string.IsNullOrEmpty(realAppIDsText)) { - throw new ArgumentNullException(nameof(realAppIDsText)); - } - - if (string.IsNullOrEmpty(botNameTo)) { - throw new ArgumentNullException(nameof(botNameTo)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - Bot? targetBot = Bot.GetBot(botNameTo); - - if (targetBot == null) { - return ASF.IsOwner(steamID) ? FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNameTo)) : null; - } - - string[] appIDTexts = realAppIDsText.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (appIDTexts.Length == 0) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(appIDTexts))); - } - - HashSet realAppIDs = new(); - - foreach (string appIDText in appIDTexts) { - if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { - return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(appID))); + if (key == startingKey) { + // We ran out of bots to try for this key, so change it to avoid infinite loop, next key + key = keysEnumerator.MoveNext() ? keysEnumerator.Current : null; } - - realAppIDs.Add(appID); } - - return await ResponseTransferByRealAppIDs(steamID, realAppIDs, targetBot, exclude).ConfigureAwait(false); } - private static async Task ResponseTransferByRealAppIDs(ulong steamID, string botNames, string realAppIDsText, string botNameTo, bool exclude = false) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); + if (unusedKeys.Count > 0) { + response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.UnusedKeys, string.Join(", ", unusedKeys)))); + } + + return response.Length > 0 ? response.ToString() : null; + } + + private static async Task ResponseRedeem(ulong steamID, string botNames, string keysText, ERedeemFlags redeemFlags = ERedeemFlags.None) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (string.IsNullOrEmpty(keysText)) { + throw new ArgumentNullException(nameof(keysText)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseRedeem(steamID, keysText, redeemFlags))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task ResponseReset(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + await Bot.CheckOccupationStatus().ConfigureAwait(false); + + return FormatBotResponse(Strings.Done); + } + + private static async Task ResponseReset(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseReset(steamID))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private static string? ResponseRestart(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!ASF.IsOwner(steamID)) { + return null; + } + + (bool success, string message) = Actions.Restart(); + + return FormatStaticResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + } + + private string? ResponseResume(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.FamilySharing)) { + return null; + } + + (bool success, string message) = Bot.Actions.Resume(); + + return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + } + + private static async Task ResponseResume(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseResume(steamID)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private string? ResponseStart(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + (bool success, string message) = Bot.Actions.Start(); + + return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + } + + private static async Task ResponseStart(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseStart(steamID)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private string? ResponseStats(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!ASF.IsOwner(steamID)) { + return null; + } + + ushort memoryInMegabytes = (ushort) (GC.GetTotalMemory(false) / 1024 / 1024); + TimeSpan uptime = DateTime.UtcNow.Subtract(OS.ProcessStartTime); + + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotStats, memoryInMegabytes, uptime.ToHumanReadable())); + } + + private (string? Response, Bot Bot) ResponseStatus(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.FamilySharing)) { + return (null, Bot); + } + + if (!Bot.IsConnectedAndLoggedOn) { + return (FormatBotResponse(Bot.KeepRunning ? Strings.BotStatusConnecting : Strings.BotStatusNotRunning), Bot); + } + + if (Bot.PlayingBlocked) { + return (FormatBotResponse(Strings.BotStatusPlayingNotAvailable), Bot); + } + + if (Bot.CardsFarmer.Paused) { + return (FormatBotResponse(Strings.BotStatusPaused), Bot); + } + + if (Bot.IsAccountLimited) { + return (FormatBotResponse(Strings.BotStatusLimited), Bot); + } + + if (Bot.IsAccountLocked) { + return (FormatBotResponse(Strings.BotStatusLocked), Bot); + } + + if (!Bot.CardsFarmer.NowFarming || (Bot.CardsFarmer.CurrentGamesFarmingReadOnly.Count == 0)) { + return (FormatBotResponse(Strings.BotStatusNotIdling), Bot); + } + + if (Bot.CardsFarmer.CurrentGamesFarmingReadOnly.Count > 1) { + return (FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotStatusIdlingList, string.Join(", ", Bot.CardsFarmer.CurrentGamesFarmingReadOnly.Select(static game => $"{game.AppID} ({game.GameName})")), Bot.CardsFarmer.GamesToFarmReadOnly.Count, Bot.CardsFarmer.GamesToFarmReadOnly.Sum(static game => game.CardsRemaining), Bot.CardsFarmer.TimeRemaining.ToHumanReadable())), Bot); + } + + Game soloGame = Bot.CardsFarmer.CurrentGamesFarmingReadOnly.First(); + + return (FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotStatusIdling, soloGame.AppID, soloGame.GameName, soloGame.CardsRemaining, Bot.CardsFarmer.GamesToFarmReadOnly.Count, Bot.CardsFarmer.GamesToFarmReadOnly.Sum(static game => game.CardsRemaining), Bot.CardsFarmer.TimeRemaining.ToHumanReadable())), Bot); + } + + private static async Task ResponseStatus(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList<(string? Response, Bot Bot)> results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseStatus(steamID)))).ConfigureAwait(false); + + List<(string Response, Bot Bot)> validResults = new(results.Where(static result => !string.IsNullOrEmpty(result.Response))!); + + if (validResults.Count == 0) { + return null; + } + + HashSet botsRunning = validResults.Where(static result => result.Bot.KeepRunning).Select(static result => result.Bot).ToHashSet(); + + string extraResponse = string.Format(CultureInfo.CurrentCulture, Strings.BotStatusOverview, botsRunning.Count, validResults.Count, botsRunning.Sum(static bot => bot.CardsFarmer.GamesToFarmReadOnly.Count), botsRunning.Sum(static bot => bot.CardsFarmer.GamesToFarmReadOnly.Sum(static game => game.CardsRemaining))); + + return string.Join(Environment.NewLine, validResults.Select(static result => result.Response).Union(extraResponse.ToEnumerable())); + } + + private string? ResponseStop(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + (bool success, string message) = Bot.Actions.Stop(); + + return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + } + + private static async Task ResponseStop(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseStop(steamID)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task ResponseTransfer(ulong steamID, string botNameTo) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNameTo)) { + throw new ArgumentNullException(nameof(botNameTo)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + if (Bot.BotConfig.TransferableTypes.Count == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotConfig.TransferableTypes))); + } + + Bot? targetBot = Bot.GetBot(botNameTo); + + if (targetBot == null) { + return ASF.IsOwner(steamID) ? FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNameTo)) : null; + } + + if (!targetBot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.TargetBotNotConnected); + } + + if (targetBot.SteamID == Bot.SteamID) { + return FormatBotResponse(Strings.BotSendingTradeToYourself); + } + + (bool success, string message) = await Bot.Actions.SendInventory(targetSteamID: targetBot.SteamID, filterFunction: item => Bot.BotConfig.TransferableTypes.Contains(item.Type)).ConfigureAwait(false); + + return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + } + + private static async Task ResponseTransfer(ulong steamID, string botNames, string botNameTo) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (string.IsNullOrEmpty(botNameTo)) { + throw new ArgumentNullException(nameof(botNameTo)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseTransfer(steamID, botNameTo))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + private async Task ResponseTransferByRealAppIDs(ulong steamID, IReadOnlyCollection realAppIDs, Bot targetBot, bool exclude = false) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if ((realAppIDs == null) || (realAppIDs.Count == 0)) { + throw new ArgumentNullException(nameof(realAppIDs)); + } + + if (targetBot == null) { + throw new ArgumentNullException(nameof(targetBot)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + if (Bot.BotConfig.TransferableTypes.Count == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(Bot.BotConfig.TransferableTypes))); + } + + if (!targetBot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.TargetBotNotConnected); + } + + if (targetBot.SteamID == Bot.SteamID) { + return FormatBotResponse(Strings.BotSendingTradeToYourself); + } + + (bool success, string message) = await Bot.Actions.SendInventory(targetSteamID: targetBot.SteamID, filterFunction: item => Bot.BotConfig.TransferableTypes.Contains(item.Type) && (exclude ^ realAppIDs.Contains(item.RealAppID))).ConfigureAwait(false); + + return FormatBotResponse(success ? message : string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, message)); + } + + private async Task ResponseTransferByRealAppIDs(ulong steamID, string realAppIDsText, string botNameTo, bool exclude = false) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(realAppIDsText)) { + throw new ArgumentNullException(nameof(realAppIDsText)); + } + + if (string.IsNullOrEmpty(botNameTo)) { + throw new ArgumentNullException(nameof(botNameTo)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + Bot? targetBot = Bot.GetBot(botNameTo); + + if (targetBot == null) { + return ASF.IsOwner(steamID) ? FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNameTo)) : null; + } + + string[] appIDTexts = realAppIDsText.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (appIDTexts.Length == 0) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(appIDTexts))); + } + + HashSet realAppIDs = new(); + + foreach (string appIDText in appIDTexts) { + if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { + return FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(appID))); } - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); + realAppIDs.Add(appID); + } + + return await ResponseTransferByRealAppIDs(steamID, realAppIDs, targetBot, exclude).ConfigureAwait(false); + } + + private static async Task ResponseTransferByRealAppIDs(ulong steamID, string botNames, string realAppIDsText, string botNameTo, bool exclude = false) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (string.IsNullOrEmpty(realAppIDsText)) { + throw new ArgumentNullException(nameof(realAppIDsText)); + } + + if (string.IsNullOrEmpty(botNameTo)) { + throw new ArgumentNullException(nameof(botNameTo)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + string[] appIDTexts = realAppIDsText.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (appIDTexts.Length == 0) { + return FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(appIDTexts))); + } + + HashSet realAppIDs = new(); + + foreach (string appIDText in appIDTexts) { + if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { + return FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(appID))); } - if (string.IsNullOrEmpty(realAppIDsText)) { - throw new ArgumentNullException(nameof(realAppIDsText)); - } + realAppIDs.Add(appID); + } - if (string.IsNullOrEmpty(botNameTo)) { - throw new ArgumentNullException(nameof(botNameTo)); - } + Bot? targetBot = Bot.GetBot(botNameTo); - HashSet? bots = Bot.GetBots(botNames); + if (targetBot == null) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNameTo)) : null; + } - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseTransferByRealAppIDs(steamID, realAppIDs, targetBot, exclude))).ConfigureAwait(false); - string[] appIDTexts = realAppIDsText.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - if (appIDTexts.Length == 0) { - return FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(appIDTexts))); - } + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } - HashSet realAppIDs = new(); + private string? ResponseUnknown(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } - foreach (string appIDText in appIDTexts) { - if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { - return FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(appID))); + return Bot.HasAccess(steamID, BotConfig.EAccess.Operator) ? FormatBotResponse(Strings.UnknownCommand) : null; + } + + private async Task ResponseUnpackBoosters(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; + } + + if (!Bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(Strings.BotNotConnected); + } + + // It'd make sense here to actually check return code of ArchiWebHandler.UnpackBooster(), but it lies most of the time | https://github.com/JustArchi/ArchiSteamFarm/issues/704 + bool completeSuccess = true; + + // It'd also make sense to run all of this in parallel, but it seems that Steam has a lot of problems with inventory-related parallel requests | https://steamcommunity.com/groups/archiasf/discussions/1/3559414588264550284/ + try { + await foreach (Asset item in Bot.ArchiWebHandler.GetInventoryAsync().Where(static item => item.Type == Asset.EType.BoosterPack).ConfigureAwait(false)) { + if (!await Bot.ArchiWebHandler.UnpackBooster(item.RealAppID, item.AssetID).ConfigureAwait(false)) { + completeSuccess = false; } - - realAppIDs.Add(appID); } + } catch (HttpRequestException e) { + Bot.ArchiLogger.LogGenericWarningException(e); - Bot? targetBot = Bot.GetBot(botNameTo); + completeSuccess = false; + } catch (Exception e) { + Bot.ArchiLogger.LogGenericException(e); - if (targetBot == null) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNameTo)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseTransferByRealAppIDs(steamID, realAppIDs, targetBot, exclude))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + completeSuccess = false; } - private string? ResponseUnknown(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + return FormatBotResponse(completeSuccess ? Strings.Success : Strings.Done); + } - return Bot.HasAccess(steamID, BotConfig.EAccess.Operator) ? FormatBotResponse(Strings.UnknownCommand) : null; + private static async Task ResponseUnpackBoosters(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - private async Task ResponseUnpackBoosters(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } - - if (!Bot.IsConnectedAndLoggedOn) { - return FormatBotResponse(Strings.BotNotConnected); - } - - // It'd make sense here to actually check return code of ArchiWebHandler.UnpackBooster(), but it lies most of the time | https://github.com/JustArchi/ArchiSteamFarm/issues/704 - bool completeSuccess = true; - - // It'd also make sense to run all of this in parallel, but it seems that Steam has a lot of problems with inventory-related parallel requests | https://steamcommunity.com/groups/archiasf/discussions/1/3559414588264550284/ - try { - await foreach (Asset item in Bot.ArchiWebHandler.GetInventoryAsync().Where(static item => item.Type == Asset.EType.BoosterPack).ConfigureAwait(false)) { - if (!await Bot.ArchiWebHandler.UnpackBooster(item.RealAppID, item.AssetID).ConfigureAwait(false)) { - completeSuccess = false; - } - } - } catch (HttpRequestException e) { - Bot.ArchiLogger.LogGenericWarningException(e); - - completeSuccess = false; - } catch (Exception e) { - Bot.ArchiLogger.LogGenericException(e); - - completeSuccess = false; - } - - return FormatBotResponse(completeSuccess ? Strings.Success : Strings.Done); + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); } - private static async Task ResponseUnpackBoosters(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + HashSet? bots = Bot.GetBots(botNames); - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseUnpackBoosters(steamID))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; } - private static async Task ResponseUpdate(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + IList results = await Utilities.InParallel(bots.Select(bot => bot.Commands.ResponseUnpackBoosters(steamID))).ConfigureAwait(false); - if (!ASF.IsOwner(steamID)) { - return null; - } + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - (bool success, string? message, Version? version) = await Actions.Update().ConfigureAwait(false); + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } - return FormatStaticResponse((success ? Strings.Success : Strings.WarningFailed) + (!string.IsNullOrEmpty(message) ? $" {message}" : version != null ? $" {version}" : "")); + private static async Task ResponseUpdate(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - private string? ResponseVersion(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } - - return Bot.HasAccess(steamID, BotConfig.EAccess.Operator) ? FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotVersion, SharedInfo.ASF, SharedInfo.Version)) : null; + if (!ASF.IsOwner(steamID)) { + return null; } - private string? ResponseWalletBalance(ulong steamID) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + (bool success, string? message, Version? version) = await Actions.Update().ConfigureAwait(false); - if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { - return null; - } + return FormatStaticResponse((success ? Strings.Success : Strings.WarningFailed) + (!string.IsNullOrEmpty(message) ? $" {message}" : version != null ? $" {version}" : "")); + } - return !Bot.IsConnectedAndLoggedOn ? FormatBotResponse(Strings.BotNotConnected) : FormatBotResponse(Bot.WalletCurrency != ECurrencyCode.Invalid ? string.Format(CultureInfo.CurrentCulture, Strings.BotWalletBalance, Bot.WalletBalance / 100.0, Bot.WalletCurrency.ToString()) : Strings.BotHasNoWallet); + private string? ResponseVersion(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - private static async Task ResponseWalletBalance(ulong steamID, string botNames) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - throw new ArgumentOutOfRangeException(nameof(steamID)); - } + return Bot.HasAccess(steamID, BotConfig.EAccess.Operator) ? FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotVersion, SharedInfo.ASF, SharedInfo.Version)) : null; + } - if (string.IsNullOrEmpty(botNames)) { - throw new ArgumentNullException(nameof(botNames)); - } - - HashSet? bots = Bot.GetBots(botNames); - - if ((bots == null) || (bots.Count == 0)) { - return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; - } - - IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseWalletBalance(steamID)))).ConfigureAwait(false); - - List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); - - return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + private string? ResponseWalletBalance(ulong steamID) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); } - [Flags] - private enum ERedeemFlags : ushort { - None = 0, - Validate = 1, - ForceForwarding = 2, - SkipForwarding = 4, - ForceDistributing = 8, - SkipDistributing = 16, - SkipInitial = 32, - ForceKeepMissingGames = 64, - SkipKeepMissingGames = 128, - ForceAssumeWalletKeyOnBadActivationCode = 256, - SkipAssumeWalletKeyOnBadActivationCode = 512 + if (!Bot.HasAccess(steamID, BotConfig.EAccess.Master)) { + return null; } + + return !Bot.IsConnectedAndLoggedOn ? FormatBotResponse(Strings.BotNotConnected) : FormatBotResponse(Bot.WalletCurrency != ECurrencyCode.Invalid ? string.Format(CultureInfo.CurrentCulture, Strings.BotWalletBalance, Bot.WalletBalance / 100.0, Bot.WalletCurrency.ToString()) : Strings.BotHasNoWallet); + } + + private static async Task ResponseWalletBalance(ulong steamID, string botNames) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return ASF.IsOwner(steamID) ? FormatStaticResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.Commands.ResponseWalletBalance(steamID)))).ConfigureAwait(false); + + List responses = new(results.Where(static result => !string.IsNullOrEmpty(result))!); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } + + [Flags] + private enum ERedeemFlags : ushort { + None = 0, + Validate = 1, + ForceForwarding = 2, + SkipForwarding = 4, + ForceDistributing = 8, + SkipDistributing = 16, + SkipInitial = 32, + ForceKeepMissingGames = 64, + SkipKeepMissingGames = 128, + ForceAssumeWalletKeyOnBadActivationCode = 256, + SkipAssumeWalletKeyOnBadActivationCode = 512 } } diff --git a/ArchiSteamFarm/Steam/Security/Confirmation.cs b/ArchiSteamFarm/Steam/Security/Confirmation.cs index e2bb58550..07598750d 100644 --- a/ArchiSteamFarm/Steam/Security/Confirmation.cs +++ b/ArchiSteamFarm/Steam/Security/Confirmation.cs @@ -24,38 +24,38 @@ using System.ComponentModel; using JetBrains.Annotations; using Newtonsoft.Json; -namespace ArchiSteamFarm.Steam.Security { - public sealed class Confirmation { - [JsonProperty(Required = Required.Always)] - public ulong Creator { get; } +namespace ArchiSteamFarm.Steam.Security; - [JsonProperty(Required = Required.Always)] - public ulong ID { get; } +public sealed class Confirmation { + [JsonProperty(Required = Required.Always)] + public ulong Creator { get; } - [JsonProperty(Required = Required.Always)] - public ulong Key { get; } + [JsonProperty(Required = Required.Always)] + public ulong ID { get; } - [JsonProperty(Required = Required.Always)] - public EType Type { get; } + [JsonProperty(Required = Required.Always)] + public ulong Key { get; } - internal Confirmation(ulong id, ulong key, ulong creator, EType type) { - ID = id > 0 ? id : throw new ArgumentOutOfRangeException(nameof(id)); - Key = key > 0 ? key : throw new ArgumentOutOfRangeException(nameof(key)); - Creator = creator > 0 ? creator : throw new ArgumentOutOfRangeException(nameof(creator)); - Type = Enum.IsDefined(typeof(EType), type) ? type : throw new InvalidEnumArgumentException(nameof(type), (int) type, typeof(EType)); - } + [JsonProperty(Required = Required.Always)] + public EType Type { get; } - // REF: Internal documentation - [PublicAPI] - public enum EType : byte { - Unknown, - Generic, - Trade, - Market, + internal Confirmation(ulong id, ulong key, ulong creator, EType type) { + ID = id > 0 ? id : throw new ArgumentOutOfRangeException(nameof(id)); + Key = key > 0 ? key : throw new ArgumentOutOfRangeException(nameof(key)); + Creator = creator > 0 ? creator : throw new ArgumentOutOfRangeException(nameof(creator)); + Type = Enum.IsDefined(typeof(EType), type) ? type : throw new InvalidEnumArgumentException(nameof(type), (int) type, typeof(EType)); + } - // We're missing information about definition of number 4 type - PhoneNumberChange = 5, - AccountRecovery = 6 - } + // REF: Internal documentation + [PublicAPI] + public enum EType : byte { + Unknown, + Generic, + Trade, + Market, + + // We're missing information about definition of number 4 type + PhoneNumberChange = 5, + AccountRecovery = 6 } } diff --git a/ArchiSteamFarm/Steam/Security/MobileAuthenticator.cs b/ArchiSteamFarm/Steam/Security/MobileAuthenticator.cs index 56441532d..eafd85601 100644 --- a/ArchiSteamFarm/Steam/Security/MobileAuthenticator.cs +++ b/ArchiSteamFarm/Steam/Security/MobileAuthenticator.cs @@ -35,442 +35,442 @@ using ArchiSteamFarm.Localization; using ArchiSteamFarm.Storage; using Newtonsoft.Json; -namespace ArchiSteamFarm.Steam.Security { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public sealed class MobileAuthenticator : IDisposable { - internal const byte BackupCodeDigits = 7; - internal const byte CodeDigits = 5; +namespace ArchiSteamFarm.Steam.Security; - private const byte CodeInterval = 30; +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class MobileAuthenticator : IDisposable { + internal const byte BackupCodeDigits = 7; + internal const byte CodeDigits = 5; - // For how many hours we can assume that SteamTimeDifference is correct - private const byte SteamTimeTTL = 24; + private const byte CodeInterval = 30; - internal static readonly ImmutableSortedSet CodeCharacters = ImmutableSortedSet.Create('2', '3', '4', '5', '6', '7', '8', '9', 'B', 'C', 'D', 'F', 'G', 'H', 'J', 'K', 'M', 'N', 'P', 'Q', 'R', 'T', 'V', 'W', 'X', 'Y'); + // For how many hours we can assume that SteamTimeDifference is correct + private const byte SteamTimeTTL = 24; - private static readonly SemaphoreSlim TimeSemaphore = new(1, 1); + internal static readonly ImmutableSortedSet CodeCharacters = ImmutableSortedSet.Create('2', '3', '4', '5', '6', '7', '8', '9', 'B', 'C', 'D', 'F', 'G', 'H', 'J', 'K', 'M', 'N', 'P', 'Q', 'R', 'T', 'V', 'W', 'X', 'Y'); - private static DateTime LastSteamTimeCheck; - private static int? SteamTimeDifference; + private static readonly SemaphoreSlim TimeSemaphore = new(1, 1); - private readonly ArchiCacheable CachedDeviceID; + private static DateTime LastSteamTimeCheck; + private static int? SteamTimeDifference; - [JsonProperty(PropertyName = "identity_secret", Required = Required.Always)] - private readonly string IdentitySecret = ""; + private readonly ArchiCacheable CachedDeviceID; - [JsonProperty(PropertyName = "shared_secret", Required = Required.Always)] - private readonly string SharedSecret = ""; + [JsonProperty(PropertyName = "identity_secret", Required = Required.Always)] + private readonly string IdentitySecret = ""; - private Bot? Bot; + [JsonProperty(PropertyName = "shared_secret", Required = Required.Always)] + private readonly string SharedSecret = ""; - [JsonConstructor] - private MobileAuthenticator() => CachedDeviceID = new ArchiCacheable(ResolveDeviceID); + private Bot? Bot; - public void Dispose() => CachedDeviceID.Dispose(); + [JsonConstructor] + private MobileAuthenticator() => CachedDeviceID = new ArchiCacheable(ResolveDeviceID); - internal async Task GenerateToken() { - if (Bot == null) { - throw new InvalidOperationException(nameof(Bot)); - } + public void Dispose() => CachedDeviceID.Dispose(); - uint time = await GetSteamTime().ConfigureAwait(false); - - if (time == 0) { - throw new InvalidOperationException(nameof(time)); - } - - return GenerateTokenForTime(time); + internal async Task GenerateToken() { + if (Bot == null) { + throw new InvalidOperationException(nameof(Bot)); } - internal async Task?> GetConfirmations() { - if (Bot == null) { - throw new InvalidOperationException(nameof(Bot)); - } + uint time = await GetSteamTime().ConfigureAwait(false); - (bool success, string? deviceID) = await CachedDeviceID.GetValue().ConfigureAwait(false); - - if (!success || string.IsNullOrEmpty(deviceID)) { - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(deviceID))); - - return null; - } - - uint time = await GetSteamTime().ConfigureAwait(false); - - if (time == 0) { - throw new InvalidOperationException(nameof(time)); - } - - string? confirmationHash = GenerateConfirmationHash(time, "conf"); - - if (string.IsNullOrEmpty(confirmationHash)) { - Bot.ArchiLogger.LogNullError(nameof(confirmationHash)); - - return null; - } - - await LimitConfirmationsRequestsAsync().ConfigureAwait(false); - - // ReSharper disable RedundantSuppressNullableWarningExpression - required for .NET Framework - using IDocument? htmlDocument = await Bot.ArchiWebHandler.GetConfirmationsPage(deviceID!, confirmationHash!, time).ConfigureAwait(false); - - // ReSharper restore RedundantSuppressNullableWarningExpression - required for .NET Framework - - if (htmlDocument == null) { - return null; - } - - IEnumerable confirmationNodes = htmlDocument.SelectNodes("//div[@class='mobileconf_list_entry']"); - - HashSet result = new(); - - foreach (IElement confirmationNode in confirmationNodes) { - string? idText = confirmationNode.GetAttribute("data-confid"); - - if (string.IsNullOrEmpty(idText)) { - Bot.ArchiLogger.LogNullError(nameof(idText)); - - return null; - } - - if (!ulong.TryParse(idText, out ulong id) || (id == 0)) { - Bot.ArchiLogger.LogNullError(nameof(id)); - - return null; - } - - string? keyText = confirmationNode.GetAttribute("data-key"); - - if (string.IsNullOrEmpty(keyText)) { - Bot.ArchiLogger.LogNullError(nameof(keyText)); - - return null; - } - - if (!ulong.TryParse(keyText, out ulong key) || (key == 0)) { - Bot.ArchiLogger.LogNullError(nameof(key)); - - return null; - } - - string? creatorText = confirmationNode.GetAttribute("data-creator"); - - if (string.IsNullOrEmpty(creatorText)) { - Bot.ArchiLogger.LogNullError(nameof(creatorText)); - - return null; - } - - if (!ulong.TryParse(creatorText, out ulong creator) || (creator == 0)) { - Bot.ArchiLogger.LogNullError(nameof(creator)); - - return null; - } - - string? typeText = confirmationNode.GetAttribute("data-type"); - - if (string.IsNullOrEmpty(typeText)) { - Bot.ArchiLogger.LogNullError(nameof(typeText)); - - return null; - } - - if (!Enum.TryParse(typeText, out Confirmation.EType type) || (type == Confirmation.EType.Unknown)) { - Bot.ArchiLogger.LogNullError(nameof(type)); - - return null; - } - - if (!Enum.IsDefined(typeof(Confirmation.EType), type)) { - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(type), type)); - - return null; - } - - result.Add(new Confirmation(id, key, creator, type)); - } - - return result; + if (time == 0) { + throw new InvalidOperationException(nameof(time)); } - internal async Task HandleConfirmations(IReadOnlyCollection confirmations, bool accept) { - if ((confirmations == null) || (confirmations.Count == 0)) { - throw new ArgumentNullException(nameof(confirmations)); + return GenerateTokenForTime(time); + } + + internal async Task?> GetConfirmations() { + if (Bot == null) { + throw new InvalidOperationException(nameof(Bot)); + } + + (bool success, string? deviceID) = await CachedDeviceID.GetValue().ConfigureAwait(false); + + if (!success || string.IsNullOrEmpty(deviceID)) { + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(deviceID))); + + return null; + } + + uint time = await GetSteamTime().ConfigureAwait(false); + + if (time == 0) { + throw new InvalidOperationException(nameof(time)); + } + + string? confirmationHash = GenerateConfirmationHash(time, "conf"); + + if (string.IsNullOrEmpty(confirmationHash)) { + Bot.ArchiLogger.LogNullError(nameof(confirmationHash)); + + return null; + } + + await LimitConfirmationsRequestsAsync().ConfigureAwait(false); + + // ReSharper disable RedundantSuppressNullableWarningExpression - required for .NET Framework + using IDocument? htmlDocument = await Bot.ArchiWebHandler.GetConfirmationsPage(deviceID!, confirmationHash!, time).ConfigureAwait(false); + + // ReSharper restore RedundantSuppressNullableWarningExpression - required for .NET Framework + + if (htmlDocument == null) { + return null; + } + + IEnumerable confirmationNodes = htmlDocument.SelectNodes("//div[@class='mobileconf_list_entry']"); + + HashSet result = new(); + + foreach (IElement confirmationNode in confirmationNodes) { + string? idText = confirmationNode.GetAttribute("data-confid"); + + if (string.IsNullOrEmpty(idText)) { + Bot.ArchiLogger.LogNullError(nameof(idText)); + + return null; } - if (Bot == null) { - throw new InvalidOperationException(nameof(Bot)); + if (!ulong.TryParse(idText, out ulong id) || (id == 0)) { + Bot.ArchiLogger.LogNullError(nameof(id)); + + return null; } - (bool success, string? deviceID) = await CachedDeviceID.GetValue().ConfigureAwait(false); + string? keyText = confirmationNode.GetAttribute("data-key"); - if (!success || string.IsNullOrEmpty(deviceID)) { - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(deviceID))); + if (string.IsNullOrEmpty(keyText)) { + Bot.ArchiLogger.LogNullError(nameof(keyText)); - return false; + return null; } - uint time = await GetSteamTime().ConfigureAwait(false); + if (!ulong.TryParse(keyText, out ulong key) || (key == 0)) { + Bot.ArchiLogger.LogNullError(nameof(key)); - if (time == 0) { - throw new InvalidOperationException(nameof(time)); + return null; } - string? confirmationHash = GenerateConfirmationHash(time, "conf"); + string? creatorText = confirmationNode.GetAttribute("data-creator"); - if (string.IsNullOrEmpty(confirmationHash)) { - Bot.ArchiLogger.LogNullError(nameof(confirmationHash)); + if (string.IsNullOrEmpty(creatorText)) { + Bot.ArchiLogger.LogNullError(nameof(creatorText)); - return false; + return null; } - // ReSharper disable RedundantSuppressNullableWarningExpression - required for .NET Framework - bool? result = await Bot.ArchiWebHandler.HandleConfirmations(deviceID!, confirmationHash!, time, confirmations, accept).ConfigureAwait(false); + if (!ulong.TryParse(creatorText, out ulong creator) || (creator == 0)) { + Bot.ArchiLogger.LogNullError(nameof(creator)); - // ReSharper restore RedundantSuppressNullableWarningExpression - required for .NET Framework - - if (!result.HasValue) { - // Request timed out - return false; + return null; } - if (result.Value) { - // Request succeeded - return true; + string? typeText = confirmationNode.GetAttribute("data-type"); + + if (string.IsNullOrEmpty(typeText)) { + Bot.ArchiLogger.LogNullError(nameof(typeText)); + + return null; } - // Our multi request failed, this is almost always Steam issue that happens randomly - // In this case, we'll accept all pending confirmations one-by-one, synchronously (as Steam can't handle them in parallel) - // We totally ignore actual result returned by those calls, abort only if request timed out - foreach (Confirmation confirmation in confirmations) { - // ReSharper disable RedundantSuppressNullableWarningExpression - required for .NET Framework - bool? confirmationResult = await Bot.ArchiWebHandler.HandleConfirmation(deviceID!, confirmationHash!, time, confirmation.ID, confirmation.Key, accept).ConfigureAwait(false); + if (!Enum.TryParse(typeText, out Confirmation.EType type) || (type == Confirmation.EType.Unknown)) { + Bot.ArchiLogger.LogNullError(nameof(type)); - // ReSharper restore RedundantSuppressNullableWarningExpression - required for .NET Framework - - if (!confirmationResult.HasValue) { - return false; - } + return null; } + if (!Enum.IsDefined(typeof(Confirmation.EType), type)) { + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(type), type)); + + return null; + } + + result.Add(new Confirmation(id, key, creator, type)); + } + + return result; + } + + internal async Task HandleConfirmations(IReadOnlyCollection confirmations, bool accept) { + if ((confirmations == null) || (confirmations.Count == 0)) { + throw new ArgumentNullException(nameof(confirmations)); + } + + if (Bot == null) { + throw new InvalidOperationException(nameof(Bot)); + } + + (bool success, string? deviceID) = await CachedDeviceID.GetValue().ConfigureAwait(false); + + if (!success || string.IsNullOrEmpty(deviceID)) { + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(deviceID))); + + return false; + } + + uint time = await GetSteamTime().ConfigureAwait(false); + + if (time == 0) { + throw new InvalidOperationException(nameof(time)); + } + + string? confirmationHash = GenerateConfirmationHash(time, "conf"); + + if (string.IsNullOrEmpty(confirmationHash)) { + Bot.ArchiLogger.LogNullError(nameof(confirmationHash)); + + return false; + } + + // ReSharper disable RedundantSuppressNullableWarningExpression - required for .NET Framework + bool? result = await Bot.ArchiWebHandler.HandleConfirmations(deviceID!, confirmationHash!, time, confirmations, accept).ConfigureAwait(false); + + // ReSharper restore RedundantSuppressNullableWarningExpression - required for .NET Framework + + if (!result.HasValue) { + // Request timed out + return false; + } + + if (result.Value) { + // Request succeeded return true; } - internal void Init(Bot bot) => Bot = bot ?? throw new ArgumentNullException(nameof(bot)); + // Our multi request failed, this is almost always Steam issue that happens randomly + // In this case, we'll accept all pending confirmations one-by-one, synchronously (as Steam can't handle them in parallel) + // We totally ignore actual result returned by those calls, abort only if request timed out + foreach (Confirmation confirmation in confirmations) { + // ReSharper disable RedundantSuppressNullableWarningExpression - required for .NET Framework + bool? confirmationResult = await Bot.ArchiWebHandler.HandleConfirmation(deviceID!, confirmationHash!, time, confirmation.ID, confirmation.Key, accept).ConfigureAwait(false); - internal static async Task ResetSteamTimeDifference() { + // ReSharper restore RedundantSuppressNullableWarningExpression - required for .NET Framework + + if (!confirmationResult.HasValue) { + return false; + } + } + + return true; + } + + internal void Init(Bot bot) => Bot = bot ?? throw new ArgumentNullException(nameof(bot)); + + internal static async Task ResetSteamTimeDifference() { + if ((SteamTimeDifference == null) && (LastSteamTimeCheck == DateTime.MinValue)) { + return; + } + + if (!await TimeSemaphore.WaitAsync(0).ConfigureAwait(false)) { + // Resolve or reset is already in-progress + return; + } + + try { if ((SteamTimeDifference == null) && (LastSteamTimeCheck == DateTime.MinValue)) { return; } - if (!await TimeSemaphore.WaitAsync(0).ConfigureAwait(false)) { - // Resolve or reset is already in-progress - return; - } + SteamTimeDifference = null; + LastSteamTimeCheck = DateTime.MinValue; + } finally { + TimeSemaphore.Release(); + } + } - try { - if ((SteamTimeDifference == null) && (LastSteamTimeCheck == DateTime.MinValue)) { - return; - } - - SteamTimeDifference = null; - LastSteamTimeCheck = DateTime.MinValue; - } finally { - TimeSemaphore.Release(); - } + private string? GenerateConfirmationHash(uint time, string? tag = null) { + if (time == 0) { + throw new ArgumentOutOfRangeException(nameof(time)); } - private string? GenerateConfirmationHash(uint time, string? tag = null) { - if (time == 0) { - throw new ArgumentOutOfRangeException(nameof(time)); - } + if (Bot == null) { + throw new InvalidOperationException(nameof(Bot)); + } - if (Bot == null) { - throw new InvalidOperationException(nameof(Bot)); - } + if (string.IsNullOrEmpty(IdentitySecret)) { + throw new InvalidOperationException(nameof(IdentitySecret)); + } - if (string.IsNullOrEmpty(IdentitySecret)) { - throw new InvalidOperationException(nameof(IdentitySecret)); - } + byte[] identitySecret; - byte[] identitySecret; + try { + identitySecret = Convert.FromBase64String(IdentitySecret); + } catch (FormatException e) { + Bot.ArchiLogger.LogGenericException(e); + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(IdentitySecret))); - try { - identitySecret = Convert.FromBase64String(IdentitySecret); - } catch (FormatException e) { - Bot.ArchiLogger.LogGenericException(e); - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(IdentitySecret))); + return null; + } - return null; - } + byte bufferSize = 8; - byte bufferSize = 8; + if (!string.IsNullOrEmpty(tag)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + bufferSize += (byte) Math.Min(32, tag!.Length); + } - if (!string.IsNullOrEmpty(tag)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - bufferSize += (byte) Math.Min(32, tag!.Length); - } + byte[] timeArray = BitConverter.GetBytes((ulong) time); - byte[] timeArray = BitConverter.GetBytes((ulong) time); + if (BitConverter.IsLittleEndian) { + Array.Reverse(timeArray); + } - if (BitConverter.IsLittleEndian) { - Array.Reverse(timeArray); - } + byte[] buffer = new byte[bufferSize]; - byte[] buffer = new byte[bufferSize]; + Array.Copy(timeArray, buffer, 8); - Array.Copy(timeArray, buffer, 8); + if (!string.IsNullOrEmpty(tag)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + Array.Copy(Encoding.UTF8.GetBytes(tag!), 0, buffer, 8, bufferSize - 8); + } - if (!string.IsNullOrEmpty(tag)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - Array.Copy(Encoding.UTF8.GetBytes(tag!), 0, buffer, 8, bufferSize - 8); - } - - byte[] hash; + byte[] hash; #pragma warning disable CA5350 // This is actually a fair warning, but there is nothing we can do about Steam using weak cryptographic algorithms - using (HMACSHA1 hmac = new(identitySecret)) { - hash = hmac.ComputeHash(buffer); - } + using (HMACSHA1 hmac = new(identitySecret)) { + hash = hmac.ComputeHash(buffer); + } #pragma warning restore CA5350 // This is actually a fair warning, but there is nothing we can do about Steam using weak cryptographic algorithms - return Convert.ToBase64String(hash); + return Convert.ToBase64String(hash); + } + + private string? GenerateTokenForTime(uint time) { + if (time == 0) { + throw new ArgumentOutOfRangeException(nameof(time)); } - private string? GenerateTokenForTime(uint time) { - if (time == 0) { - throw new ArgumentOutOfRangeException(nameof(time)); - } + if (Bot == null) { + throw new InvalidOperationException(nameof(Bot)); + } - if (Bot == null) { - throw new InvalidOperationException(nameof(Bot)); - } + if (string.IsNullOrEmpty(SharedSecret)) { + throw new InvalidOperationException(nameof(SharedSecret)); + } - if (string.IsNullOrEmpty(SharedSecret)) { - throw new InvalidOperationException(nameof(SharedSecret)); - } + byte[] sharedSecret; - byte[] sharedSecret; + try { + sharedSecret = Convert.FromBase64String(SharedSecret); + } catch (FormatException e) { + Bot.ArchiLogger.LogGenericException(e); + Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(SharedSecret))); - try { - sharedSecret = Convert.FromBase64String(SharedSecret); - } catch (FormatException e) { - Bot.ArchiLogger.LogGenericException(e); - Bot.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(SharedSecret))); + return null; + } - return null; - } + byte[] timeArray = BitConverter.GetBytes((ulong) (time / CodeInterval)); - byte[] timeArray = BitConverter.GetBytes((ulong) (time / CodeInterval)); + if (BitConverter.IsLittleEndian) { + Array.Reverse(timeArray); + } - if (BitConverter.IsLittleEndian) { - Array.Reverse(timeArray); - } - - byte[] hash; + byte[] hash; #pragma warning disable CA5350 // This is actually a fair warning, but there is nothing we can do about Steam using weak cryptographic algorithms - using (HMACSHA1 hmac = new(sharedSecret)) { - hash = hmac.ComputeHash(timeArray); - } + using (HMACSHA1 hmac = new(sharedSecret)) { + hash = hmac.ComputeHash(timeArray); + } #pragma warning restore CA5350 // This is actually a fair warning, but there is nothing we can do about Steam using weak cryptographic algorithms - // The last 4 bits of the mac say where the code starts - int start = hash[^1] & 0x0f; + // The last 4 bits of the mac say where the code starts + int start = hash[^1] & 0x0f; - // Extract those 4 bytes - byte[] bytes = new byte[4]; + // Extract those 4 bytes + byte[] bytes = new byte[4]; - Array.Copy(hash, start, bytes, 0, 4); + Array.Copy(hash, start, bytes, 0, 4); - if (BitConverter.IsLittleEndian) { - Array.Reverse(bytes); - } - - // Build the alphanumeric code - uint fullCode = BitConverter.ToUInt32(bytes, 0) & 0x7fffffff; - - // ReSharper disable once BuiltInTypeReferenceStyleForMemberAccess - required for .NET Framework - return String.Create( - CodeDigits, fullCode, static (buffer, state) => { - for (byte i = 0; i < CodeDigits; i++) { - buffer[i] = CodeCharacters[(byte) (state % CodeCharacters.Count)]; - state /= (byte) CodeCharacters.Count; - } - } - ); + if (BitConverter.IsLittleEndian) { + Array.Reverse(bytes); } - private async Task GetSteamTime() { - if (Bot == null) { - throw new InvalidOperationException(nameof(Bot)); - } + // Build the alphanumeric code + uint fullCode = BitConverter.ToUInt32(bytes, 0) & 0x7fffffff; - int? steamTimeDifference = SteamTimeDifference; + // ReSharper disable once BuiltInTypeReferenceStyleForMemberAccess - required for .NET Framework + return String.Create( + CodeDigits, fullCode, static (buffer, state) => { + for (byte i = 0; i < CodeDigits; i++) { + buffer[i] = CodeCharacters[(byte) (state % CodeCharacters.Count)]; + state /= (byte) CodeCharacters.Count; + } + } + ); + } + + private async Task GetSteamTime() { + if (Bot == null) { + throw new InvalidOperationException(nameof(Bot)); + } + + int? steamTimeDifference = SteamTimeDifference; + + if (steamTimeDifference.HasValue && (DateTime.UtcNow.Subtract(LastSteamTimeCheck).TotalHours < SteamTimeTTL)) { + return (uint) (Utilities.GetUnixTime() + steamTimeDifference.Value); + } + + await TimeSemaphore.WaitAsync().ConfigureAwait(false); + + try { + steamTimeDifference = SteamTimeDifference; if (steamTimeDifference.HasValue && (DateTime.UtcNow.Subtract(LastSteamTimeCheck).TotalHours < SteamTimeTTL)) { return (uint) (Utilities.GetUnixTime() + steamTimeDifference.Value); } - await TimeSemaphore.WaitAsync().ConfigureAwait(false); + uint serverTime = await Bot.ArchiWebHandler.GetServerTime().ConfigureAwait(false); - try { - steamTimeDifference = SteamTimeDifference; - - if (steamTimeDifference.HasValue && (DateTime.UtcNow.Subtract(LastSteamTimeCheck).TotalHours < SteamTimeTTL)) { - return (uint) (Utilities.GetUnixTime() + steamTimeDifference.Value); - } - - uint serverTime = await Bot.ArchiWebHandler.GetServerTime().ConfigureAwait(false); - - if (serverTime == 0) { - return Utilities.GetUnixTime(); - } - - SteamTimeDifference = (int) (serverTime - Utilities.GetUnixTime()); - LastSteamTimeCheck = DateTime.UtcNow; - - return (uint) (Utilities.GetUnixTime() + SteamTimeDifference.Value); - } finally { - TimeSemaphore.Release(); - } - } - - private static async Task LimitConfirmationsRequestsAsync() { - if (ASF.ConfirmationsSemaphore == null) { - throw new InvalidOperationException(nameof(ASF.ConfirmationsSemaphore)); + if (serverTime == 0) { + return Utilities.GetUnixTime(); } - byte confirmationsLimiterDelay = ASF.GlobalConfig?.ConfirmationsLimiterDelay ?? GlobalConfig.DefaultConfirmationsLimiterDelay; + SteamTimeDifference = (int) (serverTime - Utilities.GetUnixTime()); + LastSteamTimeCheck = DateTime.UtcNow; - if (confirmationsLimiterDelay == 0) { - return; - } - - await ASF.ConfirmationsSemaphore.WaitAsync().ConfigureAwait(false); - - Utilities.InBackground( - async () => { - await Task.Delay(confirmationsLimiterDelay * 1000).ConfigureAwait(false); - ASF.ConfirmationsSemaphore.Release(); - } - ); - } - - private async Task<(bool Success, string? Result)> ResolveDeviceID() { - if (Bot == null) { - throw new ArgumentNullException(nameof(Bot)); - } - - string? deviceID = await Bot.ArchiHandler.GetTwoFactorDeviceIdentifier(Bot.SteamID).ConfigureAwait(false); - - if (string.IsNullOrEmpty(deviceID)) { - Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return (false, null); - } - - return (true, deviceID); + return (uint) (Utilities.GetUnixTime() + SteamTimeDifference.Value); + } finally { + TimeSemaphore.Release(); } } + + private static async Task LimitConfirmationsRequestsAsync() { + if (ASF.ConfirmationsSemaphore == null) { + throw new InvalidOperationException(nameof(ASF.ConfirmationsSemaphore)); + } + + byte confirmationsLimiterDelay = ASF.GlobalConfig?.ConfirmationsLimiterDelay ?? GlobalConfig.DefaultConfirmationsLimiterDelay; + + if (confirmationsLimiterDelay == 0) { + return; + } + + await ASF.ConfirmationsSemaphore.WaitAsync().ConfigureAwait(false); + + Utilities.InBackground( + async () => { + await Task.Delay(confirmationsLimiterDelay * 1000).ConfigureAwait(false); + ASF.ConfirmationsSemaphore.Release(); + } + ); + } + + private async Task<(bool Success, string? Result)> ResolveDeviceID() { + if (Bot == null) { + throw new ArgumentNullException(nameof(Bot)); + } + + string? deviceID = await Bot.ArchiHandler.GetTwoFactorDeviceIdentifier(Bot.SteamID).ConfigureAwait(false); + + if (string.IsNullOrEmpty(deviceID)) { + Bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + + return (false, null); + } + + return (true, deviceID); + } } diff --git a/ArchiSteamFarm/Steam/SteamKit2/InMemoryServerListProvider.cs b/ArchiSteamFarm/Steam/SteamKit2/InMemoryServerListProvider.cs index be70f4568..7722974be 100644 --- a/ArchiSteamFarm/Steam/SteamKit2/InMemoryServerListProvider.cs +++ b/ArchiSteamFarm/Steam/SteamKit2/InMemoryServerListProvider.cs @@ -27,29 +27,29 @@ using ArchiSteamFarm.Collections; using Newtonsoft.Json; using SteamKit2.Discovery; -namespace ArchiSteamFarm.Steam.SteamKit2 { - internal sealed class InMemoryServerListProvider : IServerListProvider { - [JsonProperty(Required = Required.DisallowNull)] - private readonly ConcurrentHashSet ServerRecords = new(); +namespace ArchiSteamFarm.Steam.SteamKit2; - public Task> FetchServerListAsync() => Task.FromResult(ServerRecords.Where(static server => !string.IsNullOrEmpty(server.Host) && (server.Port > 0) && (server.ProtocolTypes > 0)).Select(static server => ServerRecord.CreateServer(server.Host, server.Port, server.ProtocolTypes))); +internal sealed class InMemoryServerListProvider : IServerListProvider { + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentHashSet ServerRecords = new(); - public Task UpdateServerListAsync(IEnumerable endpoints) { - if (endpoints == null) { - throw new ArgumentNullException(nameof(endpoints)); - } + public Task> FetchServerListAsync() => Task.FromResult(ServerRecords.Where(static server => !string.IsNullOrEmpty(server.Host) && (server.Port > 0) && (server.ProtocolTypes > 0)).Select(static server => ServerRecord.CreateServer(server.Host, server.Port, server.ProtocolTypes))); - HashSet newServerRecords = endpoints.Select(static endpoint => new ServerRecordEndPoint(endpoint.GetHost(), (ushort) endpoint.GetPort(), endpoint.ProtocolTypes)).ToHashSet(); - - if (ServerRecords.ReplaceIfNeededWith(newServerRecords)) { - ServerListUpdated?.Invoke(this, EventArgs.Empty); - } - - return Task.CompletedTask; + public Task UpdateServerListAsync(IEnumerable endpoints) { + if (endpoints == null) { + throw new ArgumentNullException(nameof(endpoints)); } - public bool ShouldSerializeServerRecords() => ServerRecords.Count > 0; + HashSet newServerRecords = endpoints.Select(static endpoint => new ServerRecordEndPoint(endpoint.GetHost(), (ushort) endpoint.GetPort(), endpoint.ProtocolTypes)).ToHashSet(); - internal event EventHandler? ServerListUpdated; + if (ServerRecords.ReplaceIfNeededWith(newServerRecords)) { + ServerListUpdated?.Invoke(this, EventArgs.Empty); + } + + return Task.CompletedTask; } + + public bool ShouldSerializeServerRecords() => ServerRecords.Count > 0; + + internal event EventHandler? ServerListUpdated; } diff --git a/ArchiSteamFarm/Steam/SteamKit2/ServerRecordEndPoint.cs b/ArchiSteamFarm/Steam/SteamKit2/ServerRecordEndPoint.cs index 5610c89e3..3db38edf8 100644 --- a/ArchiSteamFarm/Steam/SteamKit2/ServerRecordEndPoint.cs +++ b/ArchiSteamFarm/Steam/SteamKit2/ServerRecordEndPoint.cs @@ -24,40 +24,40 @@ using System.ComponentModel; using Newtonsoft.Json; using SteamKit2; -namespace ArchiSteamFarm.Steam.SteamKit2 { - internal sealed class ServerRecordEndPoint : IEquatable { - [JsonProperty(Required = Required.Always)] - internal readonly string Host = ""; +namespace ArchiSteamFarm.Steam.SteamKit2; - [JsonProperty(Required = Required.Always)] - internal readonly ushort Port; +internal sealed class ServerRecordEndPoint : IEquatable { + [JsonProperty(Required = Required.Always)] + internal readonly string Host = ""; - [JsonProperty(Required = Required.Always)] - internal readonly ProtocolTypes ProtocolTypes; + [JsonProperty(Required = Required.Always)] + internal readonly ushort Port; - internal ServerRecordEndPoint(string host, ushort port, ProtocolTypes protocolTypes) { - if (string.IsNullOrEmpty(host)) { - throw new ArgumentNullException(nameof(host)); - } + [JsonProperty(Required = Required.Always)] + internal readonly ProtocolTypes ProtocolTypes; - if (port == 0) { - throw new ArgumentOutOfRangeException(nameof(port)); - } - - if (protocolTypes == 0) { - throw new InvalidEnumArgumentException(nameof(protocolTypes), (int) protocolTypes, typeof(ProtocolTypes)); - } - - Host = host; - Port = port; - ProtocolTypes = protocolTypes; + internal ServerRecordEndPoint(string host, ushort port, ProtocolTypes protocolTypes) { + if (string.IsNullOrEmpty(host)) { + throw new ArgumentNullException(nameof(host)); } - [JsonConstructor] - private ServerRecordEndPoint() { } + if (port == 0) { + throw new ArgumentOutOfRangeException(nameof(port)); + } - public bool Equals(ServerRecordEndPoint? other) => (other != null) && (ReferenceEquals(other, this) || ((Host == other.Host) && (Port == other.Port) && (ProtocolTypes == other.ProtocolTypes))); - public override bool Equals(object? obj) => (obj != null) && ((obj == this) || (obj is ServerRecordEndPoint serverRecord && Equals(serverRecord))); - public override int GetHashCode() => HashCode.Combine(Host, Port, ProtocolTypes); + if (protocolTypes == 0) { + throw new InvalidEnumArgumentException(nameof(protocolTypes), (int) protocolTypes, typeof(ProtocolTypes)); + } + + Host = host; + Port = port; + ProtocolTypes = protocolTypes; } + + [JsonConstructor] + private ServerRecordEndPoint() { } + + public bool Equals(ServerRecordEndPoint? other) => (other != null) && (ReferenceEquals(other, this) || ((Host == other.Host) && (Port == other.Port) && (ProtocolTypes == other.ProtocolTypes))); + public override bool Equals(object? obj) => (obj != null) && ((obj == this) || (obj is ServerRecordEndPoint serverRecord && Equals(serverRecord))); + public override int GetHashCode() => HashCode.Combine(Host, Port, ProtocolTypes); } diff --git a/ArchiSteamFarm/Steam/Storage/BotConfig.cs b/ArchiSteamFarm/Steam/Storage/BotConfig.cs index 2f0756cd0..8f882649c 100644 --- a/ArchiSteamFarm/Steam/Storage/BotConfig.cs +++ b/ArchiSteamFarm/Steam/Storage/BotConfig.cs @@ -40,638 +40,638 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using SteamKit2; -namespace ArchiSteamFarm.Steam.Storage { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public sealed class BotConfig { - [PublicAPI] - public const bool DefaultAcceptGifts = false; +namespace ArchiSteamFarm.Steam.Storage; - [PublicAPI] - public const bool DefaultAutoSteamSaleEvent = false; +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class BotConfig { + [PublicAPI] + public const bool DefaultAcceptGifts = false; - [PublicAPI] - public const EBotBehaviour DefaultBotBehaviour = EBotBehaviour.None; + [PublicAPI] + public const bool DefaultAutoSteamSaleEvent = false; - [PublicAPI] - public const string? DefaultCustomGamePlayedWhileFarming = null; + [PublicAPI] + public const EBotBehaviour DefaultBotBehaviour = EBotBehaviour.None; - [PublicAPI] - public const string? DefaultCustomGamePlayedWhileIdle = null; + [PublicAPI] + public const string? DefaultCustomGamePlayedWhileFarming = null; - [PublicAPI] - public const bool DefaultEnabled = false; + [PublicAPI] + public const string? DefaultCustomGamePlayedWhileIdle = null; - [PublicAPI] - public const bool DefaultFarmPriorityQueueOnly = false; + [PublicAPI] + public const bool DefaultEnabled = false; - [PublicAPI] - public const byte DefaultHoursUntilCardDrops = 3; + [PublicAPI] + public const bool DefaultFarmPriorityQueueOnly = false; - [PublicAPI] - public const EPersonaState DefaultOnlineStatus = EPersonaState.Online; + [PublicAPI] + public const byte DefaultHoursUntilCardDrops = 3; - [PublicAPI] - public const ArchiCryptoHelper.ECryptoMethod DefaultPasswordFormat = ArchiCryptoHelper.ECryptoMethod.PlainText; + [PublicAPI] + public const EPersonaState DefaultOnlineStatus = EPersonaState.Online; - [PublicAPI] - public const bool DefaultPaused = false; + [PublicAPI] + public const ArchiCryptoHelper.ECryptoMethod DefaultPasswordFormat = ArchiCryptoHelper.ECryptoMethod.PlainText; - [PublicAPI] - public const ERedeemingPreferences DefaultRedeemingPreferences = ERedeemingPreferences.None; + [PublicAPI] + public const bool DefaultPaused = false; - [PublicAPI] - public const bool DefaultSendOnFarmingFinished = false; + [PublicAPI] + public const ERedeemingPreferences DefaultRedeemingPreferences = ERedeemingPreferences.None; - [PublicAPI] - public const byte DefaultSendTradePeriod = 0; + [PublicAPI] + public const bool DefaultSendOnFarmingFinished = false; - [PublicAPI] - public const bool DefaultShutdownOnFarmingFinished = false; + [PublicAPI] + public const byte DefaultSendTradePeriod = 0; - [PublicAPI] - public const bool DefaultSkipRefundableGames = false; + [PublicAPI] + public const bool DefaultShutdownOnFarmingFinished = false; - [PublicAPI] - public const string? DefaultSteamLogin = null; + [PublicAPI] + public const bool DefaultSkipRefundableGames = false; - [PublicAPI] - public const ulong DefaultSteamMasterClanID = 0; + [PublicAPI] + public const string? DefaultSteamLogin = null; - [PublicAPI] - public const string? DefaultSteamParentalCode = null; + [PublicAPI] + public const ulong DefaultSteamMasterClanID = 0; - [PublicAPI] - public const string? DefaultSteamPassword = null; + [PublicAPI] + public const string? DefaultSteamParentalCode = null; - [PublicAPI] - public const string? DefaultSteamTradeToken = null; + [PublicAPI] + public const string? DefaultSteamPassword = null; - [PublicAPI] - public const ETradingPreferences DefaultTradingPreferences = ETradingPreferences.None; + [PublicAPI] + public const string? DefaultSteamTradeToken = null; - [PublicAPI] - public const bool DefaultUseLoginKeys = true; + [PublicAPI] + public const ETradingPreferences DefaultTradingPreferences = ETradingPreferences.None; - [PublicAPI] - public const ArchiHandler.EUserInterfaceMode DefaultUserInterfaceMode = ArchiHandler.EUserInterfaceMode.Default; + [PublicAPI] + public const bool DefaultUseLoginKeys = true; - internal const byte SteamParentalCodeLength = 4; + [PublicAPI] + public const ArchiHandler.EUserInterfaceMode DefaultUserInterfaceMode = ArchiHandler.EUserInterfaceMode.Default; - private const byte SteamTradeTokenLength = 8; + internal const byte SteamParentalCodeLength = 4; - [PublicAPI] - public static readonly ImmutableHashSet DefaultCompleteTypesToSend = ImmutableHashSet.Empty; + private const byte SteamTradeTokenLength = 8; - [PublicAPI] - public static readonly ImmutableList DefaultFarmingOrders = ImmutableList.Empty; + [PublicAPI] + public static readonly ImmutableHashSet DefaultCompleteTypesToSend = ImmutableHashSet.Empty; - [PublicAPI] - public static readonly ImmutableHashSet DefaultGamesPlayedWhileIdle = ImmutableHashSet.Empty; + [PublicAPI] + public static readonly ImmutableList DefaultFarmingOrders = ImmutableList.Empty; - [PublicAPI] - public static readonly ImmutableHashSet DefaultLootableTypes = ImmutableHashSet.Create(Asset.EType.BoosterPack, Asset.EType.FoilTradingCard, Asset.EType.TradingCard); + [PublicAPI] + public static readonly ImmutableHashSet DefaultGamesPlayedWhileIdle = ImmutableHashSet.Empty; - [PublicAPI] - public static readonly ImmutableHashSet DefaultMatchableTypes = ImmutableHashSet.Create(Asset.EType.TradingCard); + [PublicAPI] + public static readonly ImmutableHashSet DefaultLootableTypes = ImmutableHashSet.Create(Asset.EType.BoosterPack, Asset.EType.FoilTradingCard, Asset.EType.TradingCard); - [PublicAPI] - public static readonly ImmutableDictionary DefaultSteamUserPermissions = ImmutableDictionary.Empty; + [PublicAPI] + public static readonly ImmutableHashSet DefaultMatchableTypes = ImmutableHashSet.Create(Asset.EType.TradingCard); - [PublicAPI] - public static readonly ImmutableHashSet DefaultTransferableTypes = ImmutableHashSet.Create(Asset.EType.BoosterPack, Asset.EType.FoilTradingCard, Asset.EType.TradingCard); + [PublicAPI] + public static readonly ImmutableDictionary DefaultSteamUserPermissions = ImmutableDictionary.Empty; - [JsonProperty(Required = Required.DisallowNull)] - public bool AcceptGifts { get; private set; } = DefaultAcceptGifts; + [PublicAPI] + public static readonly ImmutableHashSet DefaultTransferableTypes = ImmutableHashSet.Create(Asset.EType.BoosterPack, Asset.EType.FoilTradingCard, Asset.EType.TradingCard); - [JsonProperty(Required = Required.DisallowNull)] - public bool AutoSteamSaleEvent { get; private set; } = DefaultAutoSteamSaleEvent; + [JsonProperty(Required = Required.DisallowNull)] + public bool AcceptGifts { get; private set; } = DefaultAcceptGifts; - [JsonProperty(Required = Required.DisallowNull)] - public EBotBehaviour BotBehaviour { get; private set; } = DefaultBotBehaviour; + [JsonProperty(Required = Required.DisallowNull)] + public bool AutoSteamSaleEvent { get; private set; } = DefaultAutoSteamSaleEvent; - [JsonProperty(Required = Required.DisallowNull)] - [SwaggerValidValues(ValidIntValues = new[] { (int) Asset.EType.FoilTradingCard, (int) Asset.EType.TradingCard })] - public ImmutableHashSet CompleteTypesToSend { get; private set; } = DefaultCompleteTypesToSend; + [JsonProperty(Required = Required.DisallowNull)] + public EBotBehaviour BotBehaviour { get; private set; } = DefaultBotBehaviour; - [JsonProperty] - public string? CustomGamePlayedWhileFarming { get; private set; } = DefaultCustomGamePlayedWhileFarming; + [JsonProperty(Required = Required.DisallowNull)] + [SwaggerValidValues(ValidIntValues = new[] { (int) Asset.EType.FoilTradingCard, (int) Asset.EType.TradingCard })] + public ImmutableHashSet CompleteTypesToSend { get; private set; } = DefaultCompleteTypesToSend; - [JsonProperty] - public string? CustomGamePlayedWhileIdle { get; private set; } = DefaultCustomGamePlayedWhileIdle; + [JsonProperty] + public string? CustomGamePlayedWhileFarming { get; private set; } = DefaultCustomGamePlayedWhileFarming; - [JsonProperty(Required = Required.DisallowNull)] - public bool Enabled { get; private set; } = DefaultEnabled; + [JsonProperty] + public string? CustomGamePlayedWhileIdle { get; private set; } = DefaultCustomGamePlayedWhileIdle; - [JsonProperty(Required = Required.DisallowNull)] - public ImmutableList FarmingOrders { get; private set; } = DefaultFarmingOrders; + [JsonProperty(Required = Required.DisallowNull)] + public bool Enabled { get; private set; } = DefaultEnabled; - [JsonProperty(Required = Required.DisallowNull)] - public bool FarmPriorityQueueOnly { get; private set; } = DefaultFarmPriorityQueueOnly; + [JsonProperty(Required = Required.DisallowNull)] + public ImmutableList FarmingOrders { get; private set; } = DefaultFarmingOrders; - [JsonProperty(Required = Required.DisallowNull)] - [MaxLength(ArchiHandler.MaxGamesPlayedConcurrently)] - [SwaggerItemsMinMax(MinimumUint = 1, MaximumUint = uint.MaxValue)] - public ImmutableHashSet GamesPlayedWhileIdle { get; private set; } = DefaultGamesPlayedWhileIdle; + [JsonProperty(Required = Required.DisallowNull)] + public bool FarmPriorityQueueOnly { get; private set; } = DefaultFarmPriorityQueueOnly; - [JsonProperty(Required = Required.DisallowNull)] - [Range(byte.MinValue, byte.MaxValue)] - public byte HoursUntilCardDrops { get; private set; } = DefaultHoursUntilCardDrops; + [JsonProperty(Required = Required.DisallowNull)] + [MaxLength(ArchiHandler.MaxGamesPlayedConcurrently)] + [SwaggerItemsMinMax(MinimumUint = 1, MaximumUint = uint.MaxValue)] + public ImmutableHashSet GamesPlayedWhileIdle { get; private set; } = DefaultGamesPlayedWhileIdle; - [JsonProperty(Required = Required.DisallowNull)] - public ImmutableHashSet LootableTypes { get; private set; } = DefaultLootableTypes; + [JsonProperty(Required = Required.DisallowNull)] + [Range(byte.MinValue, byte.MaxValue)] + public byte HoursUntilCardDrops { get; private set; } = DefaultHoursUntilCardDrops; - [JsonProperty(Required = Required.DisallowNull)] - public ImmutableHashSet MatchableTypes { get; private set; } = DefaultMatchableTypes; + [JsonProperty(Required = Required.DisallowNull)] + public ImmutableHashSet LootableTypes { get; private set; } = DefaultLootableTypes; - [JsonProperty(Required = Required.DisallowNull)] - public EPersonaState OnlineStatus { get; private set; } = DefaultOnlineStatus; + [JsonProperty(Required = Required.DisallowNull)] + public ImmutableHashSet MatchableTypes { get; private set; } = DefaultMatchableTypes; - [JsonProperty(Required = Required.DisallowNull)] - public ArchiCryptoHelper.ECryptoMethod PasswordFormat { get; private set; } = DefaultPasswordFormat; + [JsonProperty(Required = Required.DisallowNull)] + public EPersonaState OnlineStatus { get; private set; } = DefaultOnlineStatus; - [JsonProperty(Required = Required.DisallowNull)] - public bool Paused { get; private set; } = DefaultPaused; + [JsonProperty(Required = Required.DisallowNull)] + public ArchiCryptoHelper.ECryptoMethod PasswordFormat { get; private set; } = DefaultPasswordFormat; - [JsonProperty(Required = Required.DisallowNull)] - public ERedeemingPreferences RedeemingPreferences { get; private set; } = DefaultRedeemingPreferences; + [JsonProperty(Required = Required.DisallowNull)] + public bool Paused { get; private set; } = DefaultPaused; - [JsonProperty(Required = Required.DisallowNull)] - public bool SendOnFarmingFinished { get; private set; } = DefaultSendOnFarmingFinished; + [JsonProperty(Required = Required.DisallowNull)] + public ERedeemingPreferences RedeemingPreferences { get; private set; } = DefaultRedeemingPreferences; - [JsonProperty(Required = Required.DisallowNull)] - [Range(byte.MinValue, byte.MaxValue)] - public byte SendTradePeriod { get; private set; } = DefaultSendTradePeriod; + [JsonProperty(Required = Required.DisallowNull)] + public bool SendOnFarmingFinished { get; private set; } = DefaultSendOnFarmingFinished; - [JsonProperty(Required = Required.DisallowNull)] - public bool ShutdownOnFarmingFinished { get; private set; } = DefaultShutdownOnFarmingFinished; + [JsonProperty(Required = Required.DisallowNull)] + [Range(byte.MinValue, byte.MaxValue)] + public byte SendTradePeriod { get; private set; } = DefaultSendTradePeriod; - [JsonProperty(Required = Required.DisallowNull)] - public bool SkipRefundableGames { get; private set; } = DefaultSkipRefundableGames; + [JsonProperty(Required = Required.DisallowNull)] + public bool ShutdownOnFarmingFinished { get; private set; } = DefaultShutdownOnFarmingFinished; - [JsonProperty] - public string? SteamLogin { - get => BackingSteamLogin; + [JsonProperty(Required = Required.DisallowNull)] + public bool SkipRefundableGames { get; private set; } = DefaultSkipRefundableGames; - internal set { - IsSteamLoginSet = true; - BackingSteamLogin = value; - } - } + [JsonProperty] + public string? SteamLogin { + get => BackingSteamLogin; - [JsonProperty(Required = Required.DisallowNull)] - [SwaggerSteamIdentifier(AccountType = EAccountType.Clan)] - [SwaggerValidValues(ValidIntValues = new[] { 0 })] - public ulong SteamMasterClanID { get; private set; } = DefaultSteamMasterClanID; - - [JsonProperty] - [MaxLength(SteamParentalCodeLength)] - [MinLength(SteamParentalCodeLength)] - [SwaggerValidValues(ValidStringValues = new[] { "0" })] - public string? SteamParentalCode { - get => BackingSteamParentalCode; - - internal set { - IsSteamParentalCodeSet = true; - BackingSteamParentalCode = value; - } - } - - [JsonProperty] - public string? SteamPassword { - get => BackingSteamPassword; - - internal set { - IsSteamPasswordSet = true; - BackingSteamPassword = value; - } - } - - [JsonProperty] - [MaxLength(SteamTradeTokenLength)] - [MinLength(SteamTradeTokenLength)] - public string? SteamTradeToken { get; private set; } = DefaultSteamTradeToken; - - [JsonProperty(Required = Required.DisallowNull)] - public ImmutableDictionary SteamUserPermissions { get; private set; } = DefaultSteamUserPermissions; - - [JsonProperty(Required = Required.DisallowNull)] - public ETradingPreferences TradingPreferences { get; private set; } = DefaultTradingPreferences; - - [JsonProperty(Required = Required.DisallowNull)] - public ImmutableHashSet TransferableTypes { get; private set; } = DefaultTransferableTypes; - - [JsonProperty(Required = Required.DisallowNull)] - public bool UseLoginKeys { get; private set; } = DefaultUseLoginKeys; - - [JsonProperty(Required = Required.DisallowNull)] - public ArchiHandler.EUserInterfaceMode UserInterfaceMode { get; private set; } = DefaultUserInterfaceMode; - - [JsonExtensionData] - internal Dictionary? AdditionalProperties { - get; - [UsedImplicitly] - set; - } - - internal string? DecryptedSteamPassword { - get { - if (string.IsNullOrEmpty(SteamPassword)) { - return null; - } - - if (PasswordFormat == ArchiCryptoHelper.ECryptoMethod.PlainText) { - return SteamPassword; - } - - string? result = ArchiCryptoHelper.Decrypt(PasswordFormat, SteamPassword!); - - if (string.IsNullOrEmpty(result)) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(SteamPassword))); - - return null; - } - - return result; - } - - set { - if (!string.IsNullOrEmpty(value) && (PasswordFormat != ArchiCryptoHelper.ECryptoMethod.PlainText)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - value = ArchiCryptoHelper.Encrypt(PasswordFormat, value!); - } - - SteamPassword = value; - } - } - - internal bool IsSteamLoginSet { get; set; } - internal bool IsSteamParentalCodeSet { get; set; } - internal bool IsSteamPasswordSet { get; set; } - internal bool Saving { get; set; } - - private string? BackingSteamLogin = DefaultSteamLogin; - private string? BackingSteamParentalCode = DefaultSteamParentalCode; - private string? BackingSteamPassword = DefaultSteamPassword; - - [JsonProperty(PropertyName = SharedInfo.UlongCompatibilityStringPrefix + nameof(SteamMasterClanID), Required = Required.DisallowNull)] - private string SSteamMasterClanID { - get => SteamMasterClanID.ToString(CultureInfo.InvariantCulture); - - set { - if (string.IsNullOrEmpty(value) || !ulong.TryParse(value, out ulong result)) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(SSteamMasterClanID))); - - return; - } - - SteamMasterClanID = result; - } - } - - [JsonConstructor] - internal BotConfig() { } - - [UsedImplicitly] - public bool ShouldSerializeAcceptGifts() => !Saving || (AcceptGifts != DefaultAcceptGifts); - - [UsedImplicitly] - public bool ShouldSerializeAutoSteamSaleEvent() => !Saving || (AutoSteamSaleEvent != DefaultAutoSteamSaleEvent); - - [UsedImplicitly] - public bool ShouldSerializeBotBehaviour() => !Saving || (BotBehaviour != DefaultBotBehaviour); - - [UsedImplicitly] - public bool ShouldSerializeCompleteTypesToSend() => !Saving || ((CompleteTypesToSend != DefaultCompleteTypesToSend) && !CompleteTypesToSend.SetEquals(DefaultCompleteTypesToSend)); - - [UsedImplicitly] - public bool ShouldSerializeCustomGamePlayedWhileFarming() => !Saving || (CustomGamePlayedWhileFarming != DefaultCustomGamePlayedWhileFarming); - - [UsedImplicitly] - public bool ShouldSerializeCustomGamePlayedWhileIdle() => !Saving || (CustomGamePlayedWhileIdle != DefaultCustomGamePlayedWhileIdle); - - [UsedImplicitly] - public bool ShouldSerializeEnabled() => !Saving || (Enabled != DefaultEnabled); - - [UsedImplicitly] - public bool ShouldSerializeFarmingOrders() => !Saving || ((FarmingOrders != DefaultFarmingOrders) && !FarmingOrders.SequenceEqual(DefaultFarmingOrders)); - - [UsedImplicitly] - public bool ShouldSerializeFarmPriorityQueueOnly() => !Saving || (FarmPriorityQueueOnly != DefaultFarmPriorityQueueOnly); - - [UsedImplicitly] - public bool ShouldSerializeGamesPlayedWhileIdle() => !Saving || ((GamesPlayedWhileIdle != DefaultGamesPlayedWhileIdle) && !GamesPlayedWhileIdle.SetEquals(DefaultGamesPlayedWhileIdle)); - - [UsedImplicitly] - public bool ShouldSerializeHoursUntilCardDrops() => !Saving || (HoursUntilCardDrops != DefaultHoursUntilCardDrops); - - [UsedImplicitly] - public bool ShouldSerializeLootableTypes() => !Saving || ((LootableTypes != DefaultLootableTypes) && !LootableTypes.SetEquals(DefaultLootableTypes)); - - [UsedImplicitly] - public bool ShouldSerializeMatchableTypes() => !Saving || ((MatchableTypes != DefaultMatchableTypes) && !MatchableTypes.SetEquals(DefaultMatchableTypes)); - - [UsedImplicitly] - public bool ShouldSerializeOnlineStatus() => !Saving || (OnlineStatus != DefaultOnlineStatus); - - [UsedImplicitly] - public bool ShouldSerializePasswordFormat() => !Saving || (PasswordFormat != DefaultPasswordFormat); - - [UsedImplicitly] - public bool ShouldSerializePaused() => !Saving || (Paused != DefaultPaused); - - [UsedImplicitly] - public bool ShouldSerializeRedeemingPreferences() => !Saving || (RedeemingPreferences != DefaultRedeemingPreferences); - - [UsedImplicitly] - public bool ShouldSerializeSendOnFarmingFinished() => !Saving || (SendOnFarmingFinished != DefaultSendOnFarmingFinished); - - [UsedImplicitly] - public bool ShouldSerializeSendTradePeriod() => !Saving || (SendTradePeriod != DefaultSendTradePeriod); - - [UsedImplicitly] - public bool ShouldSerializeShutdownOnFarmingFinished() => !Saving || (ShutdownOnFarmingFinished != DefaultShutdownOnFarmingFinished); - - [UsedImplicitly] - public bool ShouldSerializeSkipRefundableGames() => !Saving || (SkipRefundableGames != DefaultSkipRefundableGames); - - [UsedImplicitly] - public bool ShouldSerializeSSteamMasterClanID() => !Saving; - - [UsedImplicitly] - public bool ShouldSerializeSteamLogin() => Saving && IsSteamLoginSet && (SteamLogin != DefaultSteamLogin); - - [UsedImplicitly] - public bool ShouldSerializeSteamMasterClanID() => !Saving || (SteamMasterClanID != DefaultSteamMasterClanID); - - [UsedImplicitly] - public bool ShouldSerializeSteamParentalCode() => Saving && IsSteamParentalCodeSet && (SteamParentalCode != DefaultSteamParentalCode); - - [UsedImplicitly] - public bool ShouldSerializeSteamPassword() => Saving && IsSteamPasswordSet && (SteamPassword != DefaultSteamPassword); - - [UsedImplicitly] - public bool ShouldSerializeSteamTradeToken() => !Saving || (SteamTradeToken != DefaultSteamTradeToken); - - [UsedImplicitly] - public bool ShouldSerializeSteamUserPermissions() => !Saving || ((SteamUserPermissions != DefaultSteamUserPermissions) && ((SteamUserPermissions.Count != DefaultSteamUserPermissions.Count) || SteamUserPermissions.Except(DefaultSteamUserPermissions).Any())); - - [UsedImplicitly] - public bool ShouldSerializeTradingPreferences() => !Saving || (TradingPreferences != DefaultTradingPreferences); - - [UsedImplicitly] - public bool ShouldSerializeTransferableTypes() => !Saving || ((TransferableTypes != DefaultTransferableTypes) && !TransferableTypes.SetEquals(DefaultTransferableTypes)); - - [UsedImplicitly] - public bool ShouldSerializeUseLoginKeys() => !Saving || (UseLoginKeys != DefaultUseLoginKeys); - - [UsedImplicitly] - public bool ShouldSerializeUserInterfaceMode() => !Saving || (UserInterfaceMode != DefaultUserInterfaceMode); - - [PublicAPI] - public static async Task Write(string filePath, BotConfig botConfig) { - if (string.IsNullOrEmpty(filePath)) { - throw new ArgumentNullException(nameof(filePath)); - } - - if (botConfig == null) { - throw new ArgumentNullException(nameof(botConfig)); - } - - string json = JsonConvert.SerializeObject(botConfig, Formatting.Indented); - - return await SerializableFile.Write(filePath, json).ConfigureAwait(false); - } - - internal (bool Valid, string? ErrorMessage) CheckValidation() { - if (BotBehaviour > EBotBehaviour.All) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(BotBehaviour), BotBehaviour)); - } - - foreach (EFarmingOrder farmingOrder in FarmingOrders.Where(static farmingOrder => !Enum.IsDefined(typeof(EFarmingOrder), farmingOrder))) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(FarmingOrders), farmingOrder)); - } - - if (GamesPlayedWhileIdle.Contains(0)) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(GamesPlayedWhileIdle), 0)); - } - - if (GamesPlayedWhileIdle.Count > ArchiHandler.MaxGamesPlayedConcurrently) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(GamesPlayedWhileIdle), $"{nameof(GamesPlayedWhileIdle.Count)} {GamesPlayedWhileIdle.Count} > {ArchiHandler.MaxGamesPlayedConcurrently}")); - } - - foreach (Asset.EType lootableType in LootableTypes.Where(static lootableType => !Enum.IsDefined(typeof(Asset.EType), lootableType))) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(LootableTypes), lootableType)); - } - - HashSet? completeTypesToSendValidTypes = null; - - foreach (Asset.EType completableType in CompleteTypesToSend) { - if (!Enum.IsDefined(typeof(Asset.EType), completableType)) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(CompleteTypesToSend), completableType)); - } - - if (completeTypesToSendValidTypes == null) { - SwaggerValidValuesAttribute? completeTypesToSendValidValues = typeof(BotConfig).GetProperty(nameof(CompleteTypesToSend))?.GetCustomAttribute(); - - if (completeTypesToSendValidValues?.ValidIntValues == null) { - throw new InvalidOperationException(nameof(completeTypesToSendValidValues)); - } - - completeTypesToSendValidTypes = completeTypesToSendValidValues.ValidIntValues.Select(static value => (Asset.EType) value).ToHashSet(); - } - - if (!completeTypesToSendValidTypes.Contains(completableType)) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(CompleteTypesToSend), completableType)); - } - } - - foreach (Asset.EType matchableType in MatchableTypes.Where(static matchableType => !Enum.IsDefined(typeof(Asset.EType), matchableType))) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(MatchableTypes), matchableType)); - } - - if (!Enum.IsDefined(typeof(EPersonaState), OnlineStatus)) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(OnlineStatus), OnlineStatus)); - } - - if (!Enum.IsDefined(typeof(ArchiCryptoHelper.ECryptoMethod), PasswordFormat)) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(PasswordFormat), PasswordFormat)); - } - - if (RedeemingPreferences > ERedeemingPreferences.All) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(RedeemingPreferences), RedeemingPreferences)); - } - - if ((SteamMasterClanID != 0) && !new SteamID(SteamMasterClanID).IsClanAccount) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamMasterClanID), SteamMasterClanID)); - } - - if (!string.IsNullOrEmpty(SteamParentalCode) && (SteamParentalCode != "0") && (SteamParentalCode!.Length != SteamParentalCodeLength)) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamParentalCode), SteamParentalCode)); - } - - if (!string.IsNullOrEmpty(SteamTradeToken) && (SteamTradeToken!.Length != SteamTradeTokenLength)) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamTradeToken), SteamTradeToken)); - } - - foreach ((ulong steamID, EAccess permission) in SteamUserPermissions) { - if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamUserPermissions), steamID)); - } - - if (!Enum.IsDefined(typeof(EAccess), permission)) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamUserPermissions), permission)); - } - } - - if (TradingPreferences > ETradingPreferences.All) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(TradingPreferences), TradingPreferences)); - } - - return !Enum.IsDefined(typeof(ArchiHandler.EUserInterfaceMode), UserInterfaceMode) ? (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(UserInterfaceMode), UserInterfaceMode)) : (true, null); - } - - internal static async Task<(BotConfig? BotConfig, string? LatestJson)> Load(string filePath) { - if (string.IsNullOrEmpty(filePath)) { - throw new ArgumentNullException(nameof(filePath)); - } - - if (!File.Exists(filePath)) { - return (null, null); - } - - string json; - BotConfig? botConfig; - - try { - json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); - - if (string.IsNullOrEmpty(json)) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(json))); - - return (null, null); - } - - botConfig = JsonConvert.DeserializeObject(json); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return (null, null); - } - - if (botConfig == null) { - ASF.ArchiLogger.LogNullError(nameof(botConfig)); - - return (null, null); - } - - (bool valid, string? errorMessage) = botConfig.CheckValidation(); - - if (!valid) { - if (!string.IsNullOrEmpty(errorMessage)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - ASF.ArchiLogger.LogGenericError(errorMessage!); - } - - return (null, null); - } - - if (!string.IsNullOrEmpty(botConfig.DecryptedSteamPassword)) { - HashSet disallowedValues = new(StringComparer.InvariantCultureIgnoreCase) { "account" }; - - if (!string.IsNullOrEmpty(botConfig.SteamLogin)) { - disallowedValues.Add(botConfig.SteamLogin!); - } - - Utilities.InBackground( - () => { - (bool isWeak, string? reason) = Utilities.TestPasswordStrength(botConfig.DecryptedSteamPassword!, disallowedValues); - - if (isWeak) { - ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningWeakSteamPassword, !string.IsNullOrEmpty(botConfig.SteamLogin) ? botConfig.SteamLogin! : filePath, reason)); - } - } - ); - } - - switch (botConfig.PasswordFormat) { - case ArchiCryptoHelper.ECryptoMethod.AES when ArchiCryptoHelper.HasDefaultCryptKey: - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningDefaultCryptKeyUsedForEncryption, botConfig.PasswordFormat, nameof(SteamPassword))); - - break; - case ArchiCryptoHelper.ECryptoMethod.ProtectedDataForCurrentUser when ArchiCryptoHelper.HasDefaultCryptKey: - ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.WarningDefaultCryptKeyUsedForHashing, botConfig.PasswordFormat, nameof(SteamPassword))); - - break; - } - - if (!Program.ConfigMigrate) { - return (botConfig, null); - } - - botConfig.Saving = true; - string latestJson = JsonConvert.SerializeObject(botConfig, Formatting.Indented); - botConfig.Saving = false; - - return (botConfig, json != latestJson ? latestJson : null); - } - - public enum EAccess : byte { - None, - FamilySharing, - Operator, - Master - } - - [Flags] - public enum EBotBehaviour : byte { - None = 0, - RejectInvalidFriendInvites = 1, - RejectInvalidTrades = 2, - RejectInvalidGroupInvites = 4, - DismissInventoryNotifications = 8, - MarkReceivedMessagesAsRead = 16, - MarkBotMessagesAsRead = 32, - All = RejectInvalidFriendInvites | RejectInvalidTrades | RejectInvalidGroupInvites | DismissInventoryNotifications | MarkReceivedMessagesAsRead | MarkBotMessagesAsRead - } - - public enum EFarmingOrder : byte { - Unordered, - AppIDsAscending, - AppIDsDescending, - CardDropsAscending, - CardDropsDescending, - HoursAscending, - HoursDescending, - NamesAscending, - NamesDescending, - Random, - BadgeLevelsAscending, - BadgeLevelsDescending, - RedeemDateTimesAscending, - RedeemDateTimesDescending, - MarketableAscending, - MarketableDescending - } - - [Flags] - public enum ERedeemingPreferences : byte { - None = 0, - Forwarding = 1, - Distributing = 2, - KeepMissingGames = 4, - AssumeWalletKeyOnBadActivationCode = 8, - All = Forwarding | Distributing | KeepMissingGames | AssumeWalletKeyOnBadActivationCode - } - - [Flags] - public enum ETradingPreferences : byte { - None = 0, - AcceptDonations = 1, - SteamTradeMatcher = 2, - MatchEverything = 4, - DontAcceptBotTrades = 8, - MatchActively = 16, - All = AcceptDonations | SteamTradeMatcher | MatchEverything | DontAcceptBotTrades | MatchActively + internal set { + IsSteamLoginSet = true; + BackingSteamLogin = value; } } + + [JsonProperty(Required = Required.DisallowNull)] + [SwaggerSteamIdentifier(AccountType = EAccountType.Clan)] + [SwaggerValidValues(ValidIntValues = new[] { 0 })] + public ulong SteamMasterClanID { get; private set; } = DefaultSteamMasterClanID; + + [JsonProperty] + [MaxLength(SteamParentalCodeLength)] + [MinLength(SteamParentalCodeLength)] + [SwaggerValidValues(ValidStringValues = new[] { "0" })] + public string? SteamParentalCode { + get => BackingSteamParentalCode; + + internal set { + IsSteamParentalCodeSet = true; + BackingSteamParentalCode = value; + } + } + + [JsonProperty] + public string? SteamPassword { + get => BackingSteamPassword; + + internal set { + IsSteamPasswordSet = true; + BackingSteamPassword = value; + } + } + + [JsonProperty] + [MaxLength(SteamTradeTokenLength)] + [MinLength(SteamTradeTokenLength)] + public string? SteamTradeToken { get; private set; } = DefaultSteamTradeToken; + + [JsonProperty(Required = Required.DisallowNull)] + public ImmutableDictionary SteamUserPermissions { get; private set; } = DefaultSteamUserPermissions; + + [JsonProperty(Required = Required.DisallowNull)] + public ETradingPreferences TradingPreferences { get; private set; } = DefaultTradingPreferences; + + [JsonProperty(Required = Required.DisallowNull)] + public ImmutableHashSet TransferableTypes { get; private set; } = DefaultTransferableTypes; + + [JsonProperty(Required = Required.DisallowNull)] + public bool UseLoginKeys { get; private set; } = DefaultUseLoginKeys; + + [JsonProperty(Required = Required.DisallowNull)] + public ArchiHandler.EUserInterfaceMode UserInterfaceMode { get; private set; } = DefaultUserInterfaceMode; + + [JsonExtensionData] + internal Dictionary? AdditionalProperties { + get; + [UsedImplicitly] + set; + } + + internal string? DecryptedSteamPassword { + get { + if (string.IsNullOrEmpty(SteamPassword)) { + return null; + } + + if (PasswordFormat == ArchiCryptoHelper.ECryptoMethod.PlainText) { + return SteamPassword; + } + + string? result = ArchiCryptoHelper.Decrypt(PasswordFormat, SteamPassword!); + + if (string.IsNullOrEmpty(result)) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(SteamPassword))); + + return null; + } + + return result; + } + + set { + if (!string.IsNullOrEmpty(value) && (PasswordFormat != ArchiCryptoHelper.ECryptoMethod.PlainText)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + value = ArchiCryptoHelper.Encrypt(PasswordFormat, value!); + } + + SteamPassword = value; + } + } + + internal bool IsSteamLoginSet { get; set; } + internal bool IsSteamParentalCodeSet { get; set; } + internal bool IsSteamPasswordSet { get; set; } + internal bool Saving { get; set; } + + private string? BackingSteamLogin = DefaultSteamLogin; + private string? BackingSteamParentalCode = DefaultSteamParentalCode; + private string? BackingSteamPassword = DefaultSteamPassword; + + [JsonProperty(PropertyName = SharedInfo.UlongCompatibilityStringPrefix + nameof(SteamMasterClanID), Required = Required.DisallowNull)] + private string SSteamMasterClanID { + get => SteamMasterClanID.ToString(CultureInfo.InvariantCulture); + + set { + if (string.IsNullOrEmpty(value) || !ulong.TryParse(value, out ulong result)) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(SSteamMasterClanID))); + + return; + } + + SteamMasterClanID = result; + } + } + + [JsonConstructor] + internal BotConfig() { } + + [UsedImplicitly] + public bool ShouldSerializeAcceptGifts() => !Saving || (AcceptGifts != DefaultAcceptGifts); + + [UsedImplicitly] + public bool ShouldSerializeAutoSteamSaleEvent() => !Saving || (AutoSteamSaleEvent != DefaultAutoSteamSaleEvent); + + [UsedImplicitly] + public bool ShouldSerializeBotBehaviour() => !Saving || (BotBehaviour != DefaultBotBehaviour); + + [UsedImplicitly] + public bool ShouldSerializeCompleteTypesToSend() => !Saving || ((CompleteTypesToSend != DefaultCompleteTypesToSend) && !CompleteTypesToSend.SetEquals(DefaultCompleteTypesToSend)); + + [UsedImplicitly] + public bool ShouldSerializeCustomGamePlayedWhileFarming() => !Saving || (CustomGamePlayedWhileFarming != DefaultCustomGamePlayedWhileFarming); + + [UsedImplicitly] + public bool ShouldSerializeCustomGamePlayedWhileIdle() => !Saving || (CustomGamePlayedWhileIdle != DefaultCustomGamePlayedWhileIdle); + + [UsedImplicitly] + public bool ShouldSerializeEnabled() => !Saving || (Enabled != DefaultEnabled); + + [UsedImplicitly] + public bool ShouldSerializeFarmingOrders() => !Saving || ((FarmingOrders != DefaultFarmingOrders) && !FarmingOrders.SequenceEqual(DefaultFarmingOrders)); + + [UsedImplicitly] + public bool ShouldSerializeFarmPriorityQueueOnly() => !Saving || (FarmPriorityQueueOnly != DefaultFarmPriorityQueueOnly); + + [UsedImplicitly] + public bool ShouldSerializeGamesPlayedWhileIdle() => !Saving || ((GamesPlayedWhileIdle != DefaultGamesPlayedWhileIdle) && !GamesPlayedWhileIdle.SetEquals(DefaultGamesPlayedWhileIdle)); + + [UsedImplicitly] + public bool ShouldSerializeHoursUntilCardDrops() => !Saving || (HoursUntilCardDrops != DefaultHoursUntilCardDrops); + + [UsedImplicitly] + public bool ShouldSerializeLootableTypes() => !Saving || ((LootableTypes != DefaultLootableTypes) && !LootableTypes.SetEquals(DefaultLootableTypes)); + + [UsedImplicitly] + public bool ShouldSerializeMatchableTypes() => !Saving || ((MatchableTypes != DefaultMatchableTypes) && !MatchableTypes.SetEquals(DefaultMatchableTypes)); + + [UsedImplicitly] + public bool ShouldSerializeOnlineStatus() => !Saving || (OnlineStatus != DefaultOnlineStatus); + + [UsedImplicitly] + public bool ShouldSerializePasswordFormat() => !Saving || (PasswordFormat != DefaultPasswordFormat); + + [UsedImplicitly] + public bool ShouldSerializePaused() => !Saving || (Paused != DefaultPaused); + + [UsedImplicitly] + public bool ShouldSerializeRedeemingPreferences() => !Saving || (RedeemingPreferences != DefaultRedeemingPreferences); + + [UsedImplicitly] + public bool ShouldSerializeSendOnFarmingFinished() => !Saving || (SendOnFarmingFinished != DefaultSendOnFarmingFinished); + + [UsedImplicitly] + public bool ShouldSerializeSendTradePeriod() => !Saving || (SendTradePeriod != DefaultSendTradePeriod); + + [UsedImplicitly] + public bool ShouldSerializeShutdownOnFarmingFinished() => !Saving || (ShutdownOnFarmingFinished != DefaultShutdownOnFarmingFinished); + + [UsedImplicitly] + public bool ShouldSerializeSkipRefundableGames() => !Saving || (SkipRefundableGames != DefaultSkipRefundableGames); + + [UsedImplicitly] + public bool ShouldSerializeSSteamMasterClanID() => !Saving; + + [UsedImplicitly] + public bool ShouldSerializeSteamLogin() => Saving && IsSteamLoginSet && (SteamLogin != DefaultSteamLogin); + + [UsedImplicitly] + public bool ShouldSerializeSteamMasterClanID() => !Saving || (SteamMasterClanID != DefaultSteamMasterClanID); + + [UsedImplicitly] + public bool ShouldSerializeSteamParentalCode() => Saving && IsSteamParentalCodeSet && (SteamParentalCode != DefaultSteamParentalCode); + + [UsedImplicitly] + public bool ShouldSerializeSteamPassword() => Saving && IsSteamPasswordSet && (SteamPassword != DefaultSteamPassword); + + [UsedImplicitly] + public bool ShouldSerializeSteamTradeToken() => !Saving || (SteamTradeToken != DefaultSteamTradeToken); + + [UsedImplicitly] + public bool ShouldSerializeSteamUserPermissions() => !Saving || ((SteamUserPermissions != DefaultSteamUserPermissions) && ((SteamUserPermissions.Count != DefaultSteamUserPermissions.Count) || SteamUserPermissions.Except(DefaultSteamUserPermissions).Any())); + + [UsedImplicitly] + public bool ShouldSerializeTradingPreferences() => !Saving || (TradingPreferences != DefaultTradingPreferences); + + [UsedImplicitly] + public bool ShouldSerializeTransferableTypes() => !Saving || ((TransferableTypes != DefaultTransferableTypes) && !TransferableTypes.SetEquals(DefaultTransferableTypes)); + + [UsedImplicitly] + public bool ShouldSerializeUseLoginKeys() => !Saving || (UseLoginKeys != DefaultUseLoginKeys); + + [UsedImplicitly] + public bool ShouldSerializeUserInterfaceMode() => !Saving || (UserInterfaceMode != DefaultUserInterfaceMode); + + [PublicAPI] + public static async Task Write(string filePath, BotConfig botConfig) { + if (string.IsNullOrEmpty(filePath)) { + throw new ArgumentNullException(nameof(filePath)); + } + + if (botConfig == null) { + throw new ArgumentNullException(nameof(botConfig)); + } + + string json = JsonConvert.SerializeObject(botConfig, Formatting.Indented); + + return await SerializableFile.Write(filePath, json).ConfigureAwait(false); + } + + internal (bool Valid, string? ErrorMessage) CheckValidation() { + if (BotBehaviour > EBotBehaviour.All) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(BotBehaviour), BotBehaviour)); + } + + foreach (EFarmingOrder farmingOrder in FarmingOrders.Where(static farmingOrder => !Enum.IsDefined(typeof(EFarmingOrder), farmingOrder))) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(FarmingOrders), farmingOrder)); + } + + if (GamesPlayedWhileIdle.Contains(0)) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(GamesPlayedWhileIdle), 0)); + } + + if (GamesPlayedWhileIdle.Count > ArchiHandler.MaxGamesPlayedConcurrently) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(GamesPlayedWhileIdle), $"{nameof(GamesPlayedWhileIdle.Count)} {GamesPlayedWhileIdle.Count} > {ArchiHandler.MaxGamesPlayedConcurrently}")); + } + + foreach (Asset.EType lootableType in LootableTypes.Where(static lootableType => !Enum.IsDefined(typeof(Asset.EType), lootableType))) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(LootableTypes), lootableType)); + } + + HashSet? completeTypesToSendValidTypes = null; + + foreach (Asset.EType completableType in CompleteTypesToSend) { + if (!Enum.IsDefined(typeof(Asset.EType), completableType)) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(CompleteTypesToSend), completableType)); + } + + if (completeTypesToSendValidTypes == null) { + SwaggerValidValuesAttribute? completeTypesToSendValidValues = typeof(BotConfig).GetProperty(nameof(CompleteTypesToSend))?.GetCustomAttribute(); + + if (completeTypesToSendValidValues?.ValidIntValues == null) { + throw new InvalidOperationException(nameof(completeTypesToSendValidValues)); + } + + completeTypesToSendValidTypes = completeTypesToSendValidValues.ValidIntValues.Select(static value => (Asset.EType) value).ToHashSet(); + } + + if (!completeTypesToSendValidTypes.Contains(completableType)) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(CompleteTypesToSend), completableType)); + } + } + + foreach (Asset.EType matchableType in MatchableTypes.Where(static matchableType => !Enum.IsDefined(typeof(Asset.EType), matchableType))) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(MatchableTypes), matchableType)); + } + + if (!Enum.IsDefined(typeof(EPersonaState), OnlineStatus)) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(OnlineStatus), OnlineStatus)); + } + + if (!Enum.IsDefined(typeof(ArchiCryptoHelper.ECryptoMethod), PasswordFormat)) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(PasswordFormat), PasswordFormat)); + } + + if (RedeemingPreferences > ERedeemingPreferences.All) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(RedeemingPreferences), RedeemingPreferences)); + } + + if ((SteamMasterClanID != 0) && !new SteamID(SteamMasterClanID).IsClanAccount) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamMasterClanID), SteamMasterClanID)); + } + + if (!string.IsNullOrEmpty(SteamParentalCode) && (SteamParentalCode != "0") && (SteamParentalCode!.Length != SteamParentalCodeLength)) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamParentalCode), SteamParentalCode)); + } + + if (!string.IsNullOrEmpty(SteamTradeToken) && (SteamTradeToken!.Length != SteamTradeTokenLength)) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamTradeToken), SteamTradeToken)); + } + + foreach ((ulong steamID, EAccess permission) in SteamUserPermissions) { + if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamUserPermissions), steamID)); + } + + if (!Enum.IsDefined(typeof(EAccess), permission)) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamUserPermissions), permission)); + } + } + + if (TradingPreferences > ETradingPreferences.All) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(TradingPreferences), TradingPreferences)); + } + + return !Enum.IsDefined(typeof(ArchiHandler.EUserInterfaceMode), UserInterfaceMode) ? (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(UserInterfaceMode), UserInterfaceMode)) : (true, null); + } + + internal static async Task<(BotConfig? BotConfig, string? LatestJson)> Load(string filePath) { + if (string.IsNullOrEmpty(filePath)) { + throw new ArgumentNullException(nameof(filePath)); + } + + if (!File.Exists(filePath)) { + return (null, null); + } + + string json; + BotConfig? botConfig; + + try { + json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); + + if (string.IsNullOrEmpty(json)) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(json))); + + return (null, null); + } + + botConfig = JsonConvert.DeserializeObject(json); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return (null, null); + } + + if (botConfig == null) { + ASF.ArchiLogger.LogNullError(nameof(botConfig)); + + return (null, null); + } + + (bool valid, string? errorMessage) = botConfig.CheckValidation(); + + if (!valid) { + if (!string.IsNullOrEmpty(errorMessage)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + ASF.ArchiLogger.LogGenericError(errorMessage!); + } + + return (null, null); + } + + if (!string.IsNullOrEmpty(botConfig.DecryptedSteamPassword)) { + HashSet disallowedValues = new(StringComparer.InvariantCultureIgnoreCase) { "account" }; + + if (!string.IsNullOrEmpty(botConfig.SteamLogin)) { + disallowedValues.Add(botConfig.SteamLogin!); + } + + Utilities.InBackground( + () => { + (bool isWeak, string? reason) = Utilities.TestPasswordStrength(botConfig.DecryptedSteamPassword!, disallowedValues); + + if (isWeak) { + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningWeakSteamPassword, !string.IsNullOrEmpty(botConfig.SteamLogin) ? botConfig.SteamLogin! : filePath, reason)); + } + } + ); + } + + switch (botConfig.PasswordFormat) { + case ArchiCryptoHelper.ECryptoMethod.AES when ArchiCryptoHelper.HasDefaultCryptKey: + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningDefaultCryptKeyUsedForEncryption, botConfig.PasswordFormat, nameof(SteamPassword))); + + break; + case ArchiCryptoHelper.ECryptoMethod.ProtectedDataForCurrentUser when ArchiCryptoHelper.HasDefaultCryptKey: + ASF.ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.WarningDefaultCryptKeyUsedForHashing, botConfig.PasswordFormat, nameof(SteamPassword))); + + break; + } + + if (!Program.ConfigMigrate) { + return (botConfig, null); + } + + botConfig.Saving = true; + string latestJson = JsonConvert.SerializeObject(botConfig, Formatting.Indented); + botConfig.Saving = false; + + return (botConfig, json != latestJson ? latestJson : null); + } + + public enum EAccess : byte { + None, + FamilySharing, + Operator, + Master + } + + [Flags] + public enum EBotBehaviour : byte { + None = 0, + RejectInvalidFriendInvites = 1, + RejectInvalidTrades = 2, + RejectInvalidGroupInvites = 4, + DismissInventoryNotifications = 8, + MarkReceivedMessagesAsRead = 16, + MarkBotMessagesAsRead = 32, + All = RejectInvalidFriendInvites | RejectInvalidTrades | RejectInvalidGroupInvites | DismissInventoryNotifications | MarkReceivedMessagesAsRead | MarkBotMessagesAsRead + } + + public enum EFarmingOrder : byte { + Unordered, + AppIDsAscending, + AppIDsDescending, + CardDropsAscending, + CardDropsDescending, + HoursAscending, + HoursDescending, + NamesAscending, + NamesDescending, + Random, + BadgeLevelsAscending, + BadgeLevelsDescending, + RedeemDateTimesAscending, + RedeemDateTimesDescending, + MarketableAscending, + MarketableDescending + } + + [Flags] + public enum ERedeemingPreferences : byte { + None = 0, + Forwarding = 1, + Distributing = 2, + KeepMissingGames = 4, + AssumeWalletKeyOnBadActivationCode = 8, + All = Forwarding | Distributing | KeepMissingGames | AssumeWalletKeyOnBadActivationCode + } + + [Flags] + public enum ETradingPreferences : byte { + None = 0, + AcceptDonations = 1, + SteamTradeMatcher = 2, + MatchEverything = 4, + DontAcceptBotTrades = 8, + MatchActively = 16, + All = AcceptDonations | SteamTradeMatcher | MatchEverything | DontAcceptBotTrades | MatchActively + } } diff --git a/ArchiSteamFarm/Steam/Storage/BotDatabase.cs b/ArchiSteamFarm/Steam/Storage/BotDatabase.cs index 237140551..50232cef5 100644 --- a/ArchiSteamFarm/Steam/Storage/BotDatabase.cs +++ b/ArchiSteamFarm/Steam/Storage/BotDatabase.cs @@ -34,207 +34,207 @@ using ArchiSteamFarm.Steam.Security; using JetBrains.Annotations; using Newtonsoft.Json; -namespace ArchiSteamFarm.Steam.Storage { - internal sealed class BotDatabase : SerializableFile { - [JsonProperty(Required = Required.DisallowNull)] - internal readonly ConcurrentHashSet BlacklistedFromTradesSteamIDs = new(); +namespace ArchiSteamFarm.Steam.Storage; - [JsonProperty(Required = Required.DisallowNull)] - internal readonly ConcurrentHashSet IdlingBlacklistedAppIDs = new(); +internal sealed class BotDatabase : SerializableFile { + [JsonProperty(Required = Required.DisallowNull)] + internal readonly ConcurrentHashSet BlacklistedFromTradesSteamIDs = new(); - [JsonProperty(Required = Required.DisallowNull)] - internal readonly ConcurrentHashSet IdlingPriorityAppIDs = new(); + [JsonProperty(Required = Required.DisallowNull)] + internal readonly ConcurrentHashSet IdlingBlacklistedAppIDs = new(); - [JsonProperty(Required = Required.DisallowNull)] - internal readonly ConcurrentHashSet MatchActivelyBlacklistedAppIDs = new(); + [JsonProperty(Required = Required.DisallowNull)] + internal readonly ConcurrentHashSet IdlingPriorityAppIDs = new(); - internal uint GamesToRedeemInBackgroundCount { - get { - lock (GamesToRedeemInBackground) { - return (uint) GamesToRedeemInBackground.Count; - } - } - } - - internal bool HasGamesToRedeemInBackground => GamesToRedeemInBackgroundCount > 0; - - [JsonProperty(Required = Required.DisallowNull)] - private readonly OrderedDictionary GamesToRedeemInBackground = new(); - - internal string? LoginKey { - get => BackingLoginKey; - - set { - if (BackingLoginKey == value) { - return; - } - - BackingLoginKey = value; - Utilities.InBackground(Save); - } - } - - internal MobileAuthenticator? MobileAuthenticator { - get => BackingMobileAuthenticator; - - set { - if (BackingMobileAuthenticator == value) { - return; - } - - BackingMobileAuthenticator = value; - Utilities.InBackground(Save); - } - } - - [JsonProperty(PropertyName = "_" + nameof(LoginKey))] - private string? BackingLoginKey; - - [JsonProperty(PropertyName = "_" + nameof(MobileAuthenticator))] - private MobileAuthenticator? BackingMobileAuthenticator; - - private BotDatabase(string filePath) { - if (string.IsNullOrEmpty(filePath)) { - throw new ArgumentNullException(nameof(filePath)); - } - - FilePath = filePath; - } - - [JsonConstructor] - private BotDatabase() { - BlacklistedFromTradesSteamIDs.OnModified += OnObjectModified; - IdlingBlacklistedAppIDs.OnModified += OnObjectModified; - IdlingPriorityAppIDs.OnModified += OnObjectModified; - MatchActivelyBlacklistedAppIDs.OnModified += OnObjectModified; - } - - [UsedImplicitly] - public bool ShouldSerializeBackingLoginKey() => !string.IsNullOrEmpty(BackingLoginKey); - - [UsedImplicitly] - public bool ShouldSerializeBackingMobileAuthenticator() => BackingMobileAuthenticator != null; - - [UsedImplicitly] - public bool ShouldSerializeBlacklistedFromTradesSteamIDs() => BlacklistedFromTradesSteamIDs.Count > 0; - - [UsedImplicitly] - public bool ShouldSerializeGamesToRedeemInBackground() => HasGamesToRedeemInBackground; - - [UsedImplicitly] - public bool ShouldSerializeIdlingBlacklistedAppIDs() => IdlingBlacklistedAppIDs.Count > 0; - - [UsedImplicitly] - public bool ShouldSerializeIdlingPriorityAppIDs() => IdlingPriorityAppIDs.Count > 0; - - [UsedImplicitly] - public bool ShouldSerializeMatchActivelyBlacklistedAppIDs() => MatchActivelyBlacklistedAppIDs.Count > 0; - - protected override void Dispose(bool disposing) { - if (disposing) { - // Events we registered - BlacklistedFromTradesSteamIDs.OnModified -= OnObjectModified; - IdlingBlacklistedAppIDs.OnModified -= OnObjectModified; - IdlingPriorityAppIDs.OnModified -= OnObjectModified; - MatchActivelyBlacklistedAppIDs.OnModified -= OnObjectModified; - - // Those are objects that might be null and the check should be in-place - BackingMobileAuthenticator?.Dispose(); - } - - // Base dispose - base.Dispose(disposing); - } - - internal void AddGamesToRedeemInBackground(IOrderedDictionary games) { - if ((games == null) || (games.Count == 0)) { - throw new ArgumentNullException(nameof(games)); - } - - bool save = false; + [JsonProperty(Required = Required.DisallowNull)] + internal readonly ConcurrentHashSet MatchActivelyBlacklistedAppIDs = new(); + internal uint GamesToRedeemInBackgroundCount { + get { lock (GamesToRedeemInBackground) { - foreach (DictionaryEntry game in games.OfType().Where(game => !GamesToRedeemInBackground.Contains(game.Key))) { - GamesToRedeemInBackground.Add(game.Key, game.Value); - save = true; - } - } - - if (save) { - Utilities.InBackground(Save); + return (uint) GamesToRedeemInBackground.Count; } } + } - internal static async Task CreateOrLoad(string filePath) { - if (string.IsNullOrEmpty(filePath)) { - throw new ArgumentNullException(nameof(filePath)); - } + internal bool HasGamesToRedeemInBackground => GamesToRedeemInBackgroundCount > 0; - if (!File.Exists(filePath)) { - return new BotDatabase(filePath); - } + [JsonProperty(Required = Required.DisallowNull)] + private readonly OrderedDictionary GamesToRedeemInBackground = new(); - BotDatabase? botDatabase; + internal string? LoginKey { + get => BackingLoginKey; - 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 null; - } - - botDatabase = JsonConvert.DeserializeObject(json); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return null; - } - - if (botDatabase == null) { - ASF.ArchiLogger.LogNullError(nameof(botDatabase)); - - return null; - } - - botDatabase.FilePath = filePath; - - return botDatabase; - } - - internal (string? Key, string? Name) GetGameToRedeemInBackground() { - lock (GamesToRedeemInBackground) { - foreach (DictionaryEntry game in GamesToRedeemInBackground) { - return (game.Key as string, game.Value as string); - } - } - - return (null, null); - } - - internal void RemoveGameToRedeemInBackground(string key) { - if (string.IsNullOrEmpty(key)) { - throw new ArgumentNullException(nameof(key)); - } - - lock (GamesToRedeemInBackground) { - if (!GamesToRedeemInBackground.Contains(key)) { - return; - } - - GamesToRedeemInBackground.Remove(key); - } - - Utilities.InBackground(Save); - } - - private async void OnObjectModified(object? sender, EventArgs e) { - if (string.IsNullOrEmpty(FilePath)) { + set { + if (BackingLoginKey == value) { return; } - await Save().ConfigureAwait(false); + BackingLoginKey = value; + Utilities.InBackground(Save); } } + + internal MobileAuthenticator? MobileAuthenticator { + get => BackingMobileAuthenticator; + + set { + if (BackingMobileAuthenticator == value) { + return; + } + + BackingMobileAuthenticator = value; + Utilities.InBackground(Save); + } + } + + [JsonProperty(PropertyName = "_" + nameof(LoginKey))] + private string? BackingLoginKey; + + [JsonProperty(PropertyName = "_" + nameof(MobileAuthenticator))] + private MobileAuthenticator? BackingMobileAuthenticator; + + private BotDatabase(string filePath) { + if (string.IsNullOrEmpty(filePath)) { + throw new ArgumentNullException(nameof(filePath)); + } + + FilePath = filePath; + } + + [JsonConstructor] + private BotDatabase() { + BlacklistedFromTradesSteamIDs.OnModified += OnObjectModified; + IdlingBlacklistedAppIDs.OnModified += OnObjectModified; + IdlingPriorityAppIDs.OnModified += OnObjectModified; + MatchActivelyBlacklistedAppIDs.OnModified += OnObjectModified; + } + + [UsedImplicitly] + public bool ShouldSerializeBackingLoginKey() => !string.IsNullOrEmpty(BackingLoginKey); + + [UsedImplicitly] + public bool ShouldSerializeBackingMobileAuthenticator() => BackingMobileAuthenticator != null; + + [UsedImplicitly] + public bool ShouldSerializeBlacklistedFromTradesSteamIDs() => BlacklistedFromTradesSteamIDs.Count > 0; + + [UsedImplicitly] + public bool ShouldSerializeGamesToRedeemInBackground() => HasGamesToRedeemInBackground; + + [UsedImplicitly] + public bool ShouldSerializeIdlingBlacklistedAppIDs() => IdlingBlacklistedAppIDs.Count > 0; + + [UsedImplicitly] + public bool ShouldSerializeIdlingPriorityAppIDs() => IdlingPriorityAppIDs.Count > 0; + + [UsedImplicitly] + public bool ShouldSerializeMatchActivelyBlacklistedAppIDs() => MatchActivelyBlacklistedAppIDs.Count > 0; + + protected override void Dispose(bool disposing) { + if (disposing) { + // Events we registered + BlacklistedFromTradesSteamIDs.OnModified -= OnObjectModified; + IdlingBlacklistedAppIDs.OnModified -= OnObjectModified; + IdlingPriorityAppIDs.OnModified -= OnObjectModified; + MatchActivelyBlacklistedAppIDs.OnModified -= OnObjectModified; + + // Those are objects that might be null and the check should be in-place + BackingMobileAuthenticator?.Dispose(); + } + + // Base dispose + base.Dispose(disposing); + } + + internal void AddGamesToRedeemInBackground(IOrderedDictionary games) { + if ((games == null) || (games.Count == 0)) { + throw new ArgumentNullException(nameof(games)); + } + + bool save = false; + + lock (GamesToRedeemInBackground) { + foreach (DictionaryEntry game in games.OfType().Where(game => !GamesToRedeemInBackground.Contains(game.Key))) { + GamesToRedeemInBackground.Add(game.Key, game.Value); + save = true; + } + } + + if (save) { + Utilities.InBackground(Save); + } + } + + internal static async Task CreateOrLoad(string filePath) { + if (string.IsNullOrEmpty(filePath)) { + throw new ArgumentNullException(nameof(filePath)); + } + + if (!File.Exists(filePath)) { + return new BotDatabase(filePath); + } + + BotDatabase? botDatabase; + + 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 null; + } + + botDatabase = JsonConvert.DeserializeObject(json); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return null; + } + + if (botDatabase == null) { + ASF.ArchiLogger.LogNullError(nameof(botDatabase)); + + return null; + } + + botDatabase.FilePath = filePath; + + return botDatabase; + } + + internal (string? Key, string? Name) GetGameToRedeemInBackground() { + lock (GamesToRedeemInBackground) { + foreach (DictionaryEntry game in GamesToRedeemInBackground) { + return (game.Key as string, game.Value as string); + } + } + + return (null, null); + } + + internal void RemoveGameToRedeemInBackground(string key) { + if (string.IsNullOrEmpty(key)) { + throw new ArgumentNullException(nameof(key)); + } + + lock (GamesToRedeemInBackground) { + if (!GamesToRedeemInBackground.Contains(key)) { + return; + } + + GamesToRedeemInBackground.Remove(key); + } + + Utilities.InBackground(Save); + } + + private async void OnObjectModified(object? sender, EventArgs e) { + if (string.IsNullOrEmpty(FilePath)) { + return; + } + + await Save().ConfigureAwait(false); + } } diff --git a/ArchiSteamFarm/Storage/GlobalConfig.cs b/ArchiSteamFarm/Storage/GlobalConfig.cs index f256e078b..c3c6d9afd 100644 --- a/ArchiSteamFarm/Storage/GlobalConfig.cs +++ b/ArchiSteamFarm/Storage/GlobalConfig.cs @@ -38,528 +38,528 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using SteamKit2; -namespace ArchiSteamFarm.Storage { - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - public sealed class GlobalConfig { - [PublicAPI] - public const bool DefaultAutoRestart = true; +namespace ArchiSteamFarm.Storage; - [PublicAPI] - public const string? DefaultCommandPrefix = "!"; +[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] +public sealed class GlobalConfig { + [PublicAPI] + public const bool DefaultAutoRestart = true; - [PublicAPI] - public const byte DefaultConfirmationsLimiterDelay = 10; + [PublicAPI] + public const string? DefaultCommandPrefix = "!"; - [PublicAPI] - public const byte DefaultConnectionTimeout = 90; + [PublicAPI] + public const byte DefaultConfirmationsLimiterDelay = 10; - [PublicAPI] - public const string? DefaultCurrentCulture = null; + [PublicAPI] + public const byte DefaultConnectionTimeout = 90; - [PublicAPI] - public const bool DefaultDebug = false; + [PublicAPI] + public const string? DefaultCurrentCulture = null; - [PublicAPI] - public const byte DefaultFarmingDelay = 15; + [PublicAPI] + public const bool DefaultDebug = false; - [PublicAPI] - public const byte DefaultGiftsLimiterDelay = 1; + [PublicAPI] + public const byte DefaultFarmingDelay = 15; - [PublicAPI] - public const bool DefaultHeadless = false; + [PublicAPI] + public const byte DefaultGiftsLimiterDelay = 1; - [PublicAPI] - public const byte DefaultIdleFarmingPeriod = 8; + [PublicAPI] + public const bool DefaultHeadless = false; - [PublicAPI] - public const byte DefaultInventoryLimiterDelay = 3; + [PublicAPI] + public const byte DefaultIdleFarmingPeriod = 8; - [PublicAPI] - public const bool DefaultIPC = true; + [PublicAPI] + public const byte DefaultInventoryLimiterDelay = 3; - [PublicAPI] - public const string? DefaultIPCPassword = null; + [PublicAPI] + public const bool DefaultIPC = true; - [PublicAPI] - public const ArchiCryptoHelper.EHashingMethod DefaultIPCPasswordFormat = ArchiCryptoHelper.EHashingMethod.PlainText; + [PublicAPI] + public const string? DefaultIPCPassword = null; - [PublicAPI] - public const byte DefaultLoginLimiterDelay = 10; + [PublicAPI] + public const ArchiCryptoHelper.EHashingMethod DefaultIPCPasswordFormat = ArchiCryptoHelper.EHashingMethod.PlainText; - [PublicAPI] - public const byte DefaultMaxFarmingTime = 10; + [PublicAPI] + public const byte DefaultLoginLimiterDelay = 10; - [PublicAPI] - public const byte DefaultMaxTradeHoldDuration = 15; + [PublicAPI] + public const byte DefaultMaxFarmingTime = 10; - [PublicAPI] - public const EOptimizationMode DefaultOptimizationMode = EOptimizationMode.MaxPerformance; + [PublicAPI] + public const byte DefaultMaxTradeHoldDuration = 15; - [PublicAPI] - public const bool DefaultStatistics = true; + [PublicAPI] + public const EOptimizationMode DefaultOptimizationMode = EOptimizationMode.MaxPerformance; - [PublicAPI] - public const string? DefaultSteamMessagePrefix = "/me "; + [PublicAPI] + public const bool DefaultStatistics = true; - [PublicAPI] - public const ulong DefaultSteamOwnerID = 0; + [PublicAPI] + public const string? DefaultSteamMessagePrefix = "/me "; - [PublicAPI] - public const ProtocolTypes DefaultSteamProtocols = ProtocolTypes.All; + [PublicAPI] + public const ulong DefaultSteamOwnerID = 0; - [PublicAPI] - public const EUpdateChannel DefaultUpdateChannel = EUpdateChannel.Stable; + [PublicAPI] + public const ProtocolTypes DefaultSteamProtocols = ProtocolTypes.All; - [PublicAPI] - public const byte DefaultUpdatePeriod = 24; + [PublicAPI] + public const EUpdateChannel DefaultUpdateChannel = EUpdateChannel.Stable; - [PublicAPI] - public const ushort DefaultWebLimiterDelay = 300; + [PublicAPI] + public const byte DefaultUpdatePeriod = 24; - [PublicAPI] - public const string? DefaultWebProxyPassword = null; + [PublicAPI] + public const ushort DefaultWebLimiterDelay = 300; - [PublicAPI] - public const string? DefaultWebProxyText = null; + [PublicAPI] + public const string? DefaultWebProxyPassword = null; - [PublicAPI] - public const string? DefaultWebProxyUsername = null; + [PublicAPI] + public const string? DefaultWebProxyText = null; - [PublicAPI] - public static readonly ImmutableHashSet DefaultBlacklist = ImmutableHashSet.Empty; + [PublicAPI] + public const string? DefaultWebProxyUsername = null; - private static readonly ImmutableHashSet ForbiddenIPCPasswordPhrases = ImmutableHashSet.Create(StringComparer.InvariantCultureIgnoreCase, "ipc", "api", "gui", "asf-ui", "asf-gui"); + [PublicAPI] + public static readonly ImmutableHashSet DefaultBlacklist = ImmutableHashSet.Empty; - [JsonIgnore] - [PublicAPI] - public WebProxy? WebProxy { - get { - if (BackingWebProxy != null) { - return BackingWebProxy; - } + private static readonly ImmutableHashSet ForbiddenIPCPasswordPhrases = ImmutableHashSet.Create(StringComparer.InvariantCultureIgnoreCase, "ipc", "api", "gui", "asf-ui", "asf-gui"); - if (string.IsNullOrEmpty(WebProxyText)) { - return null; - } - - Uri uri; - - try { - uri = new Uri(WebProxyText!); - } catch (UriFormatException e) { - ASF.ArchiLogger.LogGenericException(e); - - return null; - } - - WebProxy proxy = new() { - Address = uri, - BypassProxyOnLocal = true - }; - - if (!string.IsNullOrEmpty(WebProxyUsername) || !string.IsNullOrEmpty(WebProxyPassword)) { - NetworkCredential credentials = new(); - - if (!string.IsNullOrEmpty(WebProxyUsername)) { - credentials.UserName = WebProxyUsername; - } - - if (!string.IsNullOrEmpty(WebProxyPassword)) { - credentials.Password = WebProxyPassword; - } - - proxy.Credentials = credentials; - } - - BackingWebProxy = proxy; - - return proxy; - } - } - - [JsonProperty(Required = Required.DisallowNull)] - public bool AutoRestart { get; private set; } = DefaultAutoRestart; - - [JsonProperty(Required = Required.DisallowNull)] - [SwaggerItemsMinMax(MinimumUint = 1, MaximumUint = uint.MaxValue)] - public ImmutableHashSet Blacklist { get; private set; } = DefaultBlacklist; - - [JsonProperty] - public string? CommandPrefix { get; private set; } = DefaultCommandPrefix; - - [JsonProperty(Required = Required.DisallowNull)] - [Range(byte.MinValue, byte.MaxValue)] - public byte ConfirmationsLimiterDelay { get; private set; } = DefaultConfirmationsLimiterDelay; - - [JsonProperty(Required = Required.DisallowNull)] - [Range(1, byte.MaxValue)] - public byte ConnectionTimeout { get; private set; } = DefaultConnectionTimeout; - - [JsonProperty] - public string? CurrentCulture { get; private set; } = DefaultCurrentCulture; - - [JsonProperty(Required = Required.DisallowNull)] - public bool Debug { get; private set; } = DefaultDebug; - - [JsonProperty(Required = Required.DisallowNull)] - [Range(1, byte.MaxValue)] - public byte FarmingDelay { get; private set; } = DefaultFarmingDelay; - - [JsonProperty(Required = Required.DisallowNull)] - [Range(byte.MinValue, byte.MaxValue)] - public byte GiftsLimiterDelay { get; private set; } = DefaultGiftsLimiterDelay; - - [JsonProperty(Required = Required.DisallowNull)] - public bool Headless { get; private set; } = DefaultHeadless; - - [JsonProperty(Required = Required.DisallowNull)] - [Range(byte.MinValue, byte.MaxValue)] - public byte IdleFarmingPeriod { get; private set; } = DefaultIdleFarmingPeriod; - - [JsonProperty(Required = Required.DisallowNull)] - [Range(byte.MinValue, byte.MaxValue)] - public byte InventoryLimiterDelay { get; private set; } = DefaultInventoryLimiterDelay; - - [JsonProperty(Required = Required.DisallowNull)] - public bool IPC { get; private set; } = DefaultIPC; - - [JsonProperty] - public string? IPCPassword { - get => BackingIPCPassword; - - internal set { - IsIPCPasswordSet = true; - BackingIPCPassword = value; - } - } - - [JsonProperty(Required = Required.DisallowNull)] - public ArchiCryptoHelper.EHashingMethod IPCPasswordFormat { get; private set; } = DefaultIPCPasswordFormat; - - [JsonProperty(Required = Required.DisallowNull)] - [Range(byte.MinValue, byte.MaxValue)] - public byte LoginLimiterDelay { get; private set; } = DefaultLoginLimiterDelay; - - [JsonProperty(Required = Required.DisallowNull)] - [Range(1, byte.MaxValue)] - public byte MaxFarmingTime { get; private set; } = DefaultMaxFarmingTime; - - [JsonProperty(Required = Required.DisallowNull)] - [Range(byte.MinValue, byte.MaxValue)] - public byte MaxTradeHoldDuration { get; private set; } = DefaultMaxTradeHoldDuration; - - [JsonProperty(Required = Required.DisallowNull)] - public EOptimizationMode OptimizationMode { get; private set; } = DefaultOptimizationMode; - - [JsonProperty(Required = Required.DisallowNull)] - public bool Statistics { get; private set; } = DefaultStatistics; - - [JsonProperty] - [MaxLength(SteamChatMessage.MaxMessagePrefixBytes / SteamChatMessage.ReservedEscapeMessageBytes)] - public string? SteamMessagePrefix { get; private set; } = DefaultSteamMessagePrefix; - - [JsonProperty(Required = Required.DisallowNull)] - [SwaggerSteamIdentifier] - [SwaggerValidValues(ValidIntValues = new[] { 0 })] - public ulong SteamOwnerID { get; private set; } = DefaultSteamOwnerID; - - [JsonProperty(Required = Required.DisallowNull)] - public ProtocolTypes SteamProtocols { get; private set; } = DefaultSteamProtocols; - - [JsonProperty(Required = Required.DisallowNull)] - public EUpdateChannel UpdateChannel { get; private set; } = DefaultUpdateChannel; - - [JsonProperty(Required = Required.DisallowNull)] - [Range(byte.MinValue, byte.MaxValue)] - public byte UpdatePeriod { get; private set; } = DefaultUpdatePeriod; - - [JsonProperty(Required = Required.DisallowNull)] - [Range(ushort.MinValue, ushort.MaxValue)] - public ushort WebLimiterDelay { get; private set; } = DefaultWebLimiterDelay; - - [JsonProperty(PropertyName = nameof(WebProxy))] - public string? WebProxyText { get; private set; } = DefaultWebProxyText; - - [JsonProperty] - public string? WebProxyUsername { get; private set; } = DefaultWebProxyUsername; - - [JsonExtensionData] - internal Dictionary? AdditionalProperties { - get; - [UsedImplicitly] - set; - } - - internal bool IsIPCPasswordSet { get; private set; } - internal bool IsWebProxyPasswordSet { get; private set; } - - internal bool Saving { get; set; } - - [JsonProperty] - internal string? WebProxyPassword { - get => BackingWebProxyPassword; - - set { - IsWebProxyPasswordSet = true; - BackingWebProxyPassword = value; - } - } - - private string? BackingIPCPassword = DefaultIPCPassword; - private WebProxy? BackingWebProxy; - private string? BackingWebProxyPassword = DefaultWebProxyPassword; - - [JsonProperty(PropertyName = SharedInfo.UlongCompatibilityStringPrefix + nameof(SteamOwnerID), Required = Required.DisallowNull)] - private string SSteamOwnerID { - get => SteamOwnerID.ToString(CultureInfo.InvariantCulture); - - set { - if (string.IsNullOrEmpty(value) || !ulong.TryParse(value, out ulong result)) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(SSteamOwnerID))); - - return; - } - - SteamOwnerID = result; - } - } - - [JsonConstructor] - internal GlobalConfig() { } - - [UsedImplicitly] - public bool ShouldSerializeAutoRestart() => !Saving || (AutoRestart != DefaultAutoRestart); - - [UsedImplicitly] - public bool ShouldSerializeBlacklist() => !Saving || ((Blacklist != DefaultBlacklist) && !Blacklist.SetEquals(DefaultBlacklist)); - - [UsedImplicitly] - public bool ShouldSerializeCommandPrefix() => !Saving || (CommandPrefix != DefaultCommandPrefix); - - [UsedImplicitly] - public bool ShouldSerializeConfirmationsLimiterDelay() => !Saving || (ConfirmationsLimiterDelay != DefaultConfirmationsLimiterDelay); - - [UsedImplicitly] - public bool ShouldSerializeConnectionTimeout() => !Saving || (ConnectionTimeout != DefaultConnectionTimeout); - - [UsedImplicitly] - public bool ShouldSerializeCurrentCulture() => !Saving || (CurrentCulture != DefaultCurrentCulture); - - [UsedImplicitly] - public bool ShouldSerializeDebug() => !Saving || (Debug != DefaultDebug); - - [UsedImplicitly] - public bool ShouldSerializeFarmingDelay() => !Saving || (FarmingDelay != DefaultFarmingDelay); - - [UsedImplicitly] - public bool ShouldSerializeGiftsLimiterDelay() => !Saving || (GiftsLimiterDelay != DefaultGiftsLimiterDelay); - - [UsedImplicitly] - public bool ShouldSerializeHeadless() => !Saving || (Headless != DefaultHeadless); - - [UsedImplicitly] - public bool ShouldSerializeIdleFarmingPeriod() => !Saving || (IdleFarmingPeriod != DefaultIdleFarmingPeriod); - - [UsedImplicitly] - public bool ShouldSerializeInventoryLimiterDelay() => !Saving || (InventoryLimiterDelay != DefaultInventoryLimiterDelay); - - [UsedImplicitly] - public bool ShouldSerializeIPC() => !Saving || (IPC != DefaultIPC); - - [UsedImplicitly] - public bool ShouldSerializeIPCPassword() => Saving && IsIPCPasswordSet && (IPCPassword != DefaultIPCPassword); - - [UsedImplicitly] - public bool ShouldSerializeIPCPasswordFormat() => !Saving || (IPCPasswordFormat != DefaultIPCPasswordFormat); - - [UsedImplicitly] - public bool ShouldSerializeLoginLimiterDelay() => !Saving || (LoginLimiterDelay != DefaultLoginLimiterDelay); - - [UsedImplicitly] - public bool ShouldSerializeMaxFarmingTime() => !Saving || (MaxFarmingTime != DefaultMaxFarmingTime); - - [UsedImplicitly] - public bool ShouldSerializeMaxTradeHoldDuration() => !Saving || (MaxTradeHoldDuration != DefaultMaxTradeHoldDuration); - - [UsedImplicitly] - public bool ShouldSerializeOptimizationMode() => !Saving || (OptimizationMode != DefaultOptimizationMode); - - [UsedImplicitly] - public bool ShouldSerializeSSteamOwnerID() => !Saving; - - [UsedImplicitly] - public bool ShouldSerializeStatistics() => !Saving || (Statistics != DefaultStatistics); - - [UsedImplicitly] - public bool ShouldSerializeSteamMessagePrefix() => !Saving || (SteamMessagePrefix != DefaultSteamMessagePrefix); - - [UsedImplicitly] - public bool ShouldSerializeSteamOwnerID() => !Saving || (SteamOwnerID != DefaultSteamOwnerID); - - [UsedImplicitly] - public bool ShouldSerializeSteamProtocols() => !Saving || (SteamProtocols != DefaultSteamProtocols); - - [UsedImplicitly] - public bool ShouldSerializeUpdateChannel() => !Saving || (UpdateChannel != DefaultUpdateChannel); - - [UsedImplicitly] - public bool ShouldSerializeUpdatePeriod() => !Saving || (UpdatePeriod != DefaultUpdatePeriod); - - [UsedImplicitly] - public bool ShouldSerializeWebLimiterDelay() => !Saving || (WebLimiterDelay != DefaultWebLimiterDelay); - - [UsedImplicitly] - public bool ShouldSerializeWebProxyPassword() => Saving && IsWebProxyPasswordSet && (WebProxyPassword != DefaultWebProxyPassword); - - [UsedImplicitly] - public bool ShouldSerializeWebProxyText() => !Saving || (WebProxyText != DefaultWebProxyText); - - [UsedImplicitly] - public bool ShouldSerializeWebProxyUsername() => !Saving || (WebProxyUsername != DefaultWebProxyUsername); - - internal (bool Valid, string? ErrorMessage) CheckValidation() { - if (Blacklist.Contains(0)) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(Blacklist), 0)); + [JsonIgnore] + [PublicAPI] + public WebProxy? WebProxy { + get { + if (BackingWebProxy != null) { + return BackingWebProxy; } - if (ConnectionTimeout == 0) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(ConnectionTimeout), ConnectionTimeout)); + if (string.IsNullOrEmpty(WebProxyText)) { + return null; } - if (FarmingDelay == 0) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(FarmingDelay), FarmingDelay)); - } - - if (!Enum.IsDefined(typeof(ArchiCryptoHelper.EHashingMethod), IPCPasswordFormat)) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(IPCPasswordFormat), IPCPasswordFormat)); - } - - if (MaxFarmingTime == 0) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(MaxFarmingTime), MaxFarmingTime)); - } - - if (!Enum.IsDefined(typeof(EOptimizationMode), OptimizationMode)) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(OptimizationMode), OptimizationMode)); - } - - if (!string.IsNullOrEmpty(SteamMessagePrefix) && !SteamChatMessage.IsValidPrefix(SteamMessagePrefix!)) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamMessagePrefix), SteamMessagePrefix)); - } - - if ((SteamOwnerID != 0) && !new SteamID(SteamOwnerID).IsIndividualAccount) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamOwnerID), SteamOwnerID)); - } - - if (SteamProtocols is <= 0 or > ProtocolTypes.All) { - return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamProtocols), SteamProtocols)); - } - - return Enum.IsDefined(typeof(EUpdateChannel), UpdateChannel) ? (true, null) : (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(UpdateChannel), UpdateChannel)); - } - - internal static async Task<(GlobalConfig? GlobalConfig, string? LatestJson)> Load(string filePath) { - if (string.IsNullOrEmpty(filePath)) { - throw new ArgumentNullException(nameof(filePath)); - } - - if (!File.Exists(filePath)) { - return (null, null); - } - - string json; - GlobalConfig? globalConfig; + Uri uri; try { - json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); - - if (string.IsNullOrEmpty(json)) { - ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(json))); - - return (null, null); - } - - globalConfig = JsonConvert.DeserializeObject(json); - } catch (Exception e) { + uri = new Uri(WebProxyText!); + } catch (UriFormatException e) { ASF.ArchiLogger.LogGenericException(e); - return (null, null); + return null; } - if (globalConfig == null) { - ASF.ArchiLogger.LogNullError(nameof(globalConfig)); + WebProxy proxy = new() { + Address = uri, + BypassProxyOnLocal = true + }; - return (null, null); - } + if (!string.IsNullOrEmpty(WebProxyUsername) || !string.IsNullOrEmpty(WebProxyPassword)) { + NetworkCredential credentials = new(); - (bool valid, string? errorMessage) = globalConfig.CheckValidation(); - - if (!valid) { - if (!string.IsNullOrEmpty(errorMessage)) { - // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework - ASF.ArchiLogger.LogGenericError(errorMessage!); + if (!string.IsNullOrEmpty(WebProxyUsername)) { + credentials.UserName = WebProxyUsername; } - return (null, null); - } - - if (globalConfig.IPC) { - switch (globalConfig.IPCPasswordFormat) { - case ArchiCryptoHelper.EHashingMethod.PlainText when !string.IsNullOrEmpty(globalConfig.IPCPassword): - Utilities.InBackground( - () => { - (bool isWeak, string? reason) = Utilities.TestPasswordStrength(globalConfig.IPCPassword!, ForbiddenIPCPasswordPhrases); - - if (isWeak) { - ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningWeakIPCPassword, reason)); - } - } - ); - - break; - case ArchiCryptoHelper.EHashingMethod.Pbkdf2 when ArchiCryptoHelper.HasDefaultCryptKey: - case ArchiCryptoHelper.EHashingMethod.SCrypt when ArchiCryptoHelper.HasDefaultCryptKey: - ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningDefaultCryptKeyUsedForHashing, globalConfig.IPCPasswordFormat, nameof(IPCPassword))); - - break; + if (!string.IsNullOrEmpty(WebProxyPassword)) { + credentials.Password = WebProxyPassword; } + + proxy.Credentials = credentials; } - if (!Program.ConfigMigrate) { - return (globalConfig, null); - } + BackingWebProxy = proxy; - globalConfig.Saving = true; - string latestJson = JsonConvert.SerializeObject(globalConfig, Formatting.Indented); - globalConfig.Saving = false; - - return (globalConfig, json != latestJson ? latestJson : null); - } - - internal static async Task Write(string filePath, GlobalConfig globalConfig) { - if (string.IsNullOrEmpty(filePath)) { - throw new ArgumentNullException(nameof(filePath)); - } - - if (globalConfig == null) { - throw new ArgumentNullException(nameof(globalConfig)); - } - - string json = JsonConvert.SerializeObject(globalConfig, Formatting.Indented); - - return await SerializableFile.Write(filePath, json).ConfigureAwait(false); - } - - [PublicAPI] - public enum EOptimizationMode : byte { - MaxPerformance, - MinMemoryUsage - } - - [PublicAPI] - public enum EUpdateChannel : byte { - None, - Stable, - Experimental + return proxy; } } + + [JsonProperty(Required = Required.DisallowNull)] + public bool AutoRestart { get; private set; } = DefaultAutoRestart; + + [JsonProperty(Required = Required.DisallowNull)] + [SwaggerItemsMinMax(MinimumUint = 1, MaximumUint = uint.MaxValue)] + public ImmutableHashSet Blacklist { get; private set; } = DefaultBlacklist; + + [JsonProperty] + public string? CommandPrefix { get; private set; } = DefaultCommandPrefix; + + [JsonProperty(Required = Required.DisallowNull)] + [Range(byte.MinValue, byte.MaxValue)] + public byte ConfirmationsLimiterDelay { get; private set; } = DefaultConfirmationsLimiterDelay; + + [JsonProperty(Required = Required.DisallowNull)] + [Range(1, byte.MaxValue)] + public byte ConnectionTimeout { get; private set; } = DefaultConnectionTimeout; + + [JsonProperty] + public string? CurrentCulture { get; private set; } = DefaultCurrentCulture; + + [JsonProperty(Required = Required.DisallowNull)] + public bool Debug { get; private set; } = DefaultDebug; + + [JsonProperty(Required = Required.DisallowNull)] + [Range(1, byte.MaxValue)] + public byte FarmingDelay { get; private set; } = DefaultFarmingDelay; + + [JsonProperty(Required = Required.DisallowNull)] + [Range(byte.MinValue, byte.MaxValue)] + public byte GiftsLimiterDelay { get; private set; } = DefaultGiftsLimiterDelay; + + [JsonProperty(Required = Required.DisallowNull)] + public bool Headless { get; private set; } = DefaultHeadless; + + [JsonProperty(Required = Required.DisallowNull)] + [Range(byte.MinValue, byte.MaxValue)] + public byte IdleFarmingPeriod { get; private set; } = DefaultIdleFarmingPeriod; + + [JsonProperty(Required = Required.DisallowNull)] + [Range(byte.MinValue, byte.MaxValue)] + public byte InventoryLimiterDelay { get; private set; } = DefaultInventoryLimiterDelay; + + [JsonProperty(Required = Required.DisallowNull)] + public bool IPC { get; private set; } = DefaultIPC; + + [JsonProperty] + public string? IPCPassword { + get => BackingIPCPassword; + + internal set { + IsIPCPasswordSet = true; + BackingIPCPassword = value; + } + } + + [JsonProperty(Required = Required.DisallowNull)] + public ArchiCryptoHelper.EHashingMethod IPCPasswordFormat { get; private set; } = DefaultIPCPasswordFormat; + + [JsonProperty(Required = Required.DisallowNull)] + [Range(byte.MinValue, byte.MaxValue)] + public byte LoginLimiterDelay { get; private set; } = DefaultLoginLimiterDelay; + + [JsonProperty(Required = Required.DisallowNull)] + [Range(1, byte.MaxValue)] + public byte MaxFarmingTime { get; private set; } = DefaultMaxFarmingTime; + + [JsonProperty(Required = Required.DisallowNull)] + [Range(byte.MinValue, byte.MaxValue)] + public byte MaxTradeHoldDuration { get; private set; } = DefaultMaxTradeHoldDuration; + + [JsonProperty(Required = Required.DisallowNull)] + public EOptimizationMode OptimizationMode { get; private set; } = DefaultOptimizationMode; + + [JsonProperty(Required = Required.DisallowNull)] + public bool Statistics { get; private set; } = DefaultStatistics; + + [JsonProperty] + [MaxLength(SteamChatMessage.MaxMessagePrefixBytes / SteamChatMessage.ReservedEscapeMessageBytes)] + public string? SteamMessagePrefix { get; private set; } = DefaultSteamMessagePrefix; + + [JsonProperty(Required = Required.DisallowNull)] + [SwaggerSteamIdentifier] + [SwaggerValidValues(ValidIntValues = new[] { 0 })] + public ulong SteamOwnerID { get; private set; } = DefaultSteamOwnerID; + + [JsonProperty(Required = Required.DisallowNull)] + public ProtocolTypes SteamProtocols { get; private set; } = DefaultSteamProtocols; + + [JsonProperty(Required = Required.DisallowNull)] + public EUpdateChannel UpdateChannel { get; private set; } = DefaultUpdateChannel; + + [JsonProperty(Required = Required.DisallowNull)] + [Range(byte.MinValue, byte.MaxValue)] + public byte UpdatePeriod { get; private set; } = DefaultUpdatePeriod; + + [JsonProperty(Required = Required.DisallowNull)] + [Range(ushort.MinValue, ushort.MaxValue)] + public ushort WebLimiterDelay { get; private set; } = DefaultWebLimiterDelay; + + [JsonProperty(PropertyName = nameof(WebProxy))] + public string? WebProxyText { get; private set; } = DefaultWebProxyText; + + [JsonProperty] + public string? WebProxyUsername { get; private set; } = DefaultWebProxyUsername; + + [JsonExtensionData] + internal Dictionary? AdditionalProperties { + get; + [UsedImplicitly] + set; + } + + internal bool IsIPCPasswordSet { get; private set; } + internal bool IsWebProxyPasswordSet { get; private set; } + + internal bool Saving { get; set; } + + [JsonProperty] + internal string? WebProxyPassword { + get => BackingWebProxyPassword; + + set { + IsWebProxyPasswordSet = true; + BackingWebProxyPassword = value; + } + } + + private string? BackingIPCPassword = DefaultIPCPassword; + private WebProxy? BackingWebProxy; + private string? BackingWebProxyPassword = DefaultWebProxyPassword; + + [JsonProperty(PropertyName = SharedInfo.UlongCompatibilityStringPrefix + nameof(SteamOwnerID), Required = Required.DisallowNull)] + private string SSteamOwnerID { + get => SteamOwnerID.ToString(CultureInfo.InvariantCulture); + + set { + if (string.IsNullOrEmpty(value) || !ulong.TryParse(value, out ulong result)) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(SSteamOwnerID))); + + return; + } + + SteamOwnerID = result; + } + } + + [JsonConstructor] + internal GlobalConfig() { } + + [UsedImplicitly] + public bool ShouldSerializeAutoRestart() => !Saving || (AutoRestart != DefaultAutoRestart); + + [UsedImplicitly] + public bool ShouldSerializeBlacklist() => !Saving || ((Blacklist != DefaultBlacklist) && !Blacklist.SetEquals(DefaultBlacklist)); + + [UsedImplicitly] + public bool ShouldSerializeCommandPrefix() => !Saving || (CommandPrefix != DefaultCommandPrefix); + + [UsedImplicitly] + public bool ShouldSerializeConfirmationsLimiterDelay() => !Saving || (ConfirmationsLimiterDelay != DefaultConfirmationsLimiterDelay); + + [UsedImplicitly] + public bool ShouldSerializeConnectionTimeout() => !Saving || (ConnectionTimeout != DefaultConnectionTimeout); + + [UsedImplicitly] + public bool ShouldSerializeCurrentCulture() => !Saving || (CurrentCulture != DefaultCurrentCulture); + + [UsedImplicitly] + public bool ShouldSerializeDebug() => !Saving || (Debug != DefaultDebug); + + [UsedImplicitly] + public bool ShouldSerializeFarmingDelay() => !Saving || (FarmingDelay != DefaultFarmingDelay); + + [UsedImplicitly] + public bool ShouldSerializeGiftsLimiterDelay() => !Saving || (GiftsLimiterDelay != DefaultGiftsLimiterDelay); + + [UsedImplicitly] + public bool ShouldSerializeHeadless() => !Saving || (Headless != DefaultHeadless); + + [UsedImplicitly] + public bool ShouldSerializeIdleFarmingPeriod() => !Saving || (IdleFarmingPeriod != DefaultIdleFarmingPeriod); + + [UsedImplicitly] + public bool ShouldSerializeInventoryLimiterDelay() => !Saving || (InventoryLimiterDelay != DefaultInventoryLimiterDelay); + + [UsedImplicitly] + public bool ShouldSerializeIPC() => !Saving || (IPC != DefaultIPC); + + [UsedImplicitly] + public bool ShouldSerializeIPCPassword() => Saving && IsIPCPasswordSet && (IPCPassword != DefaultIPCPassword); + + [UsedImplicitly] + public bool ShouldSerializeIPCPasswordFormat() => !Saving || (IPCPasswordFormat != DefaultIPCPasswordFormat); + + [UsedImplicitly] + public bool ShouldSerializeLoginLimiterDelay() => !Saving || (LoginLimiterDelay != DefaultLoginLimiterDelay); + + [UsedImplicitly] + public bool ShouldSerializeMaxFarmingTime() => !Saving || (MaxFarmingTime != DefaultMaxFarmingTime); + + [UsedImplicitly] + public bool ShouldSerializeMaxTradeHoldDuration() => !Saving || (MaxTradeHoldDuration != DefaultMaxTradeHoldDuration); + + [UsedImplicitly] + public bool ShouldSerializeOptimizationMode() => !Saving || (OptimizationMode != DefaultOptimizationMode); + + [UsedImplicitly] + public bool ShouldSerializeSSteamOwnerID() => !Saving; + + [UsedImplicitly] + public bool ShouldSerializeStatistics() => !Saving || (Statistics != DefaultStatistics); + + [UsedImplicitly] + public bool ShouldSerializeSteamMessagePrefix() => !Saving || (SteamMessagePrefix != DefaultSteamMessagePrefix); + + [UsedImplicitly] + public bool ShouldSerializeSteamOwnerID() => !Saving || (SteamOwnerID != DefaultSteamOwnerID); + + [UsedImplicitly] + public bool ShouldSerializeSteamProtocols() => !Saving || (SteamProtocols != DefaultSteamProtocols); + + [UsedImplicitly] + public bool ShouldSerializeUpdateChannel() => !Saving || (UpdateChannel != DefaultUpdateChannel); + + [UsedImplicitly] + public bool ShouldSerializeUpdatePeriod() => !Saving || (UpdatePeriod != DefaultUpdatePeriod); + + [UsedImplicitly] + public bool ShouldSerializeWebLimiterDelay() => !Saving || (WebLimiterDelay != DefaultWebLimiterDelay); + + [UsedImplicitly] + public bool ShouldSerializeWebProxyPassword() => Saving && IsWebProxyPasswordSet && (WebProxyPassword != DefaultWebProxyPassword); + + [UsedImplicitly] + public bool ShouldSerializeWebProxyText() => !Saving || (WebProxyText != DefaultWebProxyText); + + [UsedImplicitly] + public bool ShouldSerializeWebProxyUsername() => !Saving || (WebProxyUsername != DefaultWebProxyUsername); + + internal (bool Valid, string? ErrorMessage) CheckValidation() { + if (Blacklist.Contains(0)) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(Blacklist), 0)); + } + + if (ConnectionTimeout == 0) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(ConnectionTimeout), ConnectionTimeout)); + } + + if (FarmingDelay == 0) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(FarmingDelay), FarmingDelay)); + } + + if (!Enum.IsDefined(typeof(ArchiCryptoHelper.EHashingMethod), IPCPasswordFormat)) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(IPCPasswordFormat), IPCPasswordFormat)); + } + + if (MaxFarmingTime == 0) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(MaxFarmingTime), MaxFarmingTime)); + } + + if (!Enum.IsDefined(typeof(EOptimizationMode), OptimizationMode)) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(OptimizationMode), OptimizationMode)); + } + + if (!string.IsNullOrEmpty(SteamMessagePrefix) && !SteamChatMessage.IsValidPrefix(SteamMessagePrefix!)) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamMessagePrefix), SteamMessagePrefix)); + } + + if ((SteamOwnerID != 0) && !new SteamID(SteamOwnerID).IsIndividualAccount) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamOwnerID), SteamOwnerID)); + } + + if (SteamProtocols is <= 0 or > ProtocolTypes.All) { + return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamProtocols), SteamProtocols)); + } + + return Enum.IsDefined(typeof(EUpdateChannel), UpdateChannel) ? (true, null) : (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(UpdateChannel), UpdateChannel)); + } + + internal static async Task<(GlobalConfig? GlobalConfig, string? LatestJson)> Load(string filePath) { + if (string.IsNullOrEmpty(filePath)) { + throw new ArgumentNullException(nameof(filePath)); + } + + if (!File.Exists(filePath)) { + return (null, null); + } + + string json; + GlobalConfig? globalConfig; + + try { + json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); + + if (string.IsNullOrEmpty(json)) { + ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(json))); + + return (null, null); + } + + globalConfig = JsonConvert.DeserializeObject(json); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); + + return (null, null); + } + + if (globalConfig == null) { + ASF.ArchiLogger.LogNullError(nameof(globalConfig)); + + return (null, null); + } + + (bool valid, string? errorMessage) = globalConfig.CheckValidation(); + + if (!valid) { + if (!string.IsNullOrEmpty(errorMessage)) { + // ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework + ASF.ArchiLogger.LogGenericError(errorMessage!); + } + + return (null, null); + } + + if (globalConfig.IPC) { + switch (globalConfig.IPCPasswordFormat) { + case ArchiCryptoHelper.EHashingMethod.PlainText when !string.IsNullOrEmpty(globalConfig.IPCPassword): + Utilities.InBackground( + () => { + (bool isWeak, string? reason) = Utilities.TestPasswordStrength(globalConfig.IPCPassword!, ForbiddenIPCPasswordPhrases); + + if (isWeak) { + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningWeakIPCPassword, reason)); + } + } + ); + + break; + case ArchiCryptoHelper.EHashingMethod.Pbkdf2 when ArchiCryptoHelper.HasDefaultCryptKey: + case ArchiCryptoHelper.EHashingMethod.SCrypt when ArchiCryptoHelper.HasDefaultCryptKey: + ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningDefaultCryptKeyUsedForHashing, globalConfig.IPCPasswordFormat, nameof(IPCPassword))); + + break; + } + } + + if (!Program.ConfigMigrate) { + return (globalConfig, null); + } + + globalConfig.Saving = true; + string latestJson = JsonConvert.SerializeObject(globalConfig, Formatting.Indented); + globalConfig.Saving = false; + + return (globalConfig, json != latestJson ? latestJson : null); + } + + internal static async Task Write(string filePath, GlobalConfig globalConfig) { + if (string.IsNullOrEmpty(filePath)) { + throw new ArgumentNullException(nameof(filePath)); + } + + if (globalConfig == null) { + throw new ArgumentNullException(nameof(globalConfig)); + } + + string json = JsonConvert.SerializeObject(globalConfig, Formatting.Indented); + + return await SerializableFile.Write(filePath, json).ConfigureAwait(false); + } + + [PublicAPI] + public enum EOptimizationMode : byte { + MaxPerformance, + MinMemoryUsage + } + + [PublicAPI] + public enum EUpdateChannel : byte { + None, + Stable, + Experimental + } } diff --git a/ArchiSteamFarm/Storage/GlobalDatabase.cs b/ArchiSteamFarm/Storage/GlobalDatabase.cs index 944d269db..ae8f40c0a 100644 --- a/ArchiSteamFarm/Storage/GlobalDatabase.cs +++ b/ArchiSteamFarm/Storage/GlobalDatabase.cs @@ -37,318 +37,318 @@ using JetBrains.Annotations; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace ArchiSteamFarm.Storage { - public sealed class GlobalDatabase : SerializableFile { - [JsonIgnore] - [PublicAPI] - public IReadOnlyDictionary PackageAccessTokensReadOnly => PackagesAccessTokens; +namespace ArchiSteamFarm.Storage; - [JsonIgnore] - [PublicAPI] - public IReadOnlyDictionary? AppIDs)> PackagesDataReadOnly => PackagesData; +public sealed class GlobalDatabase : SerializableFile { + [JsonIgnore] + [PublicAPI] + public IReadOnlyDictionary PackageAccessTokensReadOnly => PackagesAccessTokens; - [JsonProperty(Required = Required.DisallowNull)] - internal readonly InMemoryServerListProvider ServerListProvider = new(); + [JsonIgnore] + [PublicAPI] + public IReadOnlyDictionary? AppIDs)> PackagesDataReadOnly => PackagesData; - [JsonProperty(Required = Required.DisallowNull)] - private readonly ConcurrentDictionary KeyValueJsonStorage = new(); + [JsonProperty(Required = Required.DisallowNull)] + internal readonly InMemoryServerListProvider ServerListProvider = new(); - [JsonProperty(Required = Required.DisallowNull)] - private readonly ConcurrentDictionary PackagesAccessTokens = new(); + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentDictionary KeyValueJsonStorage = new(); - [JsonProperty(Required = Required.DisallowNull)] - private readonly ConcurrentDictionary? AppIDs)> PackagesData = new(); + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentDictionary PackagesAccessTokens = new(); - private readonly SemaphoreSlim PackagesRefreshSemaphore = new(1, 1); + [JsonProperty(Required = Required.DisallowNull)] + private readonly ConcurrentDictionary? AppIDs)> PackagesData = new(); - [JsonProperty(Required = Required.DisallowNull)] - [PublicAPI] - public Guid Identifier { get; private set; } = Guid.NewGuid(); + private readonly SemaphoreSlim PackagesRefreshSemaphore = new(1, 1); - internal uint CellID { - get => BackingCellID; + [JsonProperty(Required = Required.DisallowNull)] + [PublicAPI] + public Guid Identifier { get; private set; } = Guid.NewGuid(); - set { - if (BackingCellID == value) { - return; - } + internal uint CellID { + get => BackingCellID; - BackingCellID = value; - Utilities.InBackground(Save); - } - } - - internal uint LastChangeNumber { - get => BackingLastChangeNumber; - - set { - if (BackingLastChangeNumber == value) { - return; - } - - BackingLastChangeNumber = value; - Utilities.InBackground(Save); - } - } - - [JsonProperty(PropertyName = "_" + nameof(CellID), Required = Required.DisallowNull)] - private uint BackingCellID; - - [JsonProperty(PropertyName = "_" + nameof(LastChangeNumber), Required = Required.DisallowNull)] - private uint BackingLastChangeNumber; - - private GlobalDatabase(string filePath) : this() { - if (string.IsNullOrEmpty(filePath)) { - throw new ArgumentNullException(nameof(filePath)); - } - - FilePath = filePath; - } - - [JsonConstructor] - private GlobalDatabase() => ServerListProvider.ServerListUpdated += OnObjectModified; - - [PublicAPI] - public void DeleteFromJsonStorage(string key) { - if (string.IsNullOrEmpty(key)) { - throw new ArgumentNullException(nameof(key)); - } - - if (!KeyValueJsonStorage.TryRemove(key, out _)) { + set { + if (BackingCellID == value) { return; } + BackingCellID = value; Utilities.InBackground(Save); } + } - [PublicAPI] - public JToken? LoadFromJsonStorage(string key) { - if (string.IsNullOrEmpty(key)) { - throw new ArgumentNullException(nameof(key)); - } - - return KeyValueJsonStorage.TryGetValue(key, out JToken? value) ? value : null; - } - - [PublicAPI] - public void SaveToJsonStorage(string key, JToken value) { - if (string.IsNullOrEmpty(key)) { - throw new ArgumentNullException(nameof(key)); - } - - if (value == null) { - throw new ArgumentNullException(nameof(value)); - } - - if (value.Type == JTokenType.Null) { - DeleteFromJsonStorage(key); + internal uint LastChangeNumber { + get => BackingLastChangeNumber; + set { + if (BackingLastChangeNumber == value) { return; } - if (KeyValueJsonStorage.TryGetValue(key, out JToken? currentValue) && JToken.DeepEquals(currentValue, value)) { - return; - } - - KeyValueJsonStorage[key] = value; + BackingLastChangeNumber = value; Utilities.InBackground(Save); } + } - [UsedImplicitly] - public bool ShouldSerializeBackingCellID() => BackingCellID != 0; + [JsonProperty(PropertyName = "_" + nameof(CellID), Required = Required.DisallowNull)] + private uint BackingCellID; - [UsedImplicitly] - public bool ShouldSerializeBackingLastChangeNumber() => LastChangeNumber != 0; + [JsonProperty(PropertyName = "_" + nameof(LastChangeNumber), Required = Required.DisallowNull)] + private uint BackingLastChangeNumber; - [UsedImplicitly] - public bool ShouldSerializeKeyValueJsonStorage() => !KeyValueJsonStorage.IsEmpty; - - [UsedImplicitly] - public bool ShouldSerializePackagesAccessTokens() => !PackagesAccessTokens.IsEmpty; - - [UsedImplicitly] - public bool ShouldSerializePackagesData() => !PackagesData.IsEmpty; - - [UsedImplicitly] - public bool ShouldSerializeServerListProvider() => ServerListProvider.ShouldSerializeServerRecords(); - - protected override void Dispose(bool disposing) { - if (disposing) { - // Events we registered - ServerListProvider.ServerListUpdated -= OnObjectModified; - - // Those are objects that are always being created if constructor doesn't throw exception - PackagesRefreshSemaphore.Dispose(); - } - - // Base dispose - base.Dispose(disposing); + private GlobalDatabase(string filePath) : this() { + if (string.IsNullOrEmpty(filePath)) { + throw new ArgumentNullException(nameof(filePath)); } - internal static async Task CreateOrLoad(string filePath) { - if (string.IsNullOrEmpty(filePath)) { - throw new ArgumentNullException(nameof(filePath)); - } + FilePath = filePath; + } - if (!File.Exists(filePath)) { - GlobalDatabase result = new(filePath); + [JsonConstructor] + private GlobalDatabase() => ServerListProvider.ServerListUpdated += OnObjectModified; - Utilities.InBackground(result.Save); - - return result; - } - - GlobalDatabase? globalDatabase; - - 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 null; - } - - globalDatabase = JsonConvert.DeserializeObject(json); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericException(e); - - return null; - } - - if (globalDatabase == null) { - ASF.ArchiLogger.LogNullError(nameof(globalDatabase)); - - return null; - } - - globalDatabase.FilePath = filePath; - - return globalDatabase; + [PublicAPI] + public void DeleteFromJsonStorage(string key) { + if (string.IsNullOrEmpty(key)) { + throw new ArgumentNullException(nameof(key)); } - internal HashSet GetPackageIDs(uint appID, IEnumerable packageIDs) { - if (appID == 0) { - throw new ArgumentOutOfRangeException(nameof(appID)); - } + if (!KeyValueJsonStorage.TryRemove(key, out _)) { + return; + } - if (packageIDs == null) { - throw new ArgumentNullException(nameof(packageIDs)); - } + Utilities.InBackground(Save); + } - HashSet result = new(); + [PublicAPI] + public JToken? LoadFromJsonStorage(string key) { + if (string.IsNullOrEmpty(key)) { + throw new ArgumentNullException(nameof(key)); + } - foreach (uint packageID in packageIDs.Where(static packageID => packageID != 0)) { - if (!PackagesData.TryGetValue(packageID, out (uint ChangeNumber, ImmutableHashSet? AppIDs) packagesData) || (packagesData.AppIDs?.Contains(appID) != true)) { - continue; - } + return KeyValueJsonStorage.TryGetValue(key, out JToken? value) ? value : null; + } - result.Add(packageID); - } + [PublicAPI] + public void SaveToJsonStorage(string key, JToken value) { + if (string.IsNullOrEmpty(key)) { + throw new ArgumentNullException(nameof(key)); + } + + if (value == null) { + throw new ArgumentNullException(nameof(value)); + } + + if (value.Type == JTokenType.Null) { + DeleteFromJsonStorage(key); + + return; + } + + if (KeyValueJsonStorage.TryGetValue(key, out JToken? currentValue) && JToken.DeepEquals(currentValue, value)) { + return; + } + + KeyValueJsonStorage[key] = value; + Utilities.InBackground(Save); + } + + [UsedImplicitly] + public bool ShouldSerializeBackingCellID() => BackingCellID != 0; + + [UsedImplicitly] + public bool ShouldSerializeBackingLastChangeNumber() => LastChangeNumber != 0; + + [UsedImplicitly] + public bool ShouldSerializeKeyValueJsonStorage() => !KeyValueJsonStorage.IsEmpty; + + [UsedImplicitly] + public bool ShouldSerializePackagesAccessTokens() => !PackagesAccessTokens.IsEmpty; + + [UsedImplicitly] + public bool ShouldSerializePackagesData() => !PackagesData.IsEmpty; + + [UsedImplicitly] + public bool ShouldSerializeServerListProvider() => ServerListProvider.ShouldSerializeServerRecords(); + + protected override void Dispose(bool disposing) { + if (disposing) { + // Events we registered + ServerListProvider.ServerListUpdated -= OnObjectModified; + + // Those are objects that are always being created if constructor doesn't throw exception + PackagesRefreshSemaphore.Dispose(); + } + + // Base dispose + base.Dispose(disposing); + } + + internal static async Task CreateOrLoad(string filePath) { + if (string.IsNullOrEmpty(filePath)) { + throw new ArgumentNullException(nameof(filePath)); + } + + if (!File.Exists(filePath)) { + GlobalDatabase result = new(filePath); + + Utilities.InBackground(result.Save); return result; } - internal async Task OnPICSChangesRestart(uint currentChangeNumber) { - if (currentChangeNumber == 0) { - throw new ArgumentOutOfRangeException(nameof(currentChangeNumber)); + GlobalDatabase? globalDatabase; + + 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 null; } - if (Bot.Bots == null) { - throw new InvalidOperationException(nameof(Bot.Bots)); - } + globalDatabase = JsonConvert.DeserializeObject(json); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericException(e); - if (currentChangeNumber <= LastChangeNumber) { - return; - } - - LastChangeNumber = currentChangeNumber; - - Bot? refreshBot = Bot.Bots.Values.FirstOrDefault(static bot => bot.IsConnectedAndLoggedOn); - - if (refreshBot == null) { - return; - } - - if (PackagesData.IsEmpty) { - return; - } - - Dictionary packageIDs = PackagesData.Keys.ToDictionary(static packageID => packageID, _ => currentChangeNumber); - - await RefreshPackages(refreshBot, packageIDs).ConfigureAwait(false); + return null; } - internal void RefreshPackageAccessTokens(IReadOnlyDictionary packageAccessTokens) { - if ((packageAccessTokens == null) || (packageAccessTokens.Count == 0)) { - throw new ArgumentNullException(nameof(packageAccessTokens)); + if (globalDatabase == null) { + ASF.ArchiLogger.LogNullError(nameof(globalDatabase)); + + return null; + } + + globalDatabase.FilePath = filePath; + + return globalDatabase; + } + + internal HashSet GetPackageIDs(uint appID, IEnumerable packageIDs) { + if (appID == 0) { + throw new ArgumentOutOfRangeException(nameof(appID)); + } + + if (packageIDs == null) { + throw new ArgumentNullException(nameof(packageIDs)); + } + + HashSet result = new(); + + foreach (uint packageID in packageIDs.Where(static packageID => packageID != 0)) { + if (!PackagesData.TryGetValue(packageID, out (uint ChangeNumber, ImmutableHashSet? AppIDs) packagesData) || (packagesData.AppIDs?.Contains(appID) != true)) { + continue; + } + + result.Add(packageID); + } + + return result; + } + + internal async Task OnPICSChangesRestart(uint currentChangeNumber) { + if (currentChangeNumber == 0) { + throw new ArgumentOutOfRangeException(nameof(currentChangeNumber)); + } + + if (Bot.Bots == null) { + throw new InvalidOperationException(nameof(Bot.Bots)); + } + + if (currentChangeNumber <= LastChangeNumber) { + return; + } + + LastChangeNumber = currentChangeNumber; + + Bot? refreshBot = Bot.Bots.Values.FirstOrDefault(static bot => bot.IsConnectedAndLoggedOn); + + if (refreshBot == null) { + return; + } + + if (PackagesData.IsEmpty) { + return; + } + + Dictionary packageIDs = PackagesData.Keys.ToDictionary(static packageID => packageID, _ => currentChangeNumber); + + await RefreshPackages(refreshBot, packageIDs).ConfigureAwait(false); + } + + internal void RefreshPackageAccessTokens(IReadOnlyDictionary packageAccessTokens) { + if ((packageAccessTokens == null) || (packageAccessTokens.Count == 0)) { + throw new ArgumentNullException(nameof(packageAccessTokens)); + } + + bool save = false; + + foreach ((uint packageID, ulong currentAccessToken) in packageAccessTokens) { + if (!PackagesAccessTokens.TryGetValue(packageID, out ulong previousAccessToken) || (previousAccessToken != currentAccessToken)) { + PackagesAccessTokens[packageID] = currentAccessToken; + save = true; + } + } + + if (save) { + Utilities.InBackground(Save); + } + } + + internal async Task RefreshPackages(Bot bot, IReadOnlyDictionary packages) { + if (bot == null) { + throw new ArgumentNullException(nameof(bot)); + } + + if ((packages == null) || (packages.Count == 0)) { + throw new ArgumentNullException(nameof(packages)); + } + + await PackagesRefreshSemaphore.WaitAsync().ConfigureAwait(false); + + try { + HashSet packageIDs = packages.Where(package => (package.Key != 0) && (!PackagesData.TryGetValue(package.Key, out (uint ChangeNumber, ImmutableHashSet? AppIDs) previousData) || (previousData.ChangeNumber < package.Value))).Select(static package => package.Key).ToHashSet(); + + if (packageIDs.Count == 0) { + return; + } + + Dictionary? AppIDs)>? packagesData = await bot.GetPackagesData(packageIDs).ConfigureAwait(false); + + if (packagesData == null) { + bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); + + return; } bool save = false; - foreach ((uint packageID, ulong currentAccessToken) in packageAccessTokens) { - if (!PackagesAccessTokens.TryGetValue(packageID, out ulong previousAccessToken) || (previousAccessToken != currentAccessToken)) { - PackagesAccessTokens[packageID] = currentAccessToken; - save = true; + foreach ((uint packageID, (uint ChangeNumber, ImmutableHashSet? AppIDs) packageData) in packagesData) { + if (PackagesData.TryGetValue(packageID, out (uint ChangeNumber, ImmutableHashSet? AppIDs) previousData) && (packageData.ChangeNumber <= previousData.ChangeNumber)) { + continue; } + + PackagesData[packageID] = packageData; + save = true; } if (save) { Utilities.InBackground(Save); } - } - - internal async Task RefreshPackages(Bot bot, IReadOnlyDictionary packages) { - if (bot == null) { - throw new ArgumentNullException(nameof(bot)); - } - - if ((packages == null) || (packages.Count == 0)) { - throw new ArgumentNullException(nameof(packages)); - } - - await PackagesRefreshSemaphore.WaitAsync().ConfigureAwait(false); - - try { - HashSet packageIDs = packages.Where(package => (package.Key != 0) && (!PackagesData.TryGetValue(package.Key, out (uint ChangeNumber, ImmutableHashSet? AppIDs) previousData) || (previousData.ChangeNumber < package.Value))).Select(static package => package.Key).ToHashSet(); - - if (packageIDs.Count == 0) { - return; - } - - Dictionary? AppIDs)>? packagesData = await bot.GetPackagesData(packageIDs).ConfigureAwait(false); - - if (packagesData == null) { - bot.ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return; - } - - bool save = false; - - foreach ((uint packageID, (uint ChangeNumber, ImmutableHashSet? AppIDs) packageData) in packagesData) { - if (PackagesData.TryGetValue(packageID, out (uint ChangeNumber, ImmutableHashSet? AppIDs) previousData) && (packageData.ChangeNumber <= previousData.ChangeNumber)) { - continue; - } - - PackagesData[packageID] = packageData; - save = true; - } - - if (save) { - Utilities.InBackground(Save); - } - } finally { - PackagesRefreshSemaphore.Release(); - } - } - - private async void OnObjectModified(object? sender, EventArgs e) { - if (string.IsNullOrEmpty(FilePath)) { - return; - } - - await Save().ConfigureAwait(false); + } finally { + PackagesRefreshSemaphore.Release(); } } + + private async void OnObjectModified(object? sender, EventArgs e) { + if (string.IsNullOrEmpty(FilePath)) { + return; + } + + await Save().ConfigureAwait(false); + } } diff --git a/ArchiSteamFarm/Web/GitHub.cs b/ArchiSteamFarm/Web/GitHub.cs index eca7cd70e..a7d4c8388 100644 --- a/ArchiSteamFarm/Web/GitHub.cs +++ b/ArchiSteamFarm/Web/GitHub.cs @@ -37,273 +37,273 @@ using Markdig.Syntax; using Markdig.Syntax.Inlines; using Newtonsoft.Json; -namespace ArchiSteamFarm.Web { - internal static class GitHub { - internal static async Task GetLatestRelease(bool stable = true) { - Uri request = new(SharedInfo.GithubReleaseURL + (stable ? "/latest" : "?per_page=1")); +namespace ArchiSteamFarm.Web; - if (stable) { - return await GetReleaseFromURL(request).ConfigureAwait(false); - } - - ImmutableList? response = await GetReleasesFromURL(request).ConfigureAwait(false); - - return response?.FirstOrDefault(); - } - - internal static async Task GetRelease(string version) { - if (string.IsNullOrEmpty(version)) { - throw new ArgumentNullException(nameof(version)); - } - - Uri request = new($"{SharedInfo.GithubReleaseURL}/tags/{version}"); +internal static class GitHub { + internal static async Task GetLatestRelease(bool stable = true) { + Uri request = new(SharedInfo.GithubReleaseURL + (stable ? "/latest" : "?per_page=1")); + if (stable) { return await GetReleaseFromURL(request).ConfigureAwait(false); } - internal static async Task?> GetWikiHistory(string page) { - if (string.IsNullOrEmpty(page)) { - throw new ArgumentNullException(nameof(page)); - } + ImmutableList? response = await GetReleasesFromURL(request).ConfigureAwait(false); - if (ASF.WebBrowser == null) { - throw new InvalidOperationException(nameof(ASF.WebBrowser)); - } + return response?.FirstOrDefault(); + } - Uri request = new($"{SharedInfo.ProjectURL}/wiki/{page}/_history"); + internal static async Task GetRelease(string version) { + if (string.IsNullOrEmpty(version)) { + throw new ArgumentNullException(nameof(version)); + } - using HtmlDocumentResponse? response = await ASF.WebBrowser.UrlGetToHtmlDocument(request, requestOptions: WebBrowser.ERequestOptions.ReturnClientErrors).ConfigureAwait(false); + Uri request = new($"{SharedInfo.GithubReleaseURL}/tags/{version}"); + + return await GetReleaseFromURL(request).ConfigureAwait(false); + } + + internal static async Task?> GetWikiHistory(string page) { + if (string.IsNullOrEmpty(page)) { + throw new ArgumentNullException(nameof(page)); + } + + if (ASF.WebBrowser == null) { + throw new InvalidOperationException(nameof(ASF.WebBrowser)); + } + + Uri request = new($"{SharedInfo.ProjectURL}/wiki/{page}/_history"); + + using HtmlDocumentResponse? response = await ASF.WebBrowser.UrlGetToHtmlDocument(request, requestOptions: WebBrowser.ERequestOptions.ReturnClientErrors).ConfigureAwait(false); + + if (response == null) { + return null; + } + + if (response.StatusCode.IsClientErrorCode()) { + return response.StatusCode switch { + HttpStatusCode.NotFound => new Dictionary(0), + _ => null + }; + } + + IEnumerable revisionNodes = response.Content.SelectNodes("//li[contains(@class, 'wiki-history-revision')]"); + + Dictionary result = new(); + + foreach (IElement revisionNode in revisionNodes) { + IElement? versionNode = revisionNode.SelectSingleElementNode(".//input/@value"); + + if (versionNode == null) { + ASF.ArchiLogger.LogNullError(nameof(versionNode)); - if (response == null) { return null; } - if (response.StatusCode.IsClientErrorCode()) { - return response.StatusCode switch { - HttpStatusCode.NotFound => new Dictionary(0), - _ => null + string versionText = versionNode.GetAttribute("value"); + + if (string.IsNullOrEmpty(versionText)) { + ASF.ArchiLogger.LogNullError(nameof(versionText)); + + return null; + } + + IElement? dateTimeNode = revisionNode.SelectSingleElementNode(".//relative-time/@datetime"); + + if (dateTimeNode == null) { + ASF.ArchiLogger.LogNullError(nameof(dateTimeNode)); + + return null; + } + + string dateTimeText = dateTimeNode.GetAttribute("datetime"); + + if (string.IsNullOrEmpty(dateTimeText)) { + ASF.ArchiLogger.LogNullError(nameof(dateTimeText)); + + return null; + } + + if (!DateTime.TryParse(dateTimeText, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime dateTime)) { + ASF.ArchiLogger.LogNullError(nameof(dateTime)); + + return null; + } + + result[versionText] = dateTime.ToUniversalTime(); + } + + return result; + } + + internal static async Task GetWikiPage(string page, string? revision = null) { + if (string.IsNullOrEmpty(page)) { + throw new ArgumentNullException(nameof(page)); + } + + if (ASF.WebBrowser == null) { + throw new InvalidOperationException(nameof(ASF.WebBrowser)); + } + + Uri request = new($"{SharedInfo.ProjectURL}/wiki/{page}{(!string.IsNullOrEmpty(revision) ? $"/{revision}" : "")}"); + + using HtmlDocumentResponse? response = await ASF.WebBrowser.UrlGetToHtmlDocument(request).ConfigureAwait(false); + + if (response == null) { + return null; + } + + IElement? markdownBodyNode = response.Content.SelectSingleNode("//div[@class='markdown-body']"); + + return markdownBodyNode?.InnerHtml.Trim() ?? ""; + } + + private static MarkdownDocument ExtractChangelogFromBody(string markdownText) { + if (string.IsNullOrEmpty(markdownText)) { + throw new ArgumentNullException(nameof(markdownText)); + } + + MarkdownDocument markdownDocument = Markdown.Parse(markdownText); + MarkdownDocument result = new(); + + foreach (Block block in markdownDocument.SkipWhile(static block => block is not HeadingBlock { Inline: { FirstChild: LiteralInline literalInline } } || !literalInline.Content.ToString().Equals("Changelog", StringComparison.OrdinalIgnoreCase)).Skip(1).TakeWhile(static block => block is not ThematicBreakBlock).ToList()) { + // All blocks that we're interested in must be removed from original markdownDocument firstly + markdownDocument.Remove(block); + result.Add(block); + } + + return result; + } + + private static async Task GetReleaseFromURL(Uri request) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (ASF.WebBrowser == null) { + throw new InvalidOperationException(nameof(ASF.WebBrowser)); + } + + ObjectResponse? response = await ASF.WebBrowser.UrlGetToJsonObject(request).ConfigureAwait(false); + + return response?.Content; + } + + private static async Task?> GetReleasesFromURL(Uri request) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (ASF.WebBrowser == null) { + throw new InvalidOperationException(nameof(ASF.WebBrowser)); + } + + ObjectResponse>? response = await ASF.WebBrowser.UrlGetToJsonObject>(request).ConfigureAwait(false); + + return response?.Content; + } + + [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] + internal sealed class ReleaseResponse { + [JsonProperty(PropertyName = "assets", Required = Required.Always)] + internal readonly ImmutableHashSet Assets = ImmutableHashSet.Empty; + + [JsonProperty(PropertyName = "prerelease", Required = Required.Always)] + internal readonly bool IsPreRelease; + + [JsonProperty(PropertyName = "published_at", Required = Required.Always)] + internal readonly DateTime PublishedAt; + + [JsonProperty(PropertyName = "tag_name", Required = Required.Always)] + internal readonly string Tag = ""; + + internal string? ChangelogHTML { + get { + if (BackingChangelogHTML != null) { + return BackingChangelogHTML; + } + + if (Changelog == null) { + ASF.ArchiLogger.LogNullError(nameof(Changelog)); + + return null; + } + + using StringWriter writer = new(); + + HtmlRenderer renderer = new(writer); + + renderer.Render(Changelog); + writer.Flush(); + + return BackingChangelogHTML = writer.ToString(); + } + } + + internal string? ChangelogPlainText { + get { + if (BackingChangelogPlainText != null) { + return BackingChangelogPlainText; + } + + if (Changelog == null) { + ASF.ArchiLogger.LogNullError(nameof(Changelog)); + + return null; + } + + using StringWriter writer = new(); + + HtmlRenderer renderer = new(writer) { + EnableHtmlForBlock = false, + EnableHtmlForInline = false, + EnableHtmlEscape = false }; + + renderer.Render(Changelog); + writer.Flush(); + + return BackingChangelogPlainText = writer.ToString(); } + } - IEnumerable revisionNodes = response.Content.SelectNodes("//li[contains(@class, 'wiki-history-revision')]"); + [JsonProperty(PropertyName = "body", Required = Required.Always)] + private readonly string? MarkdownBody = ""; - Dictionary result = new(); + private MarkdownDocument? Changelog { + get { + if (BackingChangelog != null) { + return BackingChangelog; + } - foreach (IElement revisionNode in revisionNodes) { - IElement? versionNode = revisionNode.SelectSingleElementNode(".//input/@value"); - - if (versionNode == null) { - ASF.ArchiLogger.LogNullError(nameof(versionNode)); + if (string.IsNullOrEmpty(MarkdownBody)) { + ASF.ArchiLogger.LogNullError(nameof(MarkdownBody)); return null; } - string versionText = versionNode.GetAttribute("value"); - - if (string.IsNullOrEmpty(versionText)) { - ASF.ArchiLogger.LogNullError(nameof(versionText)); - - return null; - } - - IElement? dateTimeNode = revisionNode.SelectSingleElementNode(".//relative-time/@datetime"); - - if (dateTimeNode == null) { - ASF.ArchiLogger.LogNullError(nameof(dateTimeNode)); - - return null; - } - - string dateTimeText = dateTimeNode.GetAttribute("datetime"); - - if (string.IsNullOrEmpty(dateTimeText)) { - ASF.ArchiLogger.LogNullError(nameof(dateTimeText)); - - return null; - } - - if (!DateTime.TryParse(dateTimeText, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime dateTime)) { - ASF.ArchiLogger.LogNullError(nameof(dateTime)); - - return null; - } - - result[versionText] = dateTime.ToUniversalTime(); + return BackingChangelog = ExtractChangelogFromBody(MarkdownBody!); } - - return result; } - internal static async Task GetWikiPage(string page, string? revision = null) { - if (string.IsNullOrEmpty(page)) { - throw new ArgumentNullException(nameof(page)); - } + private MarkdownDocument? BackingChangelog; + private string? BackingChangelogHTML; + private string? BackingChangelogPlainText; - if (ASF.WebBrowser == null) { - throw new InvalidOperationException(nameof(ASF.WebBrowser)); - } + [JsonConstructor] + private ReleaseResponse() { } - Uri request = new($"{SharedInfo.ProjectURL}/wiki/{page}{(!string.IsNullOrEmpty(revision) ? $"/{revision}" : "")}"); + internal sealed class Asset { + [JsonProperty(PropertyName = "browser_download_url", Required = Required.Always)] + internal readonly Uri? DownloadURL; - using HtmlDocumentResponse? response = await ASF.WebBrowser.UrlGetToHtmlDocument(request).ConfigureAwait(false); + [JsonProperty(PropertyName = "name", Required = Required.Always)] + internal readonly string? Name; - if (response == null) { - return null; - } - - IElement? markdownBodyNode = response.Content.SelectSingleNode("//div[@class='markdown-body']"); - - return markdownBodyNode?.InnerHtml.Trim() ?? ""; - } - - private static MarkdownDocument ExtractChangelogFromBody(string markdownText) { - if (string.IsNullOrEmpty(markdownText)) { - throw new ArgumentNullException(nameof(markdownText)); - } - - MarkdownDocument markdownDocument = Markdown.Parse(markdownText); - MarkdownDocument result = new(); - - foreach (Block block in markdownDocument.SkipWhile(static block => block is not HeadingBlock { Inline: { FirstChild: LiteralInline literalInline } } || !literalInline.Content.ToString().Equals("Changelog", StringComparison.OrdinalIgnoreCase)).Skip(1).TakeWhile(static block => block is not ThematicBreakBlock).ToList()) { - // All blocks that we're interested in must be removed from original markdownDocument firstly - markdownDocument.Remove(block); - result.Add(block); - } - - return result; - } - - private static async Task GetReleaseFromURL(Uri request) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (ASF.WebBrowser == null) { - throw new InvalidOperationException(nameof(ASF.WebBrowser)); - } - - ObjectResponse? response = await ASF.WebBrowser.UrlGetToJsonObject(request).ConfigureAwait(false); - - return response?.Content; - } - - private static async Task?> GetReleasesFromURL(Uri request) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (ASF.WebBrowser == null) { - throw new InvalidOperationException(nameof(ASF.WebBrowser)); - } - - ObjectResponse>? response = await ASF.WebBrowser.UrlGetToJsonObject>(request).ConfigureAwait(false); - - return response?.Content; - } - - [SuppressMessage("ReSharper", "ClassCannotBeInstantiated")] - internal sealed class ReleaseResponse { - [JsonProperty(PropertyName = "assets", Required = Required.Always)] - internal readonly ImmutableHashSet Assets = ImmutableHashSet.Empty; - - [JsonProperty(PropertyName = "prerelease", Required = Required.Always)] - internal readonly bool IsPreRelease; - - [JsonProperty(PropertyName = "published_at", Required = Required.Always)] - internal readonly DateTime PublishedAt; - - [JsonProperty(PropertyName = "tag_name", Required = Required.Always)] - internal readonly string Tag = ""; - - internal string? ChangelogHTML { - get { - if (BackingChangelogHTML != null) { - return BackingChangelogHTML; - } - - if (Changelog == null) { - ASF.ArchiLogger.LogNullError(nameof(Changelog)); - - return null; - } - - using StringWriter writer = new(); - - HtmlRenderer renderer = new(writer); - - renderer.Render(Changelog); - writer.Flush(); - - return BackingChangelogHTML = writer.ToString(); - } - } - - internal string? ChangelogPlainText { - get { - if (BackingChangelogPlainText != null) { - return BackingChangelogPlainText; - } - - if (Changelog == null) { - ASF.ArchiLogger.LogNullError(nameof(Changelog)); - - return null; - } - - using StringWriter writer = new(); - - HtmlRenderer renderer = new(writer) { - EnableHtmlForBlock = false, - EnableHtmlForInline = false, - EnableHtmlEscape = false - }; - - renderer.Render(Changelog); - writer.Flush(); - - return BackingChangelogPlainText = writer.ToString(); - } - } - - [JsonProperty(PropertyName = "body", Required = Required.Always)] - private readonly string? MarkdownBody = ""; - - private MarkdownDocument? Changelog { - get { - if (BackingChangelog != null) { - return BackingChangelog; - } - - if (string.IsNullOrEmpty(MarkdownBody)) { - ASF.ArchiLogger.LogNullError(nameof(MarkdownBody)); - - return null; - } - - return BackingChangelog = ExtractChangelogFromBody(MarkdownBody!); - } - } - - private MarkdownDocument? BackingChangelog; - private string? BackingChangelogHTML; - private string? BackingChangelogPlainText; + [JsonProperty(PropertyName = "size", Required = Required.Always)] + internal readonly uint Size; [JsonConstructor] - private ReleaseResponse() { } - - internal sealed class Asset { - [JsonProperty(PropertyName = "browser_download_url", Required = Required.Always)] - internal readonly Uri? DownloadURL; - - [JsonProperty(PropertyName = "name", Required = Required.Always)] - internal readonly string? Name; - - [JsonProperty(PropertyName = "size", Required = Required.Always)] - internal readonly uint Size; - - [JsonConstructor] - private Asset() { } - } + private Asset() { } } } } diff --git a/ArchiSteamFarm/Web/Responses/BasicResponse.cs b/ArchiSteamFarm/Web/Responses/BasicResponse.cs index ca115a0cd..ab85a08ba 100644 --- a/ArchiSteamFarm/Web/Responses/BasicResponse.cs +++ b/ArchiSteamFarm/Web/Responses/BasicResponse.cs @@ -24,29 +24,29 @@ using System.Net; using System.Net.Http; using JetBrains.Annotations; -namespace ArchiSteamFarm.Web.Responses { - public class BasicResponse { - [PublicAPI] - public HttpStatusCode StatusCode { get; } +namespace ArchiSteamFarm.Web.Responses; - internal readonly Uri FinalUri; +public class BasicResponse { + [PublicAPI] + public HttpStatusCode StatusCode { get; } - internal BasicResponse(HttpResponseMessage httpResponseMessage) { - if (httpResponseMessage == null) { - throw new ArgumentNullException(nameof(httpResponseMessage)); - } + internal readonly Uri FinalUri; - FinalUri = httpResponseMessage.Headers.Location ?? httpResponseMessage.RequestMessage?.RequestUri ?? throw new InvalidOperationException(); - StatusCode = httpResponseMessage.StatusCode; + internal BasicResponse(HttpResponseMessage httpResponseMessage) { + if (httpResponseMessage == null) { + throw new ArgumentNullException(nameof(httpResponseMessage)); } - internal BasicResponse(BasicResponse basicResponse) { - if (basicResponse == null) { - throw new ArgumentNullException(nameof(basicResponse)); - } + FinalUri = httpResponseMessage.Headers.Location ?? httpResponseMessage.RequestMessage?.RequestUri ?? throw new InvalidOperationException(); + StatusCode = httpResponseMessage.StatusCode; + } - FinalUri = basicResponse.FinalUri; - StatusCode = basicResponse.StatusCode; + internal BasicResponse(BasicResponse basicResponse) { + if (basicResponse == null) { + throw new ArgumentNullException(nameof(basicResponse)); } + + FinalUri = basicResponse.FinalUri; + StatusCode = basicResponse.StatusCode; } } diff --git a/ArchiSteamFarm/Web/Responses/BinaryResponse.cs b/ArchiSteamFarm/Web/Responses/BinaryResponse.cs index cc050717e..272dfd95e 100644 --- a/ArchiSteamFarm/Web/Responses/BinaryResponse.cs +++ b/ArchiSteamFarm/Web/Responses/BinaryResponse.cs @@ -23,19 +23,19 @@ using System; using System.Collections.Generic; using JetBrains.Annotations; -namespace ArchiSteamFarm.Web.Responses { - public sealed class BinaryResponse : BasicResponse { - [PublicAPI] - public IReadOnlyCollection Content => Bytes; +namespace ArchiSteamFarm.Web.Responses; - private readonly byte[] Bytes; +public sealed class BinaryResponse : BasicResponse { + [PublicAPI] + public IReadOnlyCollection Content => Bytes; - public BinaryResponse(BasicResponse basicResponse, byte[] bytes) : base(basicResponse) { - if (basicResponse == null) { - throw new ArgumentNullException(nameof(basicResponse)); - } + private readonly byte[] Bytes; - Bytes = bytes ?? throw new ArgumentNullException(nameof(bytes)); + public BinaryResponse(BasicResponse basicResponse, byte[] bytes) : base(basicResponse) { + if (basicResponse == null) { + throw new ArgumentNullException(nameof(basicResponse)); } + + Bytes = bytes ?? throw new ArgumentNullException(nameof(bytes)); } } diff --git a/ArchiSteamFarm/Web/Responses/HtmlDocumentResponse.cs b/ArchiSteamFarm/Web/Responses/HtmlDocumentResponse.cs index e69341a65..71c03b7a7 100644 --- a/ArchiSteamFarm/Web/Responses/HtmlDocumentResponse.cs +++ b/ArchiSteamFarm/Web/Responses/HtmlDocumentResponse.cs @@ -26,38 +26,38 @@ using AngleSharp.Dom; using ArchiSteamFarm.Core; using JetBrains.Annotations; -namespace ArchiSteamFarm.Web.Responses { - public sealed class HtmlDocumentResponse : BasicResponse, IDisposable { - [PublicAPI] - public IDocument Content { get; } +namespace ArchiSteamFarm.Web.Responses; - private HtmlDocumentResponse(BasicResponse basicResponse, IDocument content) : base(basicResponse) { - if (basicResponse == null) { - throw new ArgumentNullException(nameof(basicResponse)); - } +public sealed class HtmlDocumentResponse : BasicResponse, IDisposable { + [PublicAPI] + public IDocument Content { get; } - Content = content ?? throw new ArgumentNullException(nameof(content)); + private HtmlDocumentResponse(BasicResponse basicResponse, IDocument content) : base(basicResponse) { + if (basicResponse == null) { + throw new ArgumentNullException(nameof(basicResponse)); } - public void Dispose() => Content.Dispose(); + Content = content ?? throw new ArgumentNullException(nameof(content)); + } - [PublicAPI] - public static async Task Create(StreamResponse streamResponse) { - if (streamResponse == null) { - throw new ArgumentNullException(nameof(streamResponse)); - } + public void Dispose() => Content.Dispose(); - IBrowsingContext context = BrowsingContext.New(); + [PublicAPI] + public static async Task Create(StreamResponse streamResponse) { + if (streamResponse == null) { + throw new ArgumentNullException(nameof(streamResponse)); + } - try { - IDocument document = await context.OpenAsync(req => req.Content(streamResponse.Content, true)).ConfigureAwait(false); + IBrowsingContext context = BrowsingContext.New(); - return new HtmlDocumentResponse(streamResponse, document); - } catch (Exception e) { - ASF.ArchiLogger.LogGenericWarningException(e); + try { + IDocument document = await context.OpenAsync(req => req.Content(streamResponse.Content, true)).ConfigureAwait(false); - return null; - } + return new HtmlDocumentResponse(streamResponse, document); + } catch (Exception e) { + ASF.ArchiLogger.LogGenericWarningException(e); + + return null; } } } diff --git a/ArchiSteamFarm/Web/Responses/ObjectResponse.cs b/ArchiSteamFarm/Web/Responses/ObjectResponse.cs index fa8c07f3f..60cc93589 100644 --- a/ArchiSteamFarm/Web/Responses/ObjectResponse.cs +++ b/ArchiSteamFarm/Web/Responses/ObjectResponse.cs @@ -22,17 +22,17 @@ using System; using JetBrains.Annotations; -namespace ArchiSteamFarm.Web.Responses { - public sealed class ObjectResponse : BasicResponse { - [PublicAPI] - public T Content { get; } +namespace ArchiSteamFarm.Web.Responses; - public ObjectResponse(BasicResponse basicResponse, T content) : base(basicResponse) { - if (basicResponse == null) { - throw new ArgumentNullException(nameof(basicResponse)); - } +public sealed class ObjectResponse : BasicResponse { + [PublicAPI] + public T Content { get; } - Content = content ?? throw new ArgumentNullException(nameof(content)); + public ObjectResponse(BasicResponse basicResponse, T content) : base(basicResponse) { + if (basicResponse == null) { + throw new ArgumentNullException(nameof(basicResponse)); } + + Content = content ?? throw new ArgumentNullException(nameof(content)); } } diff --git a/ArchiSteamFarm/Web/Responses/StreamResponse.cs b/ArchiSteamFarm/Web/Responses/StreamResponse.cs index d3785fcd9..da76d8a4a 100644 --- a/ArchiSteamFarm/Web/Responses/StreamResponse.cs +++ b/ArchiSteamFarm/Web/Responses/StreamResponse.cs @@ -25,27 +25,27 @@ using System.Net.Http; using System.Threading.Tasks; using JetBrains.Annotations; -namespace ArchiSteamFarm.Web.Responses { - public sealed class StreamResponse : BasicResponse, IAsyncDisposable { - [PublicAPI] - public Stream Content { get; } +namespace ArchiSteamFarm.Web.Responses; - [PublicAPI] - public long Length { get; } +public sealed class StreamResponse : BasicResponse, IAsyncDisposable { + [PublicAPI] + public Stream Content { get; } - private readonly HttpResponseMessage ResponseMessage; + [PublicAPI] + public long Length { get; } - internal StreamResponse(HttpResponseMessage httpResponseMessage, Stream content) : base(httpResponseMessage) { - ResponseMessage = httpResponseMessage ?? throw new ArgumentNullException(nameof(httpResponseMessage)); - Content = content ?? throw new ArgumentNullException(nameof(content)); + private readonly HttpResponseMessage ResponseMessage; - Length = httpResponseMessage.Content.Headers.ContentLength.GetValueOrDefault(); - } + internal StreamResponse(HttpResponseMessage httpResponseMessage, Stream content) : base(httpResponseMessage) { + ResponseMessage = httpResponseMessage ?? throw new ArgumentNullException(nameof(httpResponseMessage)); + Content = content ?? throw new ArgumentNullException(nameof(content)); - public async ValueTask DisposeAsync() { - await Content.DisposeAsync().ConfigureAwait(false); + Length = httpResponseMessage.Content.Headers.ContentLength.GetValueOrDefault(); + } - ResponseMessage.Dispose(); - } + public async ValueTask DisposeAsync() { + await Content.DisposeAsync().ConfigureAwait(false); + + ResponseMessage.Dispose(); } } diff --git a/ArchiSteamFarm/Web/Responses/StringResponse.cs b/ArchiSteamFarm/Web/Responses/StringResponse.cs index c3c852d7f..c1e21afc9 100644 --- a/ArchiSteamFarm/Web/Responses/StringResponse.cs +++ b/ArchiSteamFarm/Web/Responses/StringResponse.cs @@ -23,18 +23,18 @@ using System; using System.Net.Http; using JetBrains.Annotations; -namespace ArchiSteamFarm.Web.Responses { - [Obsolete("ASF no longer uses this class, re-implement it yourself using " + nameof(BasicResponse) + " if needed.")] - public sealed class StringResponse : BasicResponse { - [PublicAPI] - public string Content { get; } +namespace ArchiSteamFarm.Web.Responses; - internal StringResponse(HttpResponseMessage httpResponseMessage, string content) : base(httpResponseMessage) { - if (httpResponseMessage == null) { - throw new ArgumentNullException(nameof(httpResponseMessage)); - } +[Obsolete("ASF no longer uses this class, re-implement it yourself using " + nameof(BasicResponse) + " if needed.")] +public sealed class StringResponse : BasicResponse { + [PublicAPI] + public string Content { get; } - Content = content ?? throw new ArgumentNullException(nameof(content)); + internal StringResponse(HttpResponseMessage httpResponseMessage, string content) : base(httpResponseMessage) { + if (httpResponseMessage == null) { + throw new ArgumentNullException(nameof(httpResponseMessage)); } + + Content = content ?? throw new ArgumentNullException(nameof(content)); } } diff --git a/ArchiSteamFarm/Web/Responses/XmlDocumentResponse.cs b/ArchiSteamFarm/Web/Responses/XmlDocumentResponse.cs index cffbe8ae2..95c677451 100644 --- a/ArchiSteamFarm/Web/Responses/XmlDocumentResponse.cs +++ b/ArchiSteamFarm/Web/Responses/XmlDocumentResponse.cs @@ -23,18 +23,18 @@ using System; using System.Xml; using JetBrains.Annotations; -namespace ArchiSteamFarm.Web.Responses { - [Obsolete("ASF no longer uses any XML-related functions, re-implement it yourself using " + nameof(BasicResponse) + " if needed.")] - public sealed class XmlDocumentResponse : BasicResponse { - [PublicAPI] - public XmlDocument Content { get; } +namespace ArchiSteamFarm.Web.Responses; - public XmlDocumentResponse(BasicResponse basicResponse, XmlDocument content) : base(basicResponse) { - if (basicResponse == null) { - throw new ArgumentNullException(nameof(basicResponse)); - } +[Obsolete("ASF no longer uses any XML-related functions, re-implement it yourself using " + nameof(BasicResponse) + " if needed.")] +public sealed class XmlDocumentResponse : BasicResponse { + [PublicAPI] + public XmlDocument Content { get; } - Content = content ?? throw new ArgumentNullException(nameof(content)); + public XmlDocumentResponse(BasicResponse basicResponse, XmlDocument content) : base(basicResponse) { + if (basicResponse == null) { + throw new ArgumentNullException(nameof(basicResponse)); } + + Content = content ?? throw new ArgumentNullException(nameof(content)); } } diff --git a/ArchiSteamFarm/Web/WebBrowser.cs b/ArchiSteamFarm/Web/WebBrowser.cs index cefd3daf9..ff7843d4c 100644 --- a/ArchiSteamFarm/Web/WebBrowser.cs +++ b/ArchiSteamFarm/Web/WebBrowser.cs @@ -38,890 +38,890 @@ using ArchiSteamFarm.Web.Responses; using JetBrains.Annotations; using Newtonsoft.Json; -namespace ArchiSteamFarm.Web { - public sealed class WebBrowser : IDisposable { - [PublicAPI] - public const byte MaxTries = 5; // Defines maximum number of recommended tries for a single request +namespace ArchiSteamFarm.Web; - internal const byte MaxConnections = 5; // Defines maximum number of connections per ServicePoint. Be careful, as it also defines maximum number of sockets in CLOSE_WAIT state +public sealed class WebBrowser : IDisposable { + [PublicAPI] + public const byte MaxTries = 5; // Defines maximum number of recommended tries for a single request - private const byte ExtendedTimeoutMultiplier = 10; // Defines multiplier of timeout for WebBrowsers dealing with huge data (ASF update) - private const byte MaxIdleTime = 15; // Defines in seconds, how long socket is allowed to stay in CLOSE_WAIT state after there are no connections to it + internal const byte MaxConnections = 5; // Defines maximum number of connections per ServicePoint. Be careful, as it also defines maximum number of sockets in CLOSE_WAIT state - [PublicAPI] - public CookieContainer CookieContainer { get; } = new(); + private const byte ExtendedTimeoutMultiplier = 10; // Defines multiplier of timeout for WebBrowsers dealing with huge data (ASF update) + private const byte MaxIdleTime = 15; // Defines in seconds, how long socket is allowed to stay in CLOSE_WAIT state after there are no connections to it - [PublicAPI] - public TimeSpan Timeout => HttpClient.Timeout; + [PublicAPI] + public CookieContainer CookieContainer { get; } = new(); - private readonly ArchiLogger ArchiLogger; - private readonly HttpClient HttpClient; - private readonly HttpClientHandler HttpClientHandler; + [PublicAPI] + public TimeSpan Timeout => HttpClient.Timeout; - internal WebBrowser(ArchiLogger archiLogger, IWebProxy? webProxy = null, bool extendedTimeout = false) { - ArchiLogger = archiLogger ?? throw new ArgumentNullException(nameof(archiLogger)); + private readonly ArchiLogger ArchiLogger; + private readonly HttpClient HttpClient; + private readonly HttpClientHandler HttpClientHandler; - HttpClientHandler = new HttpClientHandler { - AllowAutoRedirect = false, // This must be false if we want to handle custom redirection schemes such as "steammobile://" + internal WebBrowser(ArchiLogger archiLogger, IWebProxy? webProxy = null, bool extendedTimeout = false) { + ArchiLogger = archiLogger ?? throw new ArgumentNullException(nameof(archiLogger)); + + HttpClientHandler = new HttpClientHandler { + AllowAutoRedirect = false, // This must be false if we want to handle custom redirection schemes such as "steammobile://" #if NETFRAMEWORK AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip, #else - AutomaticDecompression = DecompressionMethods.All, + AutomaticDecompression = DecompressionMethods.All, #endif - CookieContainer = CookieContainer - }; + CookieContainer = CookieContainer + }; - if (webProxy != null) { - HttpClientHandler.Proxy = webProxy; - HttpClientHandler.UseProxy = true; - } + if (webProxy != null) { + HttpClientHandler.Proxy = webProxy; + HttpClientHandler.UseProxy = true; + } #if NETFRAMEWORK if (!RuntimeMadness.IsRunningOnMono) { HttpClientHandler.MaxConnectionsPerServer = MaxConnections; } #else - HttpClientHandler.MaxConnectionsPerServer = MaxConnections; + HttpClientHandler.MaxConnectionsPerServer = MaxConnections; #endif - HttpClient = GenerateDisposableHttpClient(extendedTimeout); + HttpClient = GenerateDisposableHttpClient(extendedTimeout); + } + + public void Dispose() { + HttpClient.Dispose(); + HttpClientHandler.Dispose(); + } + + [PublicAPI] + public HttpClient GenerateDisposableHttpClient(bool extendedTimeout = false) { + if (ASF.GlobalConfig == null) { + throw new InvalidOperationException(nameof(ASF.GlobalConfig)); } - public void Dispose() { - HttpClient.Dispose(); - HttpClientHandler.Dispose(); - } - - [PublicAPI] - public HttpClient GenerateDisposableHttpClient(bool extendedTimeout = false) { - if (ASF.GlobalConfig == null) { - throw new InvalidOperationException(nameof(ASF.GlobalConfig)); - } - - HttpClient result = new(HttpClientHandler, false) { + HttpClient result = new(HttpClientHandler, false) { #if !NETFRAMEWORK - DefaultRequestVersion = HttpVersion.Version30, + DefaultRequestVersion = HttpVersion.Version30, #endif - Timeout = TimeSpan.FromSeconds(extendedTimeout ? ExtendedTimeoutMultiplier * ASF.GlobalConfig.ConnectionTimeout : ASF.GlobalConfig.ConnectionTimeout) - }; + Timeout = TimeSpan.FromSeconds(extendedTimeout ? ExtendedTimeoutMultiplier * ASF.GlobalConfig.ConnectionTimeout : ASF.GlobalConfig.ConnectionTimeout) + }; - // Most web services expect that UserAgent is set, so we declare it globally - // If you by any chance came here with a very "clever" idea of hiding your ass by changing default ASF user-agent then here is a very good advice from me: don't, for your own safety - you've been warned - result.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(SharedInfo.PublicIdentifier, SharedInfo.Version.ToString())); - result.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue($"({SharedInfo.BuildInfo.Variant}; {OS.Version.Replace("(", "", StringComparison.Ordinal).Replace(")", "", StringComparison.Ordinal)}; +{SharedInfo.ProjectURL})")); + // Most web services expect that UserAgent is set, so we declare it globally + // If you by any chance came here with a very "clever" idea of hiding your ass by changing default ASF user-agent then here is a very good advice from me: don't, for your own safety - you've been warned + result.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(SharedInfo.PublicIdentifier, SharedInfo.Version.ToString())); + result.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue($"({SharedInfo.BuildInfo.Variant}; {OS.Version.Replace("(", "", StringComparison.Ordinal).Replace(")", "", StringComparison.Ordinal)}; +{SharedInfo.ProjectURL})")); - return result; + return result; + } + + [PublicAPI] + public async Task UrlGetToBinary(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries, IProgress? progressReporter = null) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); } - [PublicAPI] - public async Task UrlGetToBinary(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries, IProgress? progressReporter = null) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); + if (maxTries == 0) { + throw new ArgumentOutOfRangeException(nameof(maxTries)); + } + + for (byte i = 0; i < maxTries; i++) { + StreamResponse? response = await UrlGetToStream(request, headers, referer, requestOptions | ERequestOptions.ReturnClientErrors, 1).ConfigureAwait(false); + + if (response == null) { + // Request timed out, try again + continue; } - if (maxTries == 0) { - throw new ArgumentOutOfRangeException(nameof(maxTries)); - } - - for (byte i = 0; i < maxTries; i++) { - StreamResponse? response = await UrlGetToStream(request, headers, referer, requestOptions | ERequestOptions.ReturnClientErrors, 1).ConfigureAwait(false); - - if (response == null) { - // Request timed out, try again - continue; + await using (response.ConfigureAwait(false)) { + if (response.StatusCode.IsClientErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { + // We're not handling this error, do not try again + break; + } + } else if (response.StatusCode.IsServerErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { + // We're not handling this error, try again + continue; + } } - await using (response.ConfigureAwait(false)) { - if (response.StatusCode.IsClientErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { - // We're not handling this error, do not try again - break; - } - } else if (response.StatusCode.IsServerErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { - // We're not handling this error, try again - continue; - } - } - - progressReporter?.Report(0); + progressReporter?.Report(0); #pragma warning disable CA2000 // False positive, we're actually wrapping it in the using clause below exactly for that purpose - MemoryStream ms = new((int) response.Length); + MemoryStream ms = new((int) response.Length); #pragma warning restore CA2000 // False positive, we're actually wrapping it in the using clause below exactly for that purpose - await using (ms.ConfigureAwait(false)) { - byte batch = 0; - long readThisBatch = 0; - long batchIncreaseSize = response.Length / 100; + await using (ms.ConfigureAwait(false)) { + byte batch = 0; + long readThisBatch = 0; + long batchIncreaseSize = response.Length / 100; - ArrayPool bytePool = ArrayPool.Shared; + ArrayPool bytePool = ArrayPool.Shared; - // This is HttpClient's buffer, using more doesn't make sense - byte[] buffer = bytePool.Rent(8192); + // This is HttpClient's buffer, using more doesn't make sense + byte[] buffer = bytePool.Rent(8192); - try { - while (response.Content.CanRead) { - int read = await response.Content.ReadAsync(buffer.AsMemory(0, buffer.Length)).ConfigureAwait(false); + try { + while (response.Content.CanRead) { + int read = await response.Content.ReadAsync(buffer.AsMemory(0, buffer.Length)).ConfigureAwait(false); - if (read == 0) { - break; - } - - await ms.WriteAsync(buffer.AsMemory(0, read)).ConfigureAwait(false); - - if ((progressReporter == null) || (batchIncreaseSize == 0) || (batch >= 99)) { - continue; - } - - readThisBatch += read; - - while ((readThisBatch >= batchIncreaseSize) && (batch < 99)) { - readThisBatch -= batchIncreaseSize; - progressReporter.Report(++batch); - } + if (read == 0) { + break; } - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - return null; - } finally { - bytePool.Return(buffer); - } + await ms.WriteAsync(buffer.AsMemory(0, read)).ConfigureAwait(false); - progressReporter?.Report(100); + if ((progressReporter == null) || (batchIncreaseSize == 0) || (batch >= 99)) { + continue; + } - return new BinaryResponse(response, ms.ToArray()); - } - } - } + readThisBatch += read; - if (maxTries > 1) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - } - - return null; - } - - [PublicAPI] - public async Task UrlGetToHtmlDocument(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (maxTries == 0) { - throw new ArgumentOutOfRangeException(nameof(maxTries)); - } - - for (byte i = 0; i < maxTries; i++) { - StreamResponse? response = await UrlGetToStream(request, headers, referer, requestOptions | ERequestOptions.ReturnClientErrors, 1).ConfigureAwait(false); - - if (response == null) { - // Request timed out, try again - continue; - } - - await using (response.ConfigureAwait(false)) { - if (response.StatusCode.IsClientErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { - // We're not handling this error, do not try again - break; - } - } else if (response.StatusCode.IsServerErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { - // We're not handling this error, try again - continue; - } - } - - try { - return await HtmlDocumentResponse.Create(response).ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - } - } - } - - if (maxTries > 1) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - } - - return null; - } - - [PublicAPI] - public async Task?> UrlGetToJsonObject(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (maxTries == 0) { - throw new ArgumentOutOfRangeException(nameof(maxTries)); - } - - for (byte i = 0; i < maxTries; i++) { - StreamResponse? response = await UrlGetToStream(request, headers, referer, requestOptions | ERequestOptions.ReturnClientErrors, 1).ConfigureAwait(false); - - if (response == null) { - // Request timed out, try again - continue; - } - - await using (response.ConfigureAwait(false)) { - if (response.StatusCode.IsClientErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { - // We're not handling this error, do not try again - break; - } - } else if (response.StatusCode.IsServerErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { - // We're not handling this error, try again - continue; - } - } - - T? obj; - - try { - using StreamReader streamReader = new(response.Content); - using JsonTextReader jsonReader = new(streamReader); - - JsonSerializer serializer = new(); - - obj = serializer.Deserialize(jsonReader); - - if (obj is null) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(obj))); - - continue; + while ((readThisBatch >= batchIncreaseSize) && (batch < 99)) { + readThisBatch -= batchIncreaseSize; + progressReporter.Report(++batch); + } } } catch (Exception e) { ArchiLogger.LogGenericWarningException(e); ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - continue; - } - - return new ObjectResponse(response, obj); - } - } - - if (maxTries > 1) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - } - - return null; - } - - [PublicAPI] - public async Task UrlGetToStream(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (maxTries == 0) { - throw new ArgumentOutOfRangeException(nameof(maxTries)); - } - - for (byte i = 0; i < maxTries; i++) { - HttpResponseMessage? response = await InternalGet(request, headers, referer, requestOptions, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); - - if (response == null) { - // Request timed out, try again - continue; - } - - if (response.StatusCode.IsClientErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { - // We're not handling this error, do not try again - break; - } - } else if (response.StatusCode.IsServerErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { - // We're not handling this error, try again - continue; - } - } - - return new StreamResponse(response, await response.Content.ReadAsStreamAsync().ConfigureAwait(false)); - } - - if (maxTries > 1) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - } - - return null; - } - - [Obsolete("ASF no longer uses this function, re-implement it yourself using " + nameof(UrlGetToStream) + " if needed.")] - [PublicAPI] - public async Task UrlGetToString(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (maxTries == 0) { - throw new ArgumentOutOfRangeException(nameof(maxTries)); - } - - for (byte i = 0; i < maxTries; i++) { - using HttpResponseMessage? response = await InternalGet(request, headers, referer, requestOptions).ConfigureAwait(false); - - if (response == null) { - // Request timed out, try again - continue; - } - - if (response.StatusCode.IsClientErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { - // We're not handling this error, do not try again - break; - } - } else if (response.StatusCode.IsServerErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { - // We're not handling this error, try again - continue; - } - } - - return new StringResponse(response, await response.Content.ReadAsStringAsync().ConfigureAwait(false)); - } - - if (maxTries > 1) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - } - - return null; - } - - [Obsolete("ASF no longer uses any XML-related functions, re-implement it yourself using " + nameof(UrlGetToStream) + " if needed.")] - [PublicAPI] - public async Task UrlGetToXmlDocument(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (maxTries == 0) { - throw new ArgumentOutOfRangeException(nameof(maxTries)); - } - - for (byte i = 0; i < maxTries; i++) { - StreamResponse? response = await UrlGetToStream(request, headers, referer, requestOptions | ERequestOptions.ReturnClientErrors, 1).ConfigureAwait(false); - - if (response == null) { - // Request timed out, try again - continue; - } - - await using (response.ConfigureAwait(false)) { - if (response.StatusCode.IsClientErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { - // We're not handling this error, do not try again - break; - } - } else if (response.StatusCode.IsServerErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { - // We're not handling this error, try again - continue; - } - } - - XmlDocument xmlDocument = new(); - - try { - using XmlReader xmlReader = XmlReader.Create(response.Content, new XmlReaderSettings { XmlResolver = null }); - - xmlDocument.Load(xmlReader); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - continue; - } - - return new XmlDocumentResponse(response, xmlDocument); - } - } - - if (maxTries > 1) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - } - - return null; - } - - [PublicAPI] - public async Task UrlHead(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (maxTries == 0) { - throw new ArgumentOutOfRangeException(nameof(maxTries)); - } - - BasicResponse? result = null; - - for (byte i = 0; i < maxTries; i++) { - using HttpResponseMessage? response = await InternalHead(request, headers, referer, requestOptions).ConfigureAwait(false); - - if (response == null) { - continue; - } - - if (response.StatusCode.IsClientErrorCode()) { - if (requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { - result = new BasicResponse(response); - } - - break; - } - - if (response.StatusCode.IsServerErrorCode()) { - if (requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { - result = new BasicResponse(response); - } - - continue; - } - - return new BasicResponse(response); - } - - if (maxTries > 1) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - } - - return result; - } - - [PublicAPI] - public async Task UrlPost(Uri request, IReadOnlyCollection>? headers = null, T? data = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) where T : class { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (maxTries == 0) { - throw new ArgumentOutOfRangeException(nameof(maxTries)); - } - - BasicResponse? result = null; - - for (byte i = 0; i < maxTries; i++) { - using HttpResponseMessage? response = await InternalPost(request, headers, data, referer, requestOptions).ConfigureAwait(false); - - if (response == null) { - continue; - } - - if (response.StatusCode.IsClientErrorCode()) { - if (requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { - result = new BasicResponse(response); - } - - break; - } - - if (response.StatusCode.IsServerErrorCode()) { - if (requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { - result = new BasicResponse(response); - } - - continue; - } - - return new BasicResponse(response); - } - - if (maxTries > 1) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - } - - return result; - } - - [PublicAPI] - public async Task UrlPostToHtmlDocument(Uri request, IReadOnlyCollection>? headers = null, T? data = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) where T : class { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (maxTries == 0) { - throw new ArgumentOutOfRangeException(nameof(maxTries)); - } - - for (byte i = 0; i < maxTries; i++) { - StreamResponse? response = await UrlPostToStream(request, headers, data, referer, requestOptions | ERequestOptions.ReturnClientErrors, 1).ConfigureAwait(false); - - if (response == null) { - // Request timed out, try again - continue; - } - - await using (response.ConfigureAwait(false)) { - if (response.StatusCode.IsClientErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { - // We're not handling this error, do not try again - break; - } - } else if (response.StatusCode.IsServerErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { - // We're not handling this error, try again - continue; - } - } - - try { - return await HtmlDocumentResponse.Create(response).ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - } - } - } - - if (maxTries > 1) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - } - - return null; - } - - [PublicAPI] - public async Task?> UrlPostToJsonObject(Uri request, IReadOnlyCollection>? headers = null, TData? data = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) where TData : class { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (maxTries == 0) { - throw new ArgumentOutOfRangeException(nameof(maxTries)); - } - - for (byte i = 0; i < maxTries; i++) { - StreamResponse? response = await UrlPostToStream(request, headers, data, referer, requestOptions | ERequestOptions.ReturnClientErrors, 1).ConfigureAwait(false); - - if (response == null) { - // Request timed out, try again - continue; - } - - await using (response.ConfigureAwait(false)) { - if (response.StatusCode.IsClientErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { - // We're not handling this error, do not try again - break; - } - } else if (response.StatusCode.IsServerErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { - // We're not handling this error, try again - continue; - } - } - - TResult? obj; - - try { - using StreamReader steamReader = new(response.Content); - using JsonReader jsonReader = new JsonTextReader(steamReader); - - JsonSerializer serializer = new(); - - obj = serializer.Deserialize(jsonReader); - - if (obj is null) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(obj))); - - continue; - } - } catch (Exception e) { - ArchiLogger.LogGenericWarningException(e); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - - continue; - } - - return new ObjectResponse(response, obj); - } - } - - if (maxTries > 1) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - } - - return null; - } - - [PublicAPI] - public async Task UrlPostToStream(Uri request, IReadOnlyCollection>? headers = null, T? data = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) where T : class { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (maxTries == 0) { - throw new ArgumentOutOfRangeException(nameof(maxTries)); - } - - for (byte i = 0; i < maxTries; i++) { - HttpResponseMessage? response = await InternalPost(request, headers, data, referer, requestOptions, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); - - if (response == null) { - // Request timed out, try again - continue; - } - - if (response.StatusCode.IsClientErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { - // We're not handling this error, do not try again - break; - } - } else if (response.StatusCode.IsServerErrorCode()) { - if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { - // We're not handling this error, try again - continue; - } - } - - return new StreamResponse(response, await response.Content.ReadAsStreamAsync().ConfigureAwait(false)); - } - - if (maxTries > 1) { - ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); - } - - return null; - } - - internal static void Init() { - // Set max connection limit from default of 2 to desired value - ServicePointManager.DefaultConnectionLimit = MaxConnections; - - // Set max idle time from default of 100 seconds (100 * 1000) to desired value - ServicePointManager.MaxServicePointIdleTime = MaxIdleTime * 1000; - - // Don't use Expect100Continue, we're sure about our POSTs, save some TCP packets - ServicePointManager.Expect100Continue = false; - - // Reuse ports if possible -#if NETFRAMEWORK - if (!RuntimeMadness.IsRunningOnMono) { - ServicePointManager.ReusePort = true; - } -#else - ServicePointManager.ReusePort = true; -#endif - } - - private async Task InternalGet(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - return await InternalRequest(request, HttpMethod.Get, headers, null, referer, requestOptions, httpCompletionOption).ConfigureAwait(false); - } - - private async Task InternalHead(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead) { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - return await InternalRequest(request, HttpMethod.Head, headers, null, referer, requestOptions, httpCompletionOption).ConfigureAwait(false); - } - - private async Task InternalPost(Uri request, IReadOnlyCollection>? headers = null, T? data = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead) where T : class { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - return await InternalRequest(request, HttpMethod.Post, headers, data, referer, requestOptions, httpCompletionOption).ConfigureAwait(false); - } - - private async Task InternalRequest(Uri request, HttpMethod httpMethod, IReadOnlyCollection>? headers = null, T? data = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, byte maxRedirections = MaxTries) where T : class { - if (request == null) { - throw new ArgumentNullException(nameof(request)); - } - - if (httpMethod == null) { - throw new ArgumentNullException(nameof(httpMethod)); - } - - HttpResponseMessage response; - - while (true) { - using (HttpRequestMessage requestMessage = new(httpMethod, request)) { -#if !NETFRAMEWORK - requestMessage.Version = HttpClient.DefaultRequestVersion; -#endif - - if (headers != null) { - foreach ((string header, string value) in headers) { - requestMessage.Headers.Add(header, value); - } - } - - if (data != null) { - switch (data) { - case HttpContent content: - requestMessage.Content = content; - - break; - case IReadOnlyCollection> nameValueCollection: - try { - requestMessage.Content = new FormUrlEncodedContent(nameValueCollection); - } catch (UriFormatException) { - requestMessage.Content = new StringContent(string.Join("&", nameValueCollection.Select(static kv => $"{WebUtility.UrlEncode(kv.Key)}={WebUtility.UrlEncode(kv.Value)}")), null, "application/x-www-form-urlencoded"); - } - - break; - case string text: - requestMessage.Content = new StringContent(text); - - break; - default: - requestMessage.Content = new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json"); - - break; - } - } - - if (referer != null) { - requestMessage.Headers.Referrer = referer; - } - - if (Debugging.IsUserDebugging) { - ArchiLogger.LogGenericDebug($"{httpMethod} {request}"); - } - - try { - response = await HttpClient.SendAsync(requestMessage, httpCompletionOption).ConfigureAwait(false); - } catch (Exception e) { - ArchiLogger.LogGenericDebuggingException(e); - return null; } finally { - if (data is HttpContent) { - // We reset the request content to null, as our http content will get disposed otherwise, and we still need it for subsequent calls, such as redirections or retries - requestMessage.Content = null; - } + bytePool.Return(buffer); + } + + progressReporter?.Report(100); + + return new BinaryResponse(response, ms.ToArray()); + } + } + } + + if (maxTries > 1) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + } + + return null; + } + + [PublicAPI] + public async Task UrlGetToHtmlDocument(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (maxTries == 0) { + throw new ArgumentOutOfRangeException(nameof(maxTries)); + } + + for (byte i = 0; i < maxTries; i++) { + StreamResponse? response = await UrlGetToStream(request, headers, referer, requestOptions | ERequestOptions.ReturnClientErrors, 1).ConfigureAwait(false); + + if (response == null) { + // Request timed out, try again + continue; + } + + await using (response.ConfigureAwait(false)) { + if (response.StatusCode.IsClientErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { + // We're not handling this error, do not try again + break; + } + } else if (response.StatusCode.IsServerErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { + // We're not handling this error, try again + continue; } } - if (Debugging.IsUserDebugging) { - ArchiLogger.LogGenericDebug($"{response.StatusCode} <- {httpMethod} {request}"); + try { + return await HtmlDocumentResponse.Create(response).ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + } + } + } + + if (maxTries > 1) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + } + + return null; + } + + [PublicAPI] + public async Task?> UrlGetToJsonObject(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (maxTries == 0) { + throw new ArgumentOutOfRangeException(nameof(maxTries)); + } + + for (byte i = 0; i < maxTries; i++) { + StreamResponse? response = await UrlGetToStream(request, headers, referer, requestOptions | ERequestOptions.ReturnClientErrors, 1).ConfigureAwait(false); + + if (response == null) { + // Request timed out, try again + continue; + } + + await using (response.ConfigureAwait(false)) { + if (response.StatusCode.IsClientErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { + // We're not handling this error, do not try again + break; + } + } else if (response.StatusCode.IsServerErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { + // We're not handling this error, try again + continue; + } } - if (response.IsSuccessStatusCode) { - return response; - } + T? obj; - // WARNING: We still have not disposed response by now, make sure to dispose it ASAP if we're not returning it! - if (response.StatusCode is >= HttpStatusCode.Ambiguous and < HttpStatusCode.BadRequest && (maxRedirections > 0)) { - Uri? redirectUri = response.Headers.Location; + try { + using StreamReader streamReader = new(response.Content); + using JsonTextReader jsonReader = new(streamReader); - if (redirectUri == null) { - ArchiLogger.LogNullError(nameof(redirectUri)); + JsonSerializer serializer = new(); - return null; + obj = serializer.Deserialize(jsonReader); + + if (obj is null) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(obj))); + + continue; } - - if (redirectUri.IsAbsoluteUri) { - switch (redirectUri.Scheme) { - case "http": - case "https": - break; - case "steammobile": - // Those redirections are invalid, but we're aware of that and we have extra logic for them - return response; - default: - // We have no clue about those, but maybe HttpClient can handle them for us - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(redirectUri.Scheme), redirectUri.Scheme)); - - break; - } - } else { - redirectUri = new Uri(request, redirectUri); - } - - switch (response.StatusCode) { - case HttpStatusCode.MovedPermanently: // Per https://tools.ietf.org/html/rfc7231#section-6.4.2, a 301 redirect may be performed using a GET request - case HttpStatusCode.Redirect: // Per https://tools.ietf.org/html/rfc7231#section-6.4.3, a 302 redirect may be performed using a GET request - case HttpStatusCode.SeeOther: // Per https://tools.ietf.org/html/rfc7231#section-6.4.4, a 303 redirect should be performed using a GET request - if (httpMethod != HttpMethod.Head) { - httpMethod = HttpMethod.Get; - } - - // Data doesn't make any sense for a fetch request, clear it in case it's being used - data = null; - - break; - } - - response.Dispose(); - - // Per https://tools.ietf.org/html/rfc7231#section-7.1.2, a redirect location without a fragment should inherit the fragment from the original URI - if (!string.IsNullOrEmpty(request.Fragment) && string.IsNullOrEmpty(redirectUri.Fragment)) { - redirectUri = new UriBuilder(redirectUri) { Fragment = request.Fragment }.Uri; - } - - request = redirectUri; - maxRedirections--; + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); continue; } + return new ObjectResponse(response, obj); + } + } + + if (maxTries > 1) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + } + + return null; + } + + [PublicAPI] + public async Task UrlGetToStream(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (maxTries == 0) { + throw new ArgumentOutOfRangeException(nameof(maxTries)); + } + + for (byte i = 0; i < maxTries; i++) { + HttpResponseMessage? response = await InternalGet(request, headers, referer, requestOptions, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); + + if (response == null) { + // Request timed out, try again + continue; + } + + if (response.StatusCode.IsClientErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { + // We're not handling this error, do not try again + break; + } + } else if (response.StatusCode.IsServerErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { + // We're not handling this error, try again + continue; + } + } + + return new StreamResponse(response, await response.Content.ReadAsStreamAsync().ConfigureAwait(false)); + } + + if (maxTries > 1) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + } + + return null; + } + + [Obsolete("ASF no longer uses this function, re-implement it yourself using " + nameof(UrlGetToStream) + " if needed.")] + [PublicAPI] + public async Task UrlGetToString(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (maxTries == 0) { + throw new ArgumentOutOfRangeException(nameof(maxTries)); + } + + for (byte i = 0; i < maxTries; i++) { + using HttpResponseMessage? response = await InternalGet(request, headers, referer, requestOptions).ConfigureAwait(false); + + if (response == null) { + // Request timed out, try again + continue; + } + + if (response.StatusCode.IsClientErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { + // We're not handling this error, do not try again + break; + } + } else if (response.StatusCode.IsServerErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { + // We're not handling this error, try again + continue; + } + } + + return new StringResponse(response, await response.Content.ReadAsStringAsync().ConfigureAwait(false)); + } + + if (maxTries > 1) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + } + + return null; + } + + [Obsolete("ASF no longer uses any XML-related functions, re-implement it yourself using " + nameof(UrlGetToStream) + " if needed.")] + [PublicAPI] + public async Task UrlGetToXmlDocument(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (maxTries == 0) { + throw new ArgumentOutOfRangeException(nameof(maxTries)); + } + + for (byte i = 0; i < maxTries; i++) { + StreamResponse? response = await UrlGetToStream(request, headers, referer, requestOptions | ERequestOptions.ReturnClientErrors, 1).ConfigureAwait(false); + + if (response == null) { + // Request timed out, try again + continue; + } + + await using (response.ConfigureAwait(false)) { + if (response.StatusCode.IsClientErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { + // We're not handling this error, do not try again + break; + } + } else if (response.StatusCode.IsServerErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { + // We're not handling this error, try again + continue; + } + } + + XmlDocument xmlDocument = new(); + + try { + using XmlReader xmlReader = XmlReader.Create(response.Content, new XmlReaderSettings { XmlResolver = null }); + + xmlDocument.Load(xmlReader); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + continue; + } + + return new XmlDocumentResponse(response, xmlDocument); + } + } + + if (maxTries > 1) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + } + + return null; + } + + [PublicAPI] + public async Task UrlHead(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (maxTries == 0) { + throw new ArgumentOutOfRangeException(nameof(maxTries)); + } + + BasicResponse? result = null; + + for (byte i = 0; i < maxTries; i++) { + using HttpResponseMessage? response = await InternalHead(request, headers, referer, requestOptions).ConfigureAwait(false); + + if (response == null) { + continue; + } + + if (response.StatusCode.IsClientErrorCode()) { + if (requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { + result = new BasicResponse(response); + } + break; } - if (!Debugging.IsUserDebugging) { - ArchiLogger.LogGenericDebug($"{response.StatusCode} <- {httpMethod} {request}"); + if (response.StatusCode.IsServerErrorCode()) { + if (requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { + result = new BasicResponse(response); + } + + continue; + } + + return new BasicResponse(response); + } + + if (maxTries > 1) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + } + + return result; + } + + [PublicAPI] + public async Task UrlPost(Uri request, IReadOnlyCollection>? headers = null, T? data = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) where T : class { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (maxTries == 0) { + throw new ArgumentOutOfRangeException(nameof(maxTries)); + } + + BasicResponse? result = null; + + for (byte i = 0; i < maxTries; i++) { + using HttpResponseMessage? response = await InternalPost(request, headers, data, referer, requestOptions).ConfigureAwait(false); + + if (response == null) { + continue; } if (response.StatusCode.IsClientErrorCode()) { - if (Debugging.IsUserDebugging) { - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, await response.Content.ReadAsStringAsync().ConfigureAwait(false))); + if (requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { + result = new BasicResponse(response); } - // Do not retry on client errors - return response; + break; } - if (requestOptions.HasFlag(ERequestOptions.ReturnServerErrors) && response.StatusCode.IsServerErrorCode()) { - if (Debugging.IsUserDebugging) { - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, await response.Content.ReadAsStringAsync().ConfigureAwait(false))); + if (response.StatusCode.IsServerErrorCode()) { + if (requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { + result = new BasicResponse(response); } - // Do not retry on server errors in this case - return response; + continue; } - using (response) { - if (Debugging.IsUserDebugging) { - ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, await response.Content.ReadAsStringAsync().ConfigureAwait(false))); + return new BasicResponse(response); + } + + if (maxTries > 1) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + } + + return result; + } + + [PublicAPI] + public async Task UrlPostToHtmlDocument(Uri request, IReadOnlyCollection>? headers = null, T? data = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) where T : class { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (maxTries == 0) { + throw new ArgumentOutOfRangeException(nameof(maxTries)); + } + + for (byte i = 0; i < maxTries; i++) { + StreamResponse? response = await UrlPostToStream(request, headers, data, referer, requestOptions | ERequestOptions.ReturnClientErrors, 1).ConfigureAwait(false); + + if (response == null) { + // Request timed out, try again + continue; + } + + await using (response.ConfigureAwait(false)) { + if (response.StatusCode.IsClientErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { + // We're not handling this error, do not try again + break; + } + } else if (response.StatusCode.IsServerErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { + // We're not handling this error, try again + continue; + } } - return null; + try { + return await HtmlDocumentResponse.Create(response).ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + } } } - [Flags] - public enum ERequestOptions : byte { - None = 0, - ReturnClientErrors = 1, - ReturnServerErrors = 2 + if (maxTries > 1) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + } + + return null; + } + + [PublicAPI] + public async Task?> UrlPostToJsonObject(Uri request, IReadOnlyCollection>? headers = null, TData? data = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) where TData : class { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (maxTries == 0) { + throw new ArgumentOutOfRangeException(nameof(maxTries)); + } + + for (byte i = 0; i < maxTries; i++) { + StreamResponse? response = await UrlPostToStream(request, headers, data, referer, requestOptions | ERequestOptions.ReturnClientErrors, 1).ConfigureAwait(false); + + if (response == null) { + // Request timed out, try again + continue; + } + + await using (response.ConfigureAwait(false)) { + if (response.StatusCode.IsClientErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { + // We're not handling this error, do not try again + break; + } + } else if (response.StatusCode.IsServerErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { + // We're not handling this error, try again + continue; + } + } + + TResult? obj; + + try { + using StreamReader steamReader = new(response.Content); + using JsonReader jsonReader = new JsonTextReader(steamReader); + + JsonSerializer serializer = new(); + + obj = serializer.Deserialize(jsonReader); + + if (obj is null) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(obj))); + + continue; + } + } catch (Exception e) { + ArchiLogger.LogGenericWarningException(e); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + + continue; + } + + return new ObjectResponse(response, obj); + } + } + + if (maxTries > 1) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + } + + return null; + } + + [PublicAPI] + public async Task UrlPostToStream(Uri request, IReadOnlyCollection>? headers = null, T? data = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, byte maxTries = MaxTries) where T : class { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (maxTries == 0) { + throw new ArgumentOutOfRangeException(nameof(maxTries)); + } + + for (byte i = 0; i < maxTries; i++) { + HttpResponseMessage? response = await InternalPost(request, headers, data, referer, requestOptions, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); + + if (response == null) { + // Request timed out, try again + continue; + } + + if (response.StatusCode.IsClientErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnClientErrors)) { + // We're not handling this error, do not try again + break; + } + } else if (response.StatusCode.IsServerErrorCode()) { + if (!requestOptions.HasFlag(ERequestOptions.ReturnServerErrors)) { + // We're not handling this error, try again + continue; + } + } + + return new StreamResponse(response, await response.Content.ReadAsStreamAsync().ConfigureAwait(false)); + } + + if (maxTries > 1) { + ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, maxTries)); + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.ErrorFailingRequest, request)); + } + + return null; + } + + internal static void Init() { + // Set max connection limit from default of 2 to desired value + ServicePointManager.DefaultConnectionLimit = MaxConnections; + + // Set max idle time from default of 100 seconds (100 * 1000) to desired value + ServicePointManager.MaxServicePointIdleTime = MaxIdleTime * 1000; + + // Don't use Expect100Continue, we're sure about our POSTs, save some TCP packets + ServicePointManager.Expect100Continue = false; + + // Reuse ports if possible +#if NETFRAMEWORK + if (!RuntimeMadness.IsRunningOnMono) { + ServicePointManager.ReusePort = true; + } +#else + ServicePointManager.ReusePort = true; +#endif + } + + private async Task InternalGet(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + return await InternalRequest(request, HttpMethod.Get, headers, null, referer, requestOptions, httpCompletionOption).ConfigureAwait(false); + } + + private async Task InternalHead(Uri request, IReadOnlyCollection>? headers = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead) { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + return await InternalRequest(request, HttpMethod.Head, headers, null, referer, requestOptions, httpCompletionOption).ConfigureAwait(false); + } + + private async Task InternalPost(Uri request, IReadOnlyCollection>? headers = null, T? data = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead) where T : class { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + return await InternalRequest(request, HttpMethod.Post, headers, data, referer, requestOptions, httpCompletionOption).ConfigureAwait(false); + } + + private async Task InternalRequest(Uri request, HttpMethod httpMethod, IReadOnlyCollection>? headers = null, T? data = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, byte maxRedirections = MaxTries) where T : class { + if (request == null) { + throw new ArgumentNullException(nameof(request)); + } + + if (httpMethod == null) { + throw new ArgumentNullException(nameof(httpMethod)); + } + + HttpResponseMessage response; + + while (true) { + using (HttpRequestMessage requestMessage = new(httpMethod, request)) { +#if !NETFRAMEWORK + requestMessage.Version = HttpClient.DefaultRequestVersion; +#endif + + if (headers != null) { + foreach ((string header, string value) in headers) { + requestMessage.Headers.Add(header, value); + } + } + + if (data != null) { + switch (data) { + case HttpContent content: + requestMessage.Content = content; + + break; + case IReadOnlyCollection> nameValueCollection: + try { + requestMessage.Content = new FormUrlEncodedContent(nameValueCollection); + } catch (UriFormatException) { + requestMessage.Content = new StringContent(string.Join("&", nameValueCollection.Select(static kv => $"{WebUtility.UrlEncode(kv.Key)}={WebUtility.UrlEncode(kv.Value)}")), null, "application/x-www-form-urlencoded"); + } + + break; + case string text: + requestMessage.Content = new StringContent(text); + + break; + default: + requestMessage.Content = new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json"); + + break; + } + } + + if (referer != null) { + requestMessage.Headers.Referrer = referer; + } + + if (Debugging.IsUserDebugging) { + ArchiLogger.LogGenericDebug($"{httpMethod} {request}"); + } + + try { + response = await HttpClient.SendAsync(requestMessage, httpCompletionOption).ConfigureAwait(false); + } catch (Exception e) { + ArchiLogger.LogGenericDebuggingException(e); + + return null; + } finally { + if (data is HttpContent) { + // We reset the request content to null, as our http content will get disposed otherwise, and we still need it for subsequent calls, such as redirections or retries + requestMessage.Content = null; + } + } + } + + if (Debugging.IsUserDebugging) { + ArchiLogger.LogGenericDebug($"{response.StatusCode} <- {httpMethod} {request}"); + } + + if (response.IsSuccessStatusCode) { + return response; + } + + // WARNING: We still have not disposed response by now, make sure to dispose it ASAP if we're not returning it! + if (response.StatusCode is >= HttpStatusCode.Ambiguous and < HttpStatusCode.BadRequest && (maxRedirections > 0)) { + Uri? redirectUri = response.Headers.Location; + + if (redirectUri == null) { + ArchiLogger.LogNullError(nameof(redirectUri)); + + return null; + } + + if (redirectUri.IsAbsoluteUri) { + switch (redirectUri.Scheme) { + case "http": + case "https": + break; + case "steammobile": + // Those redirections are invalid, but we're aware of that and we have extra logic for them + return response; + default: + // We have no clue about those, but maybe HttpClient can handle them for us + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(redirectUri.Scheme), redirectUri.Scheme)); + + break; + } + } else { + redirectUri = new Uri(request, redirectUri); + } + + switch (response.StatusCode) { + case HttpStatusCode.MovedPermanently: // Per https://tools.ietf.org/html/rfc7231#section-6.4.2, a 301 redirect may be performed using a GET request + case HttpStatusCode.Redirect: // Per https://tools.ietf.org/html/rfc7231#section-6.4.3, a 302 redirect may be performed using a GET request + case HttpStatusCode.SeeOther: // Per https://tools.ietf.org/html/rfc7231#section-6.4.4, a 303 redirect should be performed using a GET request + if (httpMethod != HttpMethod.Head) { + httpMethod = HttpMethod.Get; + } + + // Data doesn't make any sense for a fetch request, clear it in case it's being used + data = null; + + break; + } + + response.Dispose(); + + // Per https://tools.ietf.org/html/rfc7231#section-7.1.2, a redirect location without a fragment should inherit the fragment from the original URI + if (!string.IsNullOrEmpty(request.Fragment) && string.IsNullOrEmpty(redirectUri.Fragment)) { + redirectUri = new UriBuilder(redirectUri) { Fragment = request.Fragment }.Uri; + } + + request = redirectUri; + maxRedirections--; + + continue; + } + + break; + } + + if (!Debugging.IsUserDebugging) { + ArchiLogger.LogGenericDebug($"{response.StatusCode} <- {httpMethod} {request}"); + } + + if (response.StatusCode.IsClientErrorCode()) { + if (Debugging.IsUserDebugging) { + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, await response.Content.ReadAsStringAsync().ConfigureAwait(false))); + } + + // Do not retry on client errors + return response; + } + + if (requestOptions.HasFlag(ERequestOptions.ReturnServerErrors) && response.StatusCode.IsServerErrorCode()) { + if (Debugging.IsUserDebugging) { + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, await response.Content.ReadAsStringAsync().ConfigureAwait(false))); + } + + // Do not retry on server errors in this case + return response; + } + + using (response) { + if (Debugging.IsUserDebugging) { + ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.Content, await response.Content.ReadAsStringAsync().ConfigureAwait(false))); + } + + return null; } } + + [Flags] + public enum ERequestOptions : byte { + None = 0, + ReturnClientErrors = 1, + ReturnServerErrors = 2 + } }