mirror of
https://github.com/JustArchiNET/ArchiSteamFarm.git
synced 2025-12-27 11:46:48 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1e8794fe3 | ||
|
|
258ad17930 | ||
|
|
52b32315cc | ||
|
|
d46e532458 | ||
|
|
1e6ab11d9f | ||
|
|
95ad16e26d | ||
|
|
7019445b84 | ||
|
|
0850a261cb | ||
|
|
ae3a60759a | ||
|
|
566be6e8c4 | ||
|
|
9aaf8d8215 | ||
|
|
0964cdac96 | ||
|
|
e62234892a | ||
|
|
55e3c064eb | ||
|
|
32575e69ec | ||
|
|
5cdeccc2ba | ||
|
|
695ffb4b1c | ||
|
|
0977b359c2 |
@@ -212,3 +212,16 @@ dotnet_style_qualification_for_property = false:warning
|
||||
|
||||
dotnet_style_readonly_field = true:warning
|
||||
dotnet_style_require_accessibility_modifiers = always:warning
|
||||
|
||||
###############################
|
||||
# JetBrains, IntelliJ/Rider #
|
||||
###############################
|
||||
|
||||
[*.{csproj,props,xml}]
|
||||
ij_xml_keep_blank_lines = 1
|
||||
ij_xml_keep_line_breaks = false
|
||||
ij_xml_keep_line_breaks_in_text = false
|
||||
ij_xml_space_inside_empty_tag = true
|
||||
|
||||
[*.{json,json5}]
|
||||
ij_json_keep_line_breaks = false
|
||||
|
||||
31
.github/workflows/publish.yml
vendored
31
.github/workflows/publish.yml
vendored
@@ -119,7 +119,7 @@ jobs:
|
||||
- name: Publish ArchiSteamFarm on Unix
|
||||
if: startsWith(matrix.os, 'macos-') || startsWith(matrix.os, 'ubuntu-')
|
||||
env:
|
||||
VARIANTS: generic linux-arm linux-arm64 linux-x64 osx-x64 win-x64 # NOTE: When modifying variants, don't forget to update ASF_VARIANT definitions in SharedInfo.cs!
|
||||
VARIANTS: generic linux-arm linux-arm64 linux-x64 osx-arm64 osx-x64 win-x64 # NOTE: When modifying variants, don't forget to update ASF_VARIANT definitions in SharedInfo.cs!
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
if [ "$1" = 'generic' ]; then
|
||||
variantArgs="-p:TargetLatestRuntimePatch=false -p:UseAppHost=false"
|
||||
else
|
||||
variantArgs="-p:PublishSingleFile=true -p:PublishTrimmed=true -r $1"
|
||||
variantArgs="-p:PublishSingleFile=true -p:PublishTrimmed=true -r $1 --self-contained"
|
||||
fi
|
||||
|
||||
dotnet publish ArchiSteamFarm -c "$CONFIGURATION" -f "$NET_CORE_VERSION" -o "out/${1}" "-p:ASFVariant=$1" -p:ContinuousIntegrationBuild=true --no-restore --nologo $variantArgs
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
- name: Publish ArchiSteamFarm on Windows
|
||||
if: startsWith(matrix.os, 'windows-')
|
||||
env:
|
||||
VARIANTS: generic generic-netf linux-arm linux-arm64 linux-x64 osx-x64 win-x64 # NOTE: When modifying variants, don't forget to update ASF_VARIANT definitions in SharedInfo.cs!
|
||||
VARIANTS: generic generic-netf linux-arm linux-arm64 linux-x64 osx-arm64 osx-x64 win-x64 # NOTE: When modifying variants, don't forget to update ASF_VARIANT definitions in SharedInfo.cs!
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
@@ -245,7 +245,7 @@ jobs:
|
||||
if ($variant -like 'generic*') {
|
||||
$variantArgs = '-p:TargetLatestRuntimePatch=false', '-p:UseAppHost=false'
|
||||
} else {
|
||||
$variantArgs = '-p:PublishSingleFile=true', '-p:PublishTrimmed=true', '-r', "$variant"
|
||||
$variantArgs = '-p:PublishSingleFile=true', '-p:PublishTrimmed=true', '-r', "$variant", '--self-contained'
|
||||
}
|
||||
|
||||
dotnet publish ArchiSteamFarm -c "$env:CONFIGURATION" -f "$targetFramework" -o "out\$variant" "-p:ASFVariant=$variant" -p:ContinuousIntegrationBuild=true --no-restore --nologo $variantArgs
|
||||
@@ -365,6 +365,13 @@ jobs:
|
||||
name: ${{ matrix.os }}_ASF-linux-x64
|
||||
path: out/ASF-linux-x64.zip
|
||||
|
||||
- name: Upload ASF-osx-arm64
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v2.2.4
|
||||
with:
|
||||
name: ${{ matrix.os }}_ASF-osx-arm64
|
||||
path: out/ASF-osx-arm64.zip
|
||||
|
||||
- name: Upload ASF-osx-x64
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v2.2.4
|
||||
@@ -421,6 +428,12 @@ jobs:
|
||||
name: windows-latest_ASF-linux-x64
|
||||
path: out
|
||||
|
||||
- name: Download ASF-osx-arm64 artifact from windows-latest
|
||||
uses: actions/download-artifact@v2.0.10
|
||||
with:
|
||||
name: windows-latest_ASF-osx-arm64
|
||||
path: out
|
||||
|
||||
- name: Download ASF-osx-x64 artifact from windows-latest
|
||||
uses: actions/download-artifact@v2.0.10
|
||||
with:
|
||||
@@ -525,6 +538,16 @@ jobs:
|
||||
asset_name: ASF-linux-x64.zip
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload ASF-osx-arm64 to GitHub release
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.github_release.outputs.upload_url }}
|
||||
asset_path: out/ASF-osx-arm64.zip
|
||||
asset_name: ASF-osx-arm64.zip
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload ASF-osx-x64 to GitHub release
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
|
||||
2
ASF-ui
2
ASF-ui
Submodule ASF-ui updated: 88fbcc1ac4...9eb53d6058
@@ -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<string?> 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<MeowResponse>? response = await webBrowser.UrlGetToJsonObject<MeowResponse>(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<string?> GetRandomCatURL(WebBrowser webBrowser) {
|
||||
if (webBrowser == null) {
|
||||
throw new ArgumentNullException(nameof(webBrowser));
|
||||
}
|
||||
|
||||
Uri request = new($"{URL}/meow");
|
||||
|
||||
ObjectResponse<MeowResponse>? response = await webBrowser.UrlGetToJsonObject<MeowResponse>(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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// Fetches URL of a random cat picture.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GenericResponse<string>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<string>(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 {
|
||||
/// <summary>
|
||||
/// Fetches URL of a random cat picture.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GenericResponse<string>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<string>(link)) : StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, JToken>? 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<bool>();
|
||||
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<string, JToken>? 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<string?> 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<bool>();
|
||||
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<bool> 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<string, JToken>? 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<string?> OnBotMessage(Bot bot, ulong steamID, string message) {
|
||||
// Normally ASF will expect from you async-capable responses, such as Task<string>. 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<string?>(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<bool> 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<string?> 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<bool> 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<string, JToken>? 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<string?> OnBotMessage(Bot bot, ulong steamID, string message) {
|
||||
// Normally ASF will expect from you async-capable responses, such as Task<string>. 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<string?>(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<bool> 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!");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<uint, uint> 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<uint, ulong> AppTokens = new();
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
private readonly ConcurrentDictionary<uint, uint> AppChangeNumbers = new();
|
||||
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
private readonly ConcurrentDictionary<uint, string> DepotKeys = new();
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
private readonly ConcurrentDictionary<uint, ulong> AppTokens = new();
|
||||
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
private readonly ConcurrentDictionary<uint, ulong> PackageTokens = new();
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
private readonly ConcurrentDictionary<uint, string> DepotKeys = new();
|
||||
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
private readonly ConcurrentDictionary<uint, ulong> SubmittedApps = new();
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
private readonly ConcurrentDictionary<uint, ulong> PackageTokens = new();
|
||||
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
private readonly ConcurrentDictionary<uint, string> SubmittedDepots = new();
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
private readonly ConcurrentDictionary<uint, ulong> SubmittedApps = new();
|
||||
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
private readonly ConcurrentDictionary<uint, ulong> SubmittedPackages = new();
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
private readonly ConcurrentDictionary<uint, string> SubmittedDepots = new();
|
||||
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
internal uint LastChangeNumber { get; private set; }
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
private readonly ConcurrentDictionary<uint, ulong> 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<uint, ulong> 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<uint, string> 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<uint, ulong> 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<GlobalCache?> Load() {
|
||||
if (!File.Exists(SharedFilePath)) {
|
||||
GlobalCache result = new();
|
||||
internal Dictionary<uint, ulong> 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<uint, string> 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<uint, ulong> 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<GlobalCache?> 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<GlobalCache>(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<GlobalCache>(json);
|
||||
} catch (Exception e) {
|
||||
ASF.ArchiLogger.LogGenericException(e);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return globalCache;
|
||||
return null;
|
||||
}
|
||||
|
||||
internal void OnPICSChanges(uint currentChangeNumber, IReadOnlyCollection<KeyValuePair<uint, SteamApps.PICSChangesCallback.PICSChangeData>> 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<KeyValuePair<uint, SteamApps.PICSChangesCallback.PICSChangeData>> 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<KeyValuePair<uint, uint>> 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<KeyValuePair<uint, ulong>> appTokens, IReadOnlyCollection<uint> 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<SteamApps.DepotKeyCallback> 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<KeyValuePair<uint, ulong>> 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<uint, ulong> apps, IReadOnlyDictionary<uint, ulong> packages, IReadOnlyDictionary<uint, string> 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<KeyValuePair<uint, uint>> 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<KeyValuePair<uint, ulong>> appTokens, IReadOnlyCollection<uint> 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<SteamApps.DepotKeyCallback> 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<KeyValuePair<uint, ulong>> 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<uint, ulong> apps, IReadOnlyDictionary<uint, ulong> packages, IReadOnlyDictionary<uint, string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() { }
|
||||
}
|
||||
|
||||
@@ -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<string, string> Apps;
|
||||
[JsonProperty(PropertyName = "v", Required = Required.Always)]
|
||||
private static byte Version => SharedInfo.ApiVersion;
|
||||
|
||||
[JsonProperty(PropertyName = "depots", Required = Required.Always)]
|
||||
private readonly ImmutableDictionary<string, string> Depots;
|
||||
[JsonProperty(PropertyName = "apps", Required = Required.Always)]
|
||||
private readonly ImmutableDictionary<string, string> Apps;
|
||||
|
||||
private readonly ulong SteamID;
|
||||
[JsonProperty(PropertyName = "depots", Required = Required.Always)]
|
||||
private readonly ImmutableDictionary<string, string> Depots;
|
||||
|
||||
[JsonProperty(PropertyName = "subs", Required = Required.Always)]
|
||||
private readonly ImmutableDictionary<string, string> 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<string, string> Subs;
|
||||
|
||||
internal RequestData(ulong steamID, IReadOnlyCollection<KeyValuePair<uint, ulong>> apps, IReadOnlyCollection<KeyValuePair<uint, ulong>> accessTokens, IReadOnlyCollection<KeyValuePair<uint, string>> 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<KeyValuePair<uint, ulong>> apps, IReadOnlyCollection<KeyValuePair<uint, ulong>> accessTokens, IReadOnlyCollection<KeyValuePair<uint, string>> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<uint> NewApps = ImmutableHashSet<uint>.Empty;
|
||||
|
||||
[JsonProperty(PropertyName = "new_depots", Required = Required.Always)]
|
||||
internal readonly ImmutableHashSet<uint> NewDepots = ImmutableHashSet<uint>.Empty;
|
||||
|
||||
[JsonProperty(PropertyName = "new_subs", Required = Required.Always)]
|
||||
internal readonly ImmutableHashSet<uint> NewPackages = ImmutableHashSet<uint>.Empty;
|
||||
|
||||
[JsonProperty(PropertyName = "verified_apps", Required = Required.Always)]
|
||||
internal readonly ImmutableHashSet<uint> VerifiedApps = ImmutableHashSet<uint>.Empty;
|
||||
|
||||
[JsonProperty(PropertyName = "verified_depots", Required = Required.Always)]
|
||||
internal readonly ImmutableHashSet<uint> VerifiedDepots = ImmutableHashSet<uint>.Empty;
|
||||
|
||||
[JsonProperty(PropertyName = "verified_subs", Required = Required.Always)]
|
||||
internal readonly ImmutableHashSet<uint> VerifiedPackages = ImmutableHashSet<uint>.Empty;
|
||||
|
||||
[JsonConstructor]
|
||||
private ResponseData() { }
|
||||
|
||||
internal sealed class InternalData {
|
||||
[JsonProperty(PropertyName = "new_apps", Required = Required.Always)]
|
||||
internal readonly ImmutableHashSet<uint> NewApps = ImmutableHashSet<uint>.Empty;
|
||||
|
||||
[JsonProperty(PropertyName = "new_depots", Required = Required.Always)]
|
||||
internal readonly ImmutableHashSet<uint> NewDepots = ImmutableHashSet<uint>.Empty;
|
||||
|
||||
[JsonProperty(PropertyName = "new_subs", Required = Required.Always)]
|
||||
internal readonly ImmutableHashSet<uint> NewPackages = ImmutableHashSet<uint>.Empty;
|
||||
|
||||
[JsonProperty(PropertyName = "verified_apps", Required = Required.Always)]
|
||||
internal readonly ImmutableHashSet<uint> VerifiedApps = ImmutableHashSet<uint>.Empty;
|
||||
|
||||
[JsonProperty(PropertyName = "verified_depots", Required = Required.Always)]
|
||||
internal readonly ImmutableHashSet<uint> VerifiedDepots = ImmutableHashSet<uint>.Empty;
|
||||
|
||||
[JsonProperty(PropertyName = "verified_subs", Required = Required.Always)]
|
||||
internal readonly ImmutableHashSet<uint> VerifiedPackages = ImmutableHashSet<uint>.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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<uint> SecretAppIDs { get; private set; } = ImmutableHashSet<uint>.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<uint> SecretDepotIDs { get; private set; } = ImmutableHashSet<uint>.Empty;
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
[SwaggerItemsMinMax(MinimumUint = 1, MaximumUint = uint.MaxValue)]
|
||||
public ImmutableHashSet<uint> SecretAppIDs { get; private set; } = ImmutableHashSet<uint>.Empty;
|
||||
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
[SwaggerItemsMinMax(MinimumUint = 1, MaximumUint = uint.MaxValue)]
|
||||
public ImmutableHashSet<uint> SecretPackageIDs { get; private set; } = ImmutableHashSet<uint>.Empty;
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
[SwaggerItemsMinMax(MinimumUint = 1, MaximumUint = uint.MaxValue)]
|
||||
public ImmutableHashSet<uint> SecretDepotIDs { get; private set; } = ImmutableHashSet<uint>.Empty;
|
||||
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
public bool SkipAutoGrantPackages { get; private set; }
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
[SwaggerItemsMinMax(MinimumUint = 1, MaximumUint = uint.MaxValue)]
|
||||
public ImmutableHashSet<uint> SecretPackageIDs { get; private set; } = ImmutableHashSet<uint>.Empty;
|
||||
|
||||
[JsonConstructor]
|
||||
internal SteamTokenDumperConfig() { }
|
||||
}
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
public bool SkipAutoGrantPackages { get; private set; }
|
||||
|
||||
[JsonConstructor]
|
||||
internal SteamTokenDumperConfig() { }
|
||||
}
|
||||
|
||||
@@ -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<GlobalConfigExtension> 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<GlobalConfigExtension> Get() => Ok(new GlobalConfigExtension());
|
||||
}
|
||||
|
||||
@@ -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<Bot, IDisposable> BotSubscriptions = new();
|
||||
private static readonly ConcurrentDictionary<Bot, (SemaphoreSlim RefreshSemaphore, Timer RefreshTimer)> 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<Bot, IDisposable> BotSubscriptions = new();
|
||||
private static readonly ConcurrentDictionary<Bot, (SemaphoreSlim RefreshSemaphore, Timer RefreshTimer)> 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<uint> 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<string, JToken>? additionalConfigProperties = null) {
|
||||
if (!SharedInfo.HasValidToken) {
|
||||
ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.PluginDisabledMissingBuildToken, nameof(SteamTokenDumperPlugin)));
|
||||
public Task<uint> GetPreferredChangeNumberToStartFrom() => Task.FromResult(Config?.Enabled == true ? GlobalCache?.LastChangeNumber ?? 0 : 0);
|
||||
|
||||
public async void OnASFInit(IReadOnlyDictionary<string, JToken>? 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<SteamTokenDumperConfig>();
|
||||
|
||||
break;
|
||||
case nameof(GlobalConfigExtension.SteamTokenDumperPluginEnabled):
|
||||
isEnabled = configValue.Value<bool>();
|
||||
|
||||
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<SteamApps.LicenseListCallback>(callback => OnLicenseList(bot, callback));
|
||||
|
||||
if (!BotSubscriptions.TryAdd(bot, subscription)) {
|
||||
subscription.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<ClientMsgHandler>? OnBotSteamHandlersInit(Bot bot) => null;
|
||||
|
||||
public override void OnLoaded() => Utilities.WarnAboutIncompleteTranslation(Strings.ResourceManager);
|
||||
|
||||
public void OnPICSChanges(uint currentChangeNumber, IReadOnlyDictionary<uint, SteamApps.PICSChangesCallback.PICSChangeData> appChanges, IReadOnlyDictionary<uint, SteamApps.PICSChangesCallback.PICSChangeData> 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<uint, ulong> 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<uint>? 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<uint> appIDsToRefresh = new();
|
||||
|
||||
foreach (uint packageID in packageIDs.Where(static packageID => !Config.SecretPackageIDs.Contains(packageID))) {
|
||||
if (!ASF.GlobalDatabase.PackagesDataReadOnly.TryGetValue(packageID, out (uint ChangeNumber, ImmutableHashSet<uint>? 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<uint> appIDsThisRound = new(Math.Min(appIDsToRefresh.Count, SharedInfo.AppInfosPerSingleRequest));
|
||||
|
||||
using (HashSet<uint>.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<SteamTokenDumperConfig>();
|
||||
|
||||
break;
|
||||
case nameof(GlobalConfigExtension.SteamTokenDumperPluginEnabled):
|
||||
isEnabled = configValue.Value<bool>();
|
||||
|
||||
break;
|
||||
}
|
||||
response = await bot.SteamApps.PICSGetAccessTokens(appIDsThisRound, Enumerable.Empty<uint>()).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<SteamApps.LicenseListCallback>(callback => OnLicenseList(bot, callback));
|
||||
|
||||
if (!BotSubscriptions.TryAdd(bot, subscription)) {
|
||||
subscription.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<ClientMsgHandler>? OnBotSteamHandlersInit(Bot bot) => null;
|
||||
|
||||
public override void OnLoaded() => Utilities.WarnAboutIncompleteTranslation(Strings.ResourceManager);
|
||||
|
||||
public void OnPICSChanges(uint currentChangeNumber, IReadOnlyDictionary<uint, SteamApps.PICSChangesCallback.PICSChangeData> appChanges, IReadOnlyDictionary<uint, SteamApps.PICSChangesCallback.PICSChangeData> 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<uint, ulong> 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<uint>? 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<uint> appIDsToRefresh = new();
|
||||
|
||||
foreach (uint packageID in packageIDs.Where(static packageID => !Config.SecretPackageIDs.Contains(packageID))) {
|
||||
if (!ASF.GlobalDatabase.PackagesDataReadOnly.TryGetValue(packageID, out (uint ChangeNumber, ImmutableHashSet<uint>? AppIDs) packageData) || (packageData.AppIDs == null)) {
|
||||
// ASF might not have the package info for us at the moment, we'll retry later
|
||||
continue;
|
||||
using (HashSet<uint>.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<SteamApps.PICSProductInfoCallback>.ResultSet response;
|
||||
|
||||
HashSet<uint> 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<SteamApps.PICSRequest>()).ToLongRunningTask().ConfigureAwait(false);
|
||||
} catch (Exception e) {
|
||||
bot.ArchiLogger.LogGenericWarningException(e);
|
||||
|
||||
using (HashSet<uint>.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<uint, uint> appChangeNumbers = new();
|
||||
|
||||
HashSet<Task<SteamApps.DepotKeyCallback>> 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<SteamApps.DepotKeyCallback> results;
|
||||
|
||||
try {
|
||||
response = await bot.SteamApps.PICSGetAccessTokens(appIDsThisRound, Enumerable.Empty<uint>()).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<uint>.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<SteamApps.PICSProductInfoCallback>.ResultSet response;
|
||||
|
||||
try {
|
||||
response = await bot.SteamApps.PICSGetProductInfo(appIDsThisRound.Select(static appID => new SteamApps.PICSRequest(appID, GlobalCache.GetAppToken(appID))), Enumerable.Empty<SteamApps.PICSRequest>()).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<uint, uint> appChangeNumbers = new();
|
||||
|
||||
HashSet<Task<SteamApps.DepotKeyCallback>> 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<SteamApps.DepotKeyCallback> 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<uint, ulong> appTokens = GlobalCache.GetAppTokensForSubmission();
|
||||
Dictionary<uint, ulong> packageTokens = GlobalCache.GetPackageTokensForSubmission();
|
||||
Dictionary<uint, string> 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<uint, ulong> appTokens = GlobalCache.GetAppTokensForSubmission();
|
||||
Dictionary<uint, ulong> packageTokens = GlobalCache.GetPackageTokensForSubmission();
|
||||
Dictionary<uint, string> 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<ResponseData>? response = await ASF.WebBrowser.UrlPostToJsonObject<ResponseData, RequestData>(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<ResponseData>? response = await ASF.WebBrowser.UrlPostToJsonObject<ResponseData, RequestData>(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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<uint, byte> itemsPerSet = new() {
|
||||
{ relevantAppID, MinCardsPerBadge },
|
||||
{ 43, MinCardsPerBadge + 1 }
|
||||
};
|
||||
[TestClass]
|
||||
public sealed class Bot {
|
||||
[TestMethod]
|
||||
public void MaxItemsBarelyEnoughForOneSet() {
|
||||
const uint relevantAppID = 42;
|
||||
|
||||
HashSet<Asset> items = new();
|
||||
Dictionary<uint, byte> 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<Asset> items = new();
|
||||
|
||||
foreach ((uint appID, byte cards) in itemsPerSet) {
|
||||
for (byte i = 1; i <= cards; i++) {
|
||||
items.Add(CreateCard(i, appID));
|
||||
}
|
||||
|
||||
HashSet<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID),
|
||||
CreateCard(2, appID)
|
||||
};
|
||||
HashSet<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID),
|
||||
CreateCard(1, appID),
|
||||
CreateCard(2, appID),
|
||||
CreateCard(3, appID)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID),
|
||||
CreateCard(1, appID),
|
||||
CreateCard(2, appID),
|
||||
CreateCard(2, appID)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID, 2),
|
||||
CreateCard(2, appID),
|
||||
CreateCard(2, appID)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> 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<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID),
|
||||
CreateCard(2, appID)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID),
|
||||
CreateCard(2, appID)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID0),
|
||||
CreateCard(1, appID1)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToSend = GetItemsForFullBadge(
|
||||
items, new Dictionary<uint, byte> {
|
||||
{ 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<Asset> items = new() {
|
||||
CreateCard(1, appID0),
|
||||
CreateCard(1, appID1)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToSend = GetItemsForFullBadge(
|
||||
items, new Dictionary<uint, byte> {
|
||||
{ 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<Asset> items = new() {
|
||||
CreateCard(1, appID0),
|
||||
CreateCard(2, appID0),
|
||||
|
||||
CreateCard(1, appID1),
|
||||
CreateCard(2, appID1),
|
||||
CreateCard(3, appID1)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToSend = GetItemsForFullBadge(
|
||||
items, new Dictionary<uint, byte> {
|
||||
{ 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<Asset> items = new() {
|
||||
CreateCard(1, appID, rarity: Asset.ERarity.Common),
|
||||
CreateCard(1, appID, rarity: Asset.ERarity.Rare)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID, rarity: Asset.ERarity.Common),
|
||||
CreateCard(1, appID, rarity: Asset.ERarity.Rare)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID),
|
||||
CreateCard(1, appID),
|
||||
CreateCard(2, appID),
|
||||
CreateCard(3, appID)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToSend = GetItemsForFullBadge(items, 3, appID);
|
||||
HashSet<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID, type: Asset.EType.TradingCard),
|
||||
CreateCard(1, appID, type: Asset.EType.FoilTradingCard)
|
||||
};
|
||||
HashSet<Asset> items = new() {
|
||||
CreateCard(1, appID),
|
||||
CreateCard(1, appID),
|
||||
CreateCard(2, appID),
|
||||
CreateCard(2, appID)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToSend = GetItemsForFullBadge(items, 1, appID);
|
||||
HashSet<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID, type: Asset.EType.TradingCard),
|
||||
CreateCard(1, appID, type: Asset.EType.FoilTradingCard)
|
||||
};
|
||||
HashSet<Asset> items = new() {
|
||||
CreateCard(1, appID, 2),
|
||||
CreateCard(2, appID),
|
||||
CreateCard(2, appID)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToSend = GetItemsForFullBadge(items, 2, appID);
|
||||
HashSet<Asset> 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<Asset> 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<Asset> 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<Asset> 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<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID),
|
||||
CreateCard(2, appID)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToSend = GetItemsForFullBadge(items, 3, appID);
|
||||
|
||||
Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new(0);
|
||||
AssertResultMatchesExpectation(expectedResult, itemsToSend);
|
||||
}
|
||||
|
||||
HashSet<Asset> items = new() {
|
||||
CreateCard(1, appID0, 2),
|
||||
CreateCard(2, appID0)
|
||||
};
|
||||
[TestMethod]
|
||||
public void OneSet() {
|
||||
const uint appID = 42;
|
||||
|
||||
HashSet<Asset> items = new() {
|
||||
CreateCard(1, appID),
|
||||
CreateCard(2, appID)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToSend = GetItemsForFullBadge(items, 2, appID);
|
||||
|
||||
HashSet<Asset> 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<Asset> items = new();
|
||||
|
||||
for (byte i = 0; i < Steam.Exchange.Trading.MaxItemsPerTrade; i++) {
|
||||
items.Add(CreateCard(1, appID));
|
||||
items.Add(CreateCard(2, appID));
|
||||
HashSet<Asset> items = new() {
|
||||
CreateCard(1, appID0),
|
||||
CreateCard(1, appID1)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToSend = GetItemsForFullBadge(
|
||||
items, new Dictionary<uint, byte> {
|
||||
{ appID0, 1 },
|
||||
{ appID1, 1 }
|
||||
}
|
||||
);
|
||||
|
||||
HashSet<Asset> 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<Asset> items = new();
|
||||
HashSet<Asset> 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<uint, byte> itemsPerSet = new() {
|
||||
HashSet<Asset> itemsToSend = GetItemsForFullBadge(
|
||||
items, new Dictionary<uint, byte> {
|
||||
{ appID0, 2 },
|
||||
{ appID1, 2 }
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID0),
|
||||
CreateCard(2, appID0),
|
||||
CreateCard(3, appID0),
|
||||
CreateCard(4, appID0)
|
||||
};
|
||||
|
||||
GetItemsForFullBadge(
|
||||
items, new Dictionary<uint, byte> {
|
||||
{ appID0, 3 },
|
||||
{ appID1, 3 },
|
||||
{ appID2, 3 }
|
||||
}
|
||||
);
|
||||
|
||||
Assert.Fail();
|
||||
}
|
||||
|
||||
private static void AssertResultMatchesExpectation(IReadOnlyDictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult, IReadOnlyCollection<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID0),
|
||||
CreateCard(2, appID0),
|
||||
|
||||
CreateCard(1, appID1),
|
||||
CreateCard(2, appID1),
|
||||
CreateCard(3, appID1)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToSend = GetItemsForFullBadge(
|
||||
items, new Dictionary<uint, byte> {
|
||||
{ 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<Asset> items = new() {
|
||||
CreateCard(1, appID, rarity: Asset.ERarity.Common),
|
||||
CreateCard(1, appID, rarity: Asset.ERarity.Rare)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID, rarity: Asset.ERarity.Common),
|
||||
CreateCard(1, appID, rarity: Asset.ERarity.Rare)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> 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<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID, type: Asset.EType.TradingCard),
|
||||
CreateCard(1, appID, type: Asset.EType.FoilTradingCard)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID, type: Asset.EType.TradingCard),
|
||||
CreateCard(1, appID, type: Asset.EType.FoilTradingCard)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> 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<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID0, 2),
|
||||
CreateCard(2, appID0)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> 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<Asset> itemsToSend = GetItemsForFullBadge(items, 2, appID);
|
||||
|
||||
private static HashSet<Asset> GetItemsForFullBadge(IReadOnlyCollection<Asset> inventory, byte cardsPerSet, uint appID, ushort maxItems = Steam.Exchange.Trading.MaxItemsPerTrade) => GetItemsForFullBadge(inventory, new Dictionary<uint, byte> { { appID, cardsPerSet } }, maxItems);
|
||||
Assert.IsTrue(itemsToSend.Count <= Steam.Exchange.Trading.MaxItemsPerTrade);
|
||||
}
|
||||
|
||||
private static HashSet<Asset> GetItemsForFullBadge(IReadOnlyCollection<Asset> inventory, IDictionary<uint, byte> cardsPerSet, ushort maxItems = Steam.Exchange.Trading.MaxItemsPerTrade) {
|
||||
Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), List<uint>> 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<Asset> 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<uint, byte> itemsPerSet = new() {
|
||||
{ appID0, 2 },
|
||||
{ appID1, 2 }
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> items = new() {
|
||||
CreateCard(1, appID0),
|
||||
CreateCard(2, appID0),
|
||||
CreateCard(3, appID0),
|
||||
CreateCard(4, appID0)
|
||||
};
|
||||
|
||||
GetItemsForFullBadge(
|
||||
items, new Dictionary<uint, byte> {
|
||||
{ appID0, 3 },
|
||||
{ appID1, 3 },
|
||||
{ appID2, 3 }
|
||||
}
|
||||
);
|
||||
|
||||
Assert.Fail();
|
||||
}
|
||||
|
||||
private static void AssertResultMatchesExpectation(IReadOnlyDictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult, IReadOnlyCollection<Asset> 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<Asset> GetItemsForFullBadge(IReadOnlyCollection<Asset> inventory, byte cardsPerSet, uint appID, ushort maxItems = Steam.Exchange.Trading.MaxItemsPerTrade) => GetItemsForFullBadge(inventory, new Dictionary<uint, byte> { { appID, cardsPerSet } }, maxItems);
|
||||
|
||||
private static HashSet<Asset> GetItemsForFullBadge(IReadOnlyCollection<Asset> inventory, IDictionary<uint, byte> cardsPerSet, ushort maxItems = Steam.Exchange.Trading.MaxItemsPerTrade) {
|
||||
Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), List<uint>> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false);
|
||||
string longLine = new('a', longLineLength - 1);
|
||||
string message = $"{longLine}[";
|
||||
|
||||
Assert.AreEqual(2, output.Count);
|
||||
List<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> output = await GetMessageParts(message, prefix).ToListAsync().ConfigureAwait(false);
|
||||
const string message = "asdf";
|
||||
|
||||
Assert.AreEqual(1, output.Count);
|
||||
Assert.AreEqual(escapedPrefix + message, output.First());
|
||||
}
|
||||
List<string> 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<string> 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<string> 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 = @"<XLimited5> Уже имеет: app/1493800 | Aircraft Carrier Survival: Prolouge
|
||||
[TestMethod]
|
||||
public async Task RyzhehvostInitialTestForSplitting() {
|
||||
const string prefix = "/me ";
|
||||
|
||||
const string message = @"<XLimited5> Уже имеет: app/1493800 | Aircraft Carrier Survival: Prolouge
|
||||
<XLimited5> Уже имеет: app/349520 | Armillo
|
||||
<XLimited5> Уже имеет: app/346330 | BrainBread 2
|
||||
<XLimited5> Уже имеет: app/1086690 | C-War 2
|
||||
@@ -254,82 +255,81 @@ namespace ArchiSteamFarm.Tests {
|
||||
<ASF> 1/1 ботов уже имеют игру app/269710 | Tumblestone.
|
||||
<ASF> 1/1 ботов уже имеют игру app/304930 | Unturned.";
|
||||
|
||||
List<string> output = await GetMessageParts(message, prefix).ToListAsync().ConfigureAwait(false);
|
||||
List<string> 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<string> 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<string> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Asset> itemsToGive = new() { CreateItem(1, rarity: Asset.ERarity.Rare) };
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(2) };
|
||||
namespace ArchiSteamFarm.Tests;
|
||||
|
||||
Assert.IsFalse(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
}
|
||||
[TestClass]
|
||||
public sealed class Trading {
|
||||
[TestMethod]
|
||||
public void MismatchRarityIsNotFair() {
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(1, rarity: Asset.ERarity.Rare) };
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(2) };
|
||||
|
||||
[TestMethod]
|
||||
public void MismatchRealAppIDsIsNotFair() {
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(1, realAppID: 570) };
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(2) };
|
||||
|
||||
Assert.IsFalse(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MismatchTypesIsNotFair() {
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(1, type: Asset.EType.Emoticon) };
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(2) };
|
||||
|
||||
Assert.IsFalse(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MultiGameMultiTypeBadReject() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1, 9),
|
||||
CreateItem(3, 9, 730, Asset.EType.Emoticon),
|
||||
CreateItem(4, realAppID: 730, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(4, realAppID: 730, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> inventory = new() {
|
||||
CreateItem(1, 9),
|
||||
CreateItem(3, realAppID: 730, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(3, realAppID: 730, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> inventory = new() {
|
||||
CreateItem(1, 9),
|
||||
CreateItem(3, realAppID: 730),
|
||||
CreateItem(4, realAppID: 730)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(3, realAppID: 730)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() {
|
||||
CreateItem(2),
|
||||
CreateItem(4, realAppID: 730)
|
||||
};
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MultiGameSingleTypeNeutralAccept() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1, 2),
|
||||
CreateItem(3, realAppID: 730)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(3, realAppID: 730)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() {
|
||||
CreateItem(2),
|
||||
CreateItem(4, realAppID: 730)
|
||||
};
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameAbrynosWasWrongNeutralAccept() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2, 2),
|
||||
CreateItem(3),
|
||||
CreateItem(4),
|
||||
CreateItem(5)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() {
|
||||
CreateItem(3)
|
||||
};
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameDonationAccept() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> inventory = new() {
|
||||
CreateItem(1, 9),
|
||||
CreateItem(3, 9, type: Asset.EType.Emoticon),
|
||||
CreateItem(4, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(4, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> inventory = new() {
|
||||
CreateItem(1, 9),
|
||||
CreateItem(3, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(3, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> inventory = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2),
|
||||
CreateItem(3)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2),
|
||||
CreateItem(3)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(4, 3) };
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameQuantityBadReject2() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2, 2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2, 2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(3, 3) };
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameQuantityNeutralAccept() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1, 2),
|
||||
CreateItem(2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(3, 2) };
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameSingleTypeBadReject() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(1) };
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(2) };
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameSingleTypeBadWithOverpayingReject() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1, 2),
|
||||
CreateItem(2, 2),
|
||||
CreateItem(3, 2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(2) };
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(3)
|
||||
};
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameSingleTypeBigDifferenceAccept() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2, 5),
|
||||
CreateItem(3)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(2) };
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(3) };
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameSingleTypeBigDifferenceReject() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2, 2),
|
||||
CreateItem(3, 2),
|
||||
CreateItem(4, 3),
|
||||
CreateItem(5, 10)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(2),
|
||||
CreateItem(5)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() {
|
||||
CreateItem(3),
|
||||
CreateItem(4)
|
||||
};
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameSingleTypeGoodAccept() {
|
||||
HashSet<Asset> inventory = new() { CreateItem(1, 2) };
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(1) };
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(2) };
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameSingleTypeNeutralAccept() {
|
||||
HashSet<Asset> inventory = new() { CreateItem(1) };
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(1) };
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(2) };
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameSingleTypeNeutralWithOverpayingAccept() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1, 2),
|
||||
CreateItem(2, 2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(2) };
|
||||
|
||||
HashSet<Asset> 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<Asset> itemsToGive = new() { CreateItem(1, realAppID: 570) };
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(2) };
|
||||
|
||||
Assert.IsFalse(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MismatchTypesIsNotFair() {
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(1, type: Asset.EType.Emoticon) };
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(2) };
|
||||
|
||||
Assert.IsFalse(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MultiGameMultiTypeBadReject() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1, 9),
|
||||
CreateItem(3, 9, 730, Asset.EType.Emoticon),
|
||||
CreateItem(4, realAppID: 730, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(4, realAppID: 730, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> inventory = new() {
|
||||
CreateItem(1, 9),
|
||||
CreateItem(3, realAppID: 730, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(3, realAppID: 730, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> inventory = new() {
|
||||
CreateItem(1, 9),
|
||||
CreateItem(3, realAppID: 730),
|
||||
CreateItem(4, realAppID: 730)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(3, realAppID: 730)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() {
|
||||
CreateItem(2),
|
||||
CreateItem(4, realAppID: 730)
|
||||
};
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MultiGameSingleTypeNeutralAccept() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1, 2),
|
||||
CreateItem(3, realAppID: 730)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(3, realAppID: 730)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() {
|
||||
CreateItem(2),
|
||||
CreateItem(4, realAppID: 730)
|
||||
};
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameAbrynosWasWrongNeutralAccept() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2, 2),
|
||||
CreateItem(3),
|
||||
CreateItem(4),
|
||||
CreateItem(5)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() {
|
||||
CreateItem(3)
|
||||
};
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameDonationAccept() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> inventory = new() {
|
||||
CreateItem(1, 9),
|
||||
CreateItem(3, 9, type: Asset.EType.Emoticon),
|
||||
CreateItem(4, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(4, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> inventory = new() {
|
||||
CreateItem(1, 9),
|
||||
CreateItem(3, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(3, type: Asset.EType.Emoticon)
|
||||
};
|
||||
|
||||
HashSet<Asset> 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<Asset> inventory = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2),
|
||||
CreateItem(3)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2),
|
||||
CreateItem(3)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(4, 3) };
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameQuantityBadReject2() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2, 2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2, 2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(3, 3) };
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameQuantityNeutralAccept() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1, 2),
|
||||
CreateItem(2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(3, 2) };
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameSingleTypeBadReject() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(1) };
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(2) };
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameSingleTypeBadWithOverpayingReject() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1, 2),
|
||||
CreateItem(2, 2),
|
||||
CreateItem(3, 2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(2) };
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(3)
|
||||
};
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameSingleTypeBigDifferenceAccept() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2, 5),
|
||||
CreateItem(3)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(2) };
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(3) };
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameSingleTypeBigDifferenceReject() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1),
|
||||
CreateItem(2, 2),
|
||||
CreateItem(3, 2),
|
||||
CreateItem(4, 3),
|
||||
CreateItem(5, 10)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() {
|
||||
CreateItem(2),
|
||||
CreateItem(5)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToReceive = new() {
|
||||
CreateItem(3),
|
||||
CreateItem(4)
|
||||
};
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsFalse(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameSingleTypeGoodAccept() {
|
||||
HashSet<Asset> inventory = new() { CreateItem(1, 2) };
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(1) };
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(2) };
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameSingleTypeNeutralAccept() {
|
||||
HashSet<Asset> inventory = new() { CreateItem(1) };
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(1) };
|
||||
HashSet<Asset> itemsToReceive = new() { CreateItem(2) };
|
||||
|
||||
Assert.IsTrue(IsFairExchange(itemsToGive, itemsToReceive));
|
||||
Assert.IsTrue(IsTradeNeutralOrBetter(inventory, itemsToGive, itemsToReceive));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleGameSingleTypeNeutralWithOverpayingAccept() {
|
||||
HashSet<Asset> inventory = new() {
|
||||
CreateItem(1, 2),
|
||||
CreateItem(2, 2)
|
||||
};
|
||||
|
||||
HashSet<Asset> itemsToGive = new() { CreateItem(2) };
|
||||
|
||||
HashSet<Asset> 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);
|
||||
}
|
||||
|
||||
@@ -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("10chars<!>asdf", new HashSet<string> { "chars<!>" }).IsWeak);
|
||||
public sealed class Utilities {
|
||||
[TestMethod]
|
||||
public void AdditionallyForbiddenWordsWeakenPassphrases() => Assert.IsTrue(TestPasswordStrength("10chars<!>asdf", new HashSet<string> { "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("10chars<!>asdf").IsWeak);
|
||||
[TestMethod]
|
||||
public void LongPassphraseIsNotWeak() => Assert.IsFalse(TestPasswordStrength("10chars<!>asdf").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
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<PackageReference Include="zxcvbn-core" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' != 'net48' AND ('$(TargetGeneric)' == 'true' OR '$(TargetWindows)' == 'true')">
|
||||
<ItemGroup Condition="'$(TargetFramework)' != 'net48'">
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -23,30 +23,30 @@ using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ArchiSteamFarm.Collections {
|
||||
internal sealed class ConcurrentEnumerator<T> : IEnumerator<T> {
|
||||
public T Current => Enumerator.Current;
|
||||
namespace ArchiSteamFarm.Collections;
|
||||
|
||||
private readonly IEnumerator<T> Enumerator;
|
||||
private readonly IDisposable LockObject;
|
||||
internal sealed class ConcurrentEnumerator<T> : IEnumerator<T> {
|
||||
public T Current => Enumerator.Current;
|
||||
|
||||
object? IEnumerator.Current => Current;
|
||||
private readonly IEnumerator<T> Enumerator;
|
||||
private readonly IDisposable LockObject;
|
||||
|
||||
internal ConcurrentEnumerator(IReadOnlyCollection<T> 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<T> 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();
|
||||
}
|
||||
|
||||
@@ -26,180 +26,180 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace ArchiSteamFarm.Collections {
|
||||
public sealed class ConcurrentHashSet<T> : IReadOnlyCollection<T>, ISet<T> where T : notnull {
|
||||
public event EventHandler? OnModified;
|
||||
namespace ArchiSteamFarm.Collections;
|
||||
|
||||
public int Count => BackingCollection.Count;
|
||||
public bool IsReadOnly => false;
|
||||
public sealed class ConcurrentHashSet<T> : IReadOnlyCollection<T>, ISet<T> where T : notnull {
|
||||
public event EventHandler? OnModified;
|
||||
|
||||
private readonly ConcurrentDictionary<T, bool> BackingCollection;
|
||||
public int Count => BackingCollection.Count;
|
||||
public bool IsReadOnly => false;
|
||||
|
||||
public ConcurrentHashSet() => BackingCollection = new ConcurrentDictionary<T, bool>();
|
||||
private readonly ConcurrentDictionary<T, bool> BackingCollection;
|
||||
|
||||
public ConcurrentHashSet(IEqualityComparer<T> comparer) {
|
||||
if (comparer == null) {
|
||||
throw new ArgumentNullException(nameof(comparer));
|
||||
}
|
||||
public ConcurrentHashSet() => BackingCollection = new ConcurrentDictionary<T, bool>();
|
||||
|
||||
BackingCollection = new ConcurrentDictionary<T, bool>(comparer);
|
||||
public ConcurrentHashSet(IEqualityComparer<T> comparer) {
|
||||
if (comparer == null) {
|
||||
throw new ArgumentNullException(nameof(comparer));
|
||||
}
|
||||
|
||||
public bool Add(T item) {
|
||||
if (!BackingCollection.TryAdd(item, true)) {
|
||||
return false;
|
||||
}
|
||||
BackingCollection = new ConcurrentDictionary<T, bool>(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<T> 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<T> other) {
|
||||
if (other == null) {
|
||||
throw new ArgumentNullException(nameof(other));
|
||||
}
|
||||
|
||||
public IEnumerator<T> GetEnumerator() => BackingCollection.Keys.GetEnumerator();
|
||||
|
||||
public void IntersectWith(IEnumerable<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? other.ToHashSet();
|
||||
|
||||
foreach (T item in this.Where(item => !otherSet.Contains(item))) {
|
||||
Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsProperSubsetOf(IEnumerable<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? other.ToHashSet();
|
||||
|
||||
return (otherSet.Count > Count) && IsSubsetOf(otherSet);
|
||||
}
|
||||
|
||||
public bool IsProperSupersetOf(IEnumerable<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? other.ToHashSet();
|
||||
|
||||
return (otherSet.Count < Count) && IsSupersetOf(otherSet);
|
||||
}
|
||||
|
||||
public bool IsSubsetOf(IEnumerable<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? other.ToHashSet();
|
||||
|
||||
return this.All(otherSet.Contains);
|
||||
}
|
||||
|
||||
public bool IsSupersetOf(IEnumerable<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? other.ToHashSet();
|
||||
|
||||
return otherSet.All(Contains);
|
||||
}
|
||||
|
||||
public bool Overlaps(IEnumerable<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? 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<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? other.ToHashSet();
|
||||
|
||||
return (otherSet.Count == Count) && otherSet.All(Contains);
|
||||
}
|
||||
|
||||
public void SymmetricExceptWith(IEnumerable<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? other.ToHashSet();
|
||||
HashSet<T> 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<T> other) {
|
||||
if (other == null) {
|
||||
throw new ArgumentNullException(nameof(other));
|
||||
}
|
||||
|
||||
foreach (T otherElement in other) {
|
||||
Add(otherElement);
|
||||
}
|
||||
}
|
||||
|
||||
void ICollection<T>.Add(T item) => Add(item);
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
|
||||
[PublicAPI]
|
||||
public bool AddRange(IEnumerable<T> items) {
|
||||
bool result = false;
|
||||
|
||||
foreach (T _ in items.Where(Add)) {
|
||||
result = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public bool RemoveRange(IEnumerable<T> items) {
|
||||
bool result = false;
|
||||
|
||||
foreach (T _ in items.Where(Remove)) {
|
||||
result = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public bool ReplaceIfNeededWith(IReadOnlyCollection<T> other) {
|
||||
if (SetEquals(other)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ReplaceWith(other);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public void ReplaceWith(IEnumerable<T> other) {
|
||||
Clear();
|
||||
UnionWith(other);
|
||||
foreach (T item in other) {
|
||||
Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerator<T> GetEnumerator() => BackingCollection.Keys.GetEnumerator();
|
||||
|
||||
public void IntersectWith(IEnumerable<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? other.ToHashSet();
|
||||
|
||||
foreach (T item in this.Where(item => !otherSet.Contains(item))) {
|
||||
Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsProperSubsetOf(IEnumerable<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? other.ToHashSet();
|
||||
|
||||
return (otherSet.Count > Count) && IsSubsetOf(otherSet);
|
||||
}
|
||||
|
||||
public bool IsProperSupersetOf(IEnumerable<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? other.ToHashSet();
|
||||
|
||||
return (otherSet.Count < Count) && IsSupersetOf(otherSet);
|
||||
}
|
||||
|
||||
public bool IsSubsetOf(IEnumerable<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? other.ToHashSet();
|
||||
|
||||
return this.All(otherSet.Contains);
|
||||
}
|
||||
|
||||
public bool IsSupersetOf(IEnumerable<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? other.ToHashSet();
|
||||
|
||||
return otherSet.All(Contains);
|
||||
}
|
||||
|
||||
public bool Overlaps(IEnumerable<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? 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<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? other.ToHashSet();
|
||||
|
||||
return (otherSet.Count == Count) && otherSet.All(Contains);
|
||||
}
|
||||
|
||||
public void SymmetricExceptWith(IEnumerable<T> other) {
|
||||
ISet<T> otherSet = other as ISet<T> ?? other.ToHashSet();
|
||||
HashSet<T> 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<T> other) {
|
||||
if (other == null) {
|
||||
throw new ArgumentNullException(nameof(other));
|
||||
}
|
||||
|
||||
foreach (T otherElement in other) {
|
||||
Add(otherElement);
|
||||
}
|
||||
}
|
||||
|
||||
void ICollection<T>.Add(T item) => Add(item);
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
|
||||
[PublicAPI]
|
||||
public bool AddRange(IEnumerable<T> items) {
|
||||
bool result = false;
|
||||
|
||||
foreach (T _ in items.Where(Add)) {
|
||||
result = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public bool RemoveRange(IEnumerable<T> items) {
|
||||
bool result = false;
|
||||
|
||||
foreach (T _ in items.Where(Remove)) {
|
||||
result = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public bool ReplaceIfNeededWith(IReadOnlyCollection<T> other) {
|
||||
if (SetEquals(other)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ReplaceWith(other);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public void ReplaceWith(IEnumerable<T> other) {
|
||||
Clear();
|
||||
UnionWith(other);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,95 +23,95 @@ using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using Nito.AsyncEx;
|
||||
|
||||
namespace ArchiSteamFarm.Collections {
|
||||
internal sealed class ConcurrentList<T> : IList<T>, IReadOnlyList<T> {
|
||||
public bool IsReadOnly => false;
|
||||
namespace ArchiSteamFarm.Collections;
|
||||
|
||||
internal int Count {
|
||||
get {
|
||||
using (Lock.ReaderLock()) {
|
||||
return BackingCollection.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
internal sealed class ConcurrentList<T> : IList<T>, IReadOnlyList<T> {
|
||||
public bool IsReadOnly => false;
|
||||
|
||||
private readonly List<T> BackingCollection = new();
|
||||
private readonly AsyncReaderWriterLock Lock = new();
|
||||
|
||||
int ICollection<T>.Count => Count;
|
||||
int IReadOnlyCollection<T>.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<T> GetEnumerator() => new ConcurrentEnumerator<T>(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<T> collection) {
|
||||
using (Lock.WriterLock()) {
|
||||
BackingCollection.Clear();
|
||||
BackingCollection.AddRange(collection);
|
||||
return BackingCollection.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly List<T> BackingCollection = new();
|
||||
private readonly AsyncReaderWriterLock Lock = new();
|
||||
|
||||
int ICollection<T>.Count => Count;
|
||||
int IReadOnlyCollection<T>.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<T> GetEnumerator() => new ConcurrentEnumerator<T>(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<T> collection) {
|
||||
using (Lock.WriterLock()) {
|
||||
BackingCollection.Clear();
|
||||
BackingCollection.AddRange(collection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,53 +25,53 @@ using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using ArchiSteamFarm.Core;
|
||||
|
||||
namespace ArchiSteamFarm.Collections {
|
||||
internal sealed class FixedSizeConcurrentQueue<T> : IEnumerable<T> {
|
||||
private readonly ConcurrentQueue<T> BackingQueue = new();
|
||||
namespace ArchiSteamFarm.Collections;
|
||||
|
||||
internal byte MaxCount {
|
||||
get => BackingMaxCount;
|
||||
internal sealed class FixedSizeConcurrentQueue<T> : IEnumerable<T> {
|
||||
private readonly ConcurrentQueue<T> 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<T> 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<T> 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 _)) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,364 +35,340 @@ 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();
|
||||
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();
|
||||
string runtime = RuntimeInformation.OSArchitecture.ToString();
|
||||
#else
|
||||
string runtime = RuntimeInformation.RuntimeIdentifier.Trim();
|
||||
string runtime = RuntimeInformation.RuntimeIdentifier.Trim();
|
||||
|
||||
if (runtime.Length == 0) {
|
||||
runtime = "Unknown Runtime";
|
||||
}
|
||||
#endif
|
||||
|
||||
string description = RuntimeInformation.OSDescription.Trim();
|
||||
|
||||
if (description.Length == 0) {
|
||||
description = "Unknown OS";
|
||||
}
|
||||
|
||||
BackingVersion = $"{framework}; {runtime}; {description}";
|
||||
|
||||
return BackingVersion;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? BackingVersion;
|
||||
private static Mutex? SingleInstance;
|
||||
|
||||
#if TARGET_GENERIC || TARGET_WINDOWS
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
internal static void CoreInit(bool _) { }
|
||||
#endif
|
||||
|
||||
internal static string GetOsResourceName(string objectName) {
|
||||
if (string.IsNullOrEmpty(objectName)) {
|
||||
throw new ArgumentNullException(nameof(objectName));
|
||||
}
|
||||
|
||||
return $"{SharedInfo.AssemblyName}-{objectName}";
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(optimizationMode));
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsRunningAsRoot() {
|
||||
#if TARGET_GENERIC || TARGET_WINDOWS
|
||||
if (OperatingSystem.IsWindows()) {
|
||||
using WindowsIdentity identity = WindowsIdentity.GetCurrent();
|
||||
|
||||
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
||||
if (runtime.Length == 0) {
|
||||
runtime = "Unknown Runtime";
|
||||
}
|
||||
#endif
|
||||
|
||||
#if TARGET_GENERIC || !TARGET_WINDOWS
|
||||
if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) {
|
||||
return NativeMethods.GetEUID() == 0;
|
||||
}
|
||||
#endif
|
||||
string description = RuntimeInformation.OSDescription.Trim();
|
||||
|
||||
// We can't determine whether user is running as root or not, so fallback to that not happening
|
||||
if (description.Length == 0) {
|
||||
description = "Unknown OS";
|
||||
}
|
||||
|
||||
BackingVersion = $"{framework}; {runtime}; {description}";
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static string GetOsResourceName(string objectName) {
|
||||
if (string.IsNullOrEmpty(objectName)) {
|
||||
throw new ArgumentNullException(nameof(objectName));
|
||||
}
|
||||
|
||||
return $"{SharedInfo.AssemblyName}-{objectName}";
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static async Task<bool> RegisterProcess() {
|
||||
if (SingleInstance != null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static async Task<bool> RegisterProcess() {
|
||||
if (SingleInstance != null) {
|
||||
return false;
|
||||
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);
|
||||
}
|
||||
|
||||
string uniqueName;
|
||||
singleInstance = new Mutex(true, uniqueName, out bool result);
|
||||
|
||||
// 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)}";
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
|
||||
Mutex? singleInstance = null;
|
||||
singleInstance.Dispose();
|
||||
singleInstance = null;
|
||||
}
|
||||
|
||||
for (byte i = 0; i < WebBrowser.MaxTries; i++) {
|
||||
if (i > 0) {
|
||||
await Task.Delay(1000).ConfigureAwait(false);
|
||||
}
|
||||
if (singleInstance == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
singleInstance = new Mutex(true, uniqueName, out bool result);
|
||||
SingleInstance = singleInstance;
|
||||
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
singleInstance.Dispose();
|
||||
singleInstance = null;
|
||||
}
|
||||
[SupportedOSPlatform("FreeBSD")]
|
||||
[SupportedOSPlatform("Linux")]
|
||||
[SupportedOSPlatform("MacOS")]
|
||||
internal static void UnixSetFileAccess(string path, EUnixPermission permission) {
|
||||
if (string.IsNullOrEmpty(path)) {
|
||||
throw new ArgumentNullException(nameof(path));
|
||||
}
|
||||
|
||||
if (singleInstance == null) {
|
||||
return false;
|
||||
}
|
||||
if (!OperatingSystem.IsFreeBSD() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) {
|
||||
throw new PlatformNotSupportedException();
|
||||
}
|
||||
|
||||
SingleInstance = singleInstance;
|
||||
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
|
||||
|
||||
// We're not going to analyze source builds, as we don't know what changes the author has made, assume they have a point
|
||||
if (SharedInfo.BuildInfo.IsCustomBuild) {
|
||||
return true;
|
||||
}
|
||||
|
||||
#if TARGET_GENERIC || !TARGET_WINDOWS
|
||||
[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()));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
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;
|
||||
// All windows variants have valid .NET Core build, and generic-netf is supported only on mono
|
||||
if (OperatingSystem.IsWindows() || !RuntimeMadness.IsRunningOnMono) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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
|
||||
return RuntimeInformation.OSArchitecture switch {
|
||||
// Sadly we can't tell a difference between ARMv6 and ARMv7 reliably, we'll believe that this linux-arm user knows what he's doing and he's indeed in need of generic-netf on ARMv6
|
||||
Architecture.Arm => true,
|
||||
|
||||
// We're not going to analyze source builds, as we don't know what changes the author has made, assume they have a point
|
||||
if (SharedInfo.BuildInfo.IsCustomBuild) {
|
||||
return true;
|
||||
}
|
||||
// Apart from real x86, this also covers all unknown architectures, such as sparc, ppc64, and anything else Mono might support, we're fine with that
|
||||
Architecture.X86 => true,
|
||||
|
||||
// All windows variants have valid .NET Core build, and generic-netf is supported only on mono
|
||||
if (OperatingSystem.IsWindows() || !RuntimeMadness.IsRunningOnMono) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return RuntimeInformation.OSArchitecture switch {
|
||||
// Sadly we can't tell a difference between ARMv6 and ARMv7 reliably, we'll believe that this linux-arm user knows what he's doing and he's indeed in need of generic-netf on ARMv6
|
||||
Architecture.Arm => true,
|
||||
|
||||
// Apart from real x86, this also covers all unknown architectures, such as sparc, ppc64, and anything else Mono might support, we're fine with that
|
||||
Architecture.X86 => true,
|
||||
|
||||
// Everything else is covered by .NET Core
|
||||
_ => false
|
||||
};
|
||||
// Everything else is covered by .NET Core
|
||||
_ => false
|
||||
};
|
||||
#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();
|
||||
}
|
||||
|
||||
#if TARGET_GENERIC || TARGET_WINDOWS
|
||||
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")]
|
||||
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 EExecutionState AwakeExecutionState = EExecutionState.SystemRequired | EExecutionState.AwayModeRequired | EExecutionState.Continuous;
|
||||
|
||||
[SupportedOSPlatform("Windows")]
|
||||
private static void WindowsKeepSystemActive() {
|
||||
if (!OperatingSystem.IsWindows()) {
|
||||
throw new PlatformNotSupportedException();
|
||||
}
|
||||
internal const uint EnableQuickEditMode = 0x0040;
|
||||
|
||||
// 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);
|
||||
[SupportedOSPlatform("Windows")]
|
||||
internal const sbyte StandardInputHandle = -10;
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if TARGET_GENERIC || !TARGET_WINDOWS
|
||||
[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
|
||||
}
|
||||
#endif
|
||||
|
||||
private static class NativeMethods {
|
||||
#if TARGET_GENERIC || TARGET_WINDOWS
|
||||
[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;
|
||||
#endif
|
||||
|
||||
#if TARGET_GENERIC || !TARGET_WINDOWS
|
||||
#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
|
||||
#endif
|
||||
|
||||
#if TARGET_GENERIC || TARGET_WINDOWS
|
||||
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
|
||||
[DllImport("kernel32.dll")]
|
||||
[SupportedOSPlatform("Windows")]
|
||||
internal static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);
|
||||
#endif
|
||||
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
|
||||
[DllImport("kernel32.dll")]
|
||||
[SupportedOSPlatform("Windows")]
|
||||
internal static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);
|
||||
|
||||
#if TARGET_GENERIC || !TARGET_WINDOWS
|
||||
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
|
||||
[DllImport("libc", EntryPoint = "geteuid", SetLastError = true)]
|
||||
[SupportedOSPlatform("FreeBSD")]
|
||||
[SupportedOSPlatform("Linux")]
|
||||
[SupportedOSPlatform("MacOS")]
|
||||
internal static extern uint GetEUID();
|
||||
#endif
|
||||
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
|
||||
[DllImport("libc", EntryPoint = "geteuid", SetLastError = true)]
|
||||
[SupportedOSPlatform("FreeBSD")]
|
||||
[SupportedOSPlatform("Linux")]
|
||||
[SupportedOSPlatform("MacOS")]
|
||||
internal static extern uint GetEUID();
|
||||
|
||||
#if TARGET_GENERIC || TARGET_WINDOWS
|
||||
[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
|
||||
}
|
||||
#endif
|
||||
[Flags]
|
||||
[SupportedOSPlatform("Windows")]
|
||||
internal enum EExecutionState : uint {
|
||||
None = 0,
|
||||
SystemRequired = 0x00000001,
|
||||
AwayModeRequired = 0x00000040,
|
||||
Continuous = 0x80000000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string> 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<string> 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<char>(), 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<char>(), 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;
|
||||
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<T>(Func<T> 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<T>(Func<T> function, bool longRunning = false) {
|
||||
if (function == null) {
|
||||
throw new ArgumentNullException(nameof(function));
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public static async Task<IList<T>> InParallel<T>(IEnumerable<Task<T>> tasks) {
|
||||
if (tasks == null) {
|
||||
throw new ArgumentNullException(nameof(tasks));
|
||||
}
|
||||
TaskCreationOptions options = TaskCreationOptions.DenyChildAttach;
|
||||
|
||||
IList<T> results;
|
||||
|
||||
switch (ASF.GlobalConfig?.OptimizationMode) {
|
||||
case GlobalConfig.EOptimizationMode.MinMemoryUsage:
|
||||
results = new List<T>();
|
||||
|
||||
foreach (Task<T> 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<Task> 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<IList<T>> InParallel<T>(IEnumerable<Task<T>> 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<T> 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<T>();
|
||||
|
||||
[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<IElement> SelectElementNodes(this IElement element, string xpath) => element.SelectNodes(xpath).OfType<IElement>();
|
||||
|
||||
[PublicAPI]
|
||||
public static IEnumerable<IElement> SelectNodes(this IDocument document, string xpath) {
|
||||
if (document == null) {
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
}
|
||||
|
||||
return document.Body.SelectNodes(xpath).OfType<IElement>();
|
||||
}
|
||||
|
||||
[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<T> ToEnumerable<T>(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<T> ToLongRunningTask<T>(this AsyncJob<T> job) where T : CallbackMsg {
|
||||
if (job == null) {
|
||||
throw new ArgumentNullException(nameof(job));
|
||||
}
|
||||
|
||||
job.Timeout = TimeSpan.FromSeconds(TimeoutForLongRunningTasksInSeconds);
|
||||
|
||||
return job.ToTask();
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public static Task<AsyncJobMultiple<T>.ResultSet> ToLongRunningTask<T>(this AsyncJobMultiple<T> 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<T> 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<Task> 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<IElement> SelectElementNodes(this IElement element, string xpath) => element.SelectNodes(xpath).OfType<IElement>();
|
||||
|
||||
[PublicAPI]
|
||||
public static IEnumerable<IElement> SelectNodes(this IDocument document, string xpath) {
|
||||
if (document == null) {
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
}
|
||||
|
||||
return document.Body.SelectNodes(xpath).OfType<IElement>();
|
||||
}
|
||||
|
||||
[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<T> ToEnumerable<T>(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<T> ToLongRunningTask<T>(this AsyncJob<T> job) where T : CallbackMsg {
|
||||
if (job == null) {
|
||||
throw new ArgumentNullException(nameof(job));
|
||||
}
|
||||
|
||||
job.Timeout = TimeSpan.FromSeconds(TimeoutForLongRunningTasksInSeconds);
|
||||
|
||||
return job.ToTask();
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public static Task<AsyncJobMultiple<T>.ResultSet> ToLongRunningTask<T>(this AsyncJobMultiple<T> 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<string>? 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<string> 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<string>? 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<string> 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<DictionaryEntry> defaultStringObjects = defaultResourceSet.Cast<DictionaryEntry>().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<DictionaryEntry> currentStringObjects = currentResourceSet.Cast<DictionaryEntry>().ToHashSet();
|
||||
|
||||
if (currentStringObjects.Count >= defaultStringObjects.Count) {
|
||||
// Either we have 100% finished translation, or we're missing it entirely and using en-US
|
||||
HashSet<DictionaryEntry> 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<DictionaryEntry> defaultStringObjects = defaultResourceSet.Cast<DictionaryEntry>().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<DictionaryEntry> currentStringObjects = currentResourceSet.Cast<DictionaryEntry>().ToHashSet();
|
||||
|
||||
if (currentStringObjects.Count >= defaultStringObjects.Count) {
|
||||
// Either we have 100% finished translation, or we're missing it entirely and using en-US
|
||||
HashSet<DictionaryEntry> 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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,87 +25,87 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace ArchiSteamFarm.Helpers {
|
||||
public sealed class ArchiCacheable<T> : IDisposable {
|
||||
private readonly TimeSpan CacheLifetime;
|
||||
private readonly SemaphoreSlim InitSemaphore = new(1, 1);
|
||||
private readonly Func<Task<(bool Success, T? Result)>> 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<T> : IDisposable {
|
||||
private readonly TimeSpan CacheLifetime;
|
||||
private readonly SemaphoreSlim InitSemaphore = new(1, 1);
|
||||
private readonly Func<Task<(bool Success, T? Result)>> 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<Task<(bool Success, T? Result)>> resolveFunction, TimeSpan? cacheLifetime = null) {
|
||||
ResolveFunction = resolveFunction ?? throw new ArgumentNullException(nameof(resolveFunction));
|
||||
CacheLifetime = cacheLifetime ?? Timeout.InfiniteTimeSpan;
|
||||
private DateTime InitializedAt;
|
||||
private T? InitializedValue;
|
||||
|
||||
public ArchiCacheable(Func<Task<(bool Success, T? Result)>> 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,275 +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<string> ForbiddenCryptKeyPhrases = ImmutableHashSet.Create(StringComparer.InvariantCultureIgnoreCase, "crypt", "key", "cryptkey");
|
||||
internal static bool HasDefaultCryptKey { get; private set; } = true;
|
||||
|
||||
private static IEnumerable<byte> SteamParentalCharacters => Enumerable.Range('0', 10).Select(static character => (byte) character);
|
||||
private static readonly ImmutableHashSet<string> ForbiddenCryptKeyPhrases = ImmutableHashSet.Create(StringComparer.InvariantCultureIgnoreCase, "crypt", "key", "cryptkey");
|
||||
|
||||
private static IEnumerable<byte[]> SteamParentalCodes {
|
||||
get {
|
||||
HashSet<byte> steamParentalCharacters = SteamParentalCharacters.ToHashSet();
|
||||
private static IEnumerable<byte> 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<byte[]> SteamParentalCodes {
|
||||
get {
|
||||
HashSet<byte> 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 TARGET_GENERIC || TARGET_WINDOWS
|
||||
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;
|
||||
}
|
||||
#else
|
||||
return null;
|
||||
#endif
|
||||
}
|
||||
|
||||
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 TARGET_GENERIC || TARGET_WINDOWS
|
||||
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;
|
||||
}
|
||||
#else
|
||||
return null;
|
||||
#endif
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,202 +19,189 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#if TARGET_GENERIC || TARGET_WINDOWS
|
||||
using System.Security.AccessControl;
|
||||
#endif
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Security.AccessControl;
|
||||
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<bool> 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 TARGET_GENERIC || TARGET_WINDOWS
|
||||
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);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if TARGET_GENERIC || !TARGET_WINDOWS
|
||||
if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) {
|
||||
OS.UnixSetFileAccess(directoryPath!, OS.EUnixPermission.Combined777);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
try {
|
||||
new FileStream(FilePath, FileMode.CreateNew).Dispose();
|
||||
|
||||
#if TARGET_GENERIC || TARGET_WINDOWS
|
||||
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);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if TARGET_GENERIC || !TARGET_WINDOWS
|
||||
if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) {
|
||||
OS.UnixSetFileAccess(FilePath, OS.EUnixPermission.Combined777);
|
||||
}
|
||||
#endif
|
||||
} 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<bool> 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()) {
|
||||
// ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
using System.Threading.Tasks;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace ArchiSteamFarm.Helpers {
|
||||
[PublicAPI]
|
||||
public interface ICrossProcessSemaphore {
|
||||
void Release();
|
||||
Task WaitAsync();
|
||||
Task<bool> WaitAsync(int millisecondsTimeout);
|
||||
}
|
||||
namespace ArchiSteamFarm.Helpers;
|
||||
|
||||
[PublicAPI]
|
||||
public interface ICrossProcessSemaphore {
|
||||
void Release();
|
||||
Task WaitAsync();
|
||||
Task<bool> WaitAsync(int millisecondsTimeout);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<bool> 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<bool> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Startup>();
|
||||
}
|
||||
);
|
||||
|
||||
// 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<Startup>();
|
||||
}
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// Encrypts data with ASF encryption mechanisms using provided details.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("Encrypt")]
|
||||
[ProducesResponseType(typeof(GenericResponse<string>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public ActionResult<GenericResponse> 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<string>(encryptedString));
|
||||
[Route("Api/ASF")]
|
||||
public sealed class ASFController : ArchiController {
|
||||
/// <summary>
|
||||
/// Encrypts data with ASF encryption mechanisms using provided details.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("Encrypt")]
|
||||
[ProducesResponseType(typeof(GenericResponse<string>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public ActionResult<GenericResponse> ASFEncryptPost([FromBody] ASFEncryptRequest request) {
|
||||
if (request == null) {
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches common info related to ASF as a whole.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GenericResponse<ASFResponse>), (int) HttpStatusCode.OK)]
|
||||
public ActionResult<GenericResponse<ASFResponse>> 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<ASFResponse>(result));
|
||||
if (string.IsNullOrEmpty(request.StringToEncrypt)) {
|
||||
return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(request.StringToEncrypt))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts data with ASF encryption mechanisms using provided details.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("Hash")]
|
||||
[ProducesResponseType(typeof(GenericResponse<string>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public ActionResult<GenericResponse> 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<string>(encryptedString));
|
||||
}
|
||||
|
||||
string hash = Actions.Hash(request.HashingMethod, request.StringToHash);
|
||||
|
||||
return Ok(new GenericResponse<string>(hash));
|
||||
/// <summary>
|
||||
/// Fetches common info related to ASF as a whole.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GenericResponse<ASFResponse>), (int) HttpStatusCode.OK)]
|
||||
public ActionResult<GenericResponse<ASFResponse>> ASFGet() {
|
||||
if (ASF.GlobalConfig == null) {
|
||||
throw new InvalidOperationException(nameof(ASF.GlobalConfig));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates ASF's global config.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<ASFResponse>(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<string, JToken>(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));
|
||||
/// <summary>
|
||||
/// Encrypts data with ASF encryption mechanisms using provided details.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("Hash")]
|
||||
[ProducesResponseType(typeof(GenericResponse<string>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public ActionResult<GenericResponse> ASFHashPost([FromBody] ASFHashRequest request) {
|
||||
if (request == null) {
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes ASF shutdown itself.
|
||||
/// </summary>
|
||||
[HttpPost("Exit")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
public ActionResult<GenericResponse> 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))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes ASF restart itself.
|
||||
/// </summary>
|
||||
[HttpPost("Restart")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
public ActionResult<GenericResponse> 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<string>(hash));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates ASF's global config.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> ASFPost([FromBody] ASFRequest request) {
|
||||
if (request == null) {
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes ASF update itself.
|
||||
/// </summary>
|
||||
[HttpPost("Update")]
|
||||
[ProducesResponseType(typeof(GenericResponse<string>), (int) HttpStatusCode.OK)]
|
||||
public async Task<ActionResult<GenericResponse<string>>> 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<string, JToken>(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<string>(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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes ASF shutdown itself.
|
||||
/// </summary>
|
||||
[HttpPost("Exit")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
public ActionResult<GenericResponse> ExitPost() {
|
||||
(bool success, string message) = Actions.Exit();
|
||||
|
||||
return Ok(new GenericResponse(success, message));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes ASF restart itself.
|
||||
/// </summary>
|
||||
[HttpPost("Restart")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
public ActionResult<GenericResponse> RestartPost() {
|
||||
(bool success, string message) = Actions.Restart();
|
||||
|
||||
return Ok(new GenericResponse(success, message));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes ASF update itself.
|
||||
/// </summary>
|
||||
[HttpPost("Update")]
|
||||
[ProducesResponseType(typeof(GenericResponse<string>), (int) HttpStatusCode.OK)]
|
||||
public async Task<ActionResult<GenericResponse<string>>> 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<string>(success, message!, version?.ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<StatusCodeResponse>))]
|
||||
[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<StatusCodeResponse>))]
|
||||
[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<StatusCodeResponse>))]
|
||||
[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<StatusCodeResponse>))]
|
||||
[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 { }
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// Deletes all files related to given bots.
|
||||
/// </summary>
|
||||
[HttpDelete("{botNames:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> BotDelete(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
namespace ArchiSteamFarm.IPC.Controllers.Api;
|
||||
|
||||
HashSet<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
if ((bots == null) || (bots.Count == 0)) {
|
||||
return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)));
|
||||
}
|
||||
|
||||
IList<bool> 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 {
|
||||
/// <summary>
|
||||
/// Deletes all files related to given bots.
|
||||
/// </summary>
|
||||
[HttpDelete("{botNames:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> BotDelete(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches common info related to given bots.
|
||||
/// </summary>
|
||||
[HttpGet("{botNames:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, Bot>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public ActionResult<GenericResponse> BotGet(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
HashSet<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
HashSet<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
if (bots == null) {
|
||||
return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(bots))));
|
||||
}
|
||||
|
||||
return Ok(new GenericResponse<IReadOnlyDictionary<string, Bot>>(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)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates bot config of given bot.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("{botNames:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, bool>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> BotPost(string botNames, [FromBody] BotRequest request) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
IList<bool> 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));
|
||||
}
|
||||
/// <summary>
|
||||
/// Fetches common info related to given bots.
|
||||
/// </summary>
|
||||
[HttpGet("{botNames:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, Bot>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public ActionResult<GenericResponse> BotGet(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
(bool valid, string? errorMessage) = request.BotConfig.CheckValidation();
|
||||
HashSet<Bot>? 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<IReadOnlyDictionary<string, Bot>>(bots.Where(static bot => !string.IsNullOrEmpty(bot.BotName)).ToDictionary(static bot => bot.BotName, static bot => bot, Bot.BotsComparer)));
|
||||
}
|
||||
|
||||
HashSet<string> bots = botNames.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToHashSet(Bot.BotsComparer);
|
||||
/// <summary>
|
||||
/// Updates bot config of given bot.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("{botNames:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, bool>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<string, bool> 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<string, JToken>(bot.BotConfig.AdditionalProperties.Count, bot.BotConfig.AdditionalProperties.Comparer);
|
||||
HashSet<string> 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<string, bool> 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<IReadOnlyDictionary<string, bool>>(result.Values.All(static value => value), result));
|
||||
}
|
||||
if (bot.BotConfig.AdditionalProperties?.Count > 0) {
|
||||
request.BotConfig.AdditionalProperties ??= new Dictionary<string, JToken>(bot.BotConfig.AdditionalProperties.Count, bot.BotConfig.AdditionalProperties.Comparer);
|
||||
|
||||
/// <summary>
|
||||
/// Removes BGR output files of given bots.
|
||||
/// </summary>
|
||||
[HttpDelete("{botNames:required}/GamesToRedeemInBackground")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
if ((bots == null) || (bots.Count == 0)) {
|
||||
return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)));
|
||||
}
|
||||
|
||||
IList<bool> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches BGR output files of given bots.
|
||||
/// </summary>
|
||||
[HttpGet("{botNames:required}/GamesToRedeemInBackground")]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, GamesToRedeemInBackgroundResponse>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> GamesToRedeemInBackgroundGet(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
HashSet<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
if ((bots == null) || (bots.Count == 0)) {
|
||||
return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)));
|
||||
}
|
||||
|
||||
IList<(Dictionary<string, string>? UnusedKeys, Dictionary<string, string>? UsedKeys)> results = await Utilities.InParallel(bots.Select(static bot => bot.GetUsedAndUnusedKeys())).ConfigureAwait(false);
|
||||
|
||||
Dictionary<string, GamesToRedeemInBackgroundResponse> result = new(bots.Count, Bot.BotsComparer);
|
||||
|
||||
foreach (Bot bot in bots) {
|
||||
(Dictionary<string, string>? unusedKeys, Dictionary<string, string>? usedKeys) = results[result.Count];
|
||||
result[bot.BotName] = new GamesToRedeemInBackgroundResponse(unusedKeys, usedKeys);
|
||||
}
|
||||
|
||||
return Ok(new GenericResponse<IReadOnlyDictionary<string, GamesToRedeemInBackgroundResponse>>(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds keys to redeem using BGR to given bot.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("{botNames:required}/GamesToRedeemInBackground")]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, IOrderedDictionary>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<Bot>? 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<string, IOrderedDictionary> result = new(bots.Count, Bot.BotsComparer);
|
||||
|
||||
foreach (Bot bot in bots) {
|
||||
result[bot.BotName] = validGamesToRedeemInBackground;
|
||||
}
|
||||
|
||||
return Ok(new GenericResponse<IReadOnlyDictionary<string, IOrderedDictionary>>(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides input value to given bot for next usage.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("{botNames:required}/Input")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
if ((bots == null) || (bots.Count == 0)) {
|
||||
return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)));
|
||||
}
|
||||
|
||||
IList<bool> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pauses given bots.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("{botNames:required}/Pause")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> PausePost(string botNames, [FromBody] BotPauseRequest request) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
if (request == null) {
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
HashSet<Bot>? 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))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redeems cd-keys on given bot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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).
|
||||
/// </remarks>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("{botNames:required}/Redeem")]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, IReadOnlyDictionary<string, PurchaseResponseCallback>>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
if ((bots == null) || (bots.Count == 0)) {
|
||||
return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)));
|
||||
}
|
||||
|
||||
IList<PurchaseResponseCallback?> results = await Utilities.InParallel(bots.Select(bot => request.KeysToRedeem.Select(key => bot.Actions.RedeemKey(key))).SelectMany(static task => task)).ConfigureAwait(false);
|
||||
|
||||
Dictionary<string, IReadOnlyDictionary<string, PurchaseResponseCallback?>> result = new(bots.Count, Bot.BotsComparer);
|
||||
|
||||
int count = 0;
|
||||
|
||||
foreach (Bot bot in bots) {
|
||||
Dictionary<string, PurchaseResponseCallback?> 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<IReadOnlyDictionary<string, IReadOnlyDictionary<string, PurchaseResponseCallback?>>>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames given bot along with all its related files.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("{botName:required}/Rename")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> RenamePost(string botName, [FromBody] BotRenameRequest request) {
|
||||
if (string.IsNullOrEmpty(botName)) {
|
||||
throw new ArgumentNullException(nameof(botName));
|
||||
}
|
||||
return Ok(new GenericResponse<IReadOnlyDictionary<string, bool>>(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));
|
||||
/// <summary>
|
||||
/// Removes BGR output files of given bots.
|
||||
/// </summary>
|
||||
[HttpDelete("{botNames:required}/GamesToRedeemInBackground")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> GamesToRedeemInBackgroundDelete(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes given bots.
|
||||
/// </summary>
|
||||
[HttpPost("{botNames:required}/Resume")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> ResumePost(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
HashSet<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
HashSet<Bot>? 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)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts given bots.
|
||||
/// </summary>
|
||||
[HttpPost("{botNames:required}/Start")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> StartPost(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
IList<bool> results = await Utilities.InParallel(bots.Select(static bot => Task.Run(bot.DeleteRedeemedKeysFiles))).ConfigureAwait(false);
|
||||
|
||||
HashSet<Bot>? 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))));
|
||||
/// <summary>
|
||||
/// Fetches BGR output files of given bots.
|
||||
/// </summary>
|
||||
[HttpGet("{botNames:required}/GamesToRedeemInBackground")]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, GamesToRedeemInBackgroundResponse>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> GamesToRedeemInBackgroundGet(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops given bots.
|
||||
/// </summary>
|
||||
[HttpPost("{botNames:required}/Stop")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> StopPost(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
HashSet<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
HashSet<Bot>? 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<string, string>? UnusedKeys, Dictionary<string, string>? UsedKeys)> results = await Utilities.InParallel(bots.Select(static bot => bot.GetUsedAndUnusedKeys())).ConfigureAwait(false);
|
||||
|
||||
Dictionary<string, GamesToRedeemInBackgroundResponse> result = new(bots.Count, Bot.BotsComparer);
|
||||
|
||||
foreach (Bot bot in bots) {
|
||||
(Dictionary<string, string>? unusedKeys, Dictionary<string, string>? usedKeys) = results[result.Count];
|
||||
result[bot.BotName] = new GamesToRedeemInBackgroundResponse(unusedKeys, usedKeys);
|
||||
}
|
||||
|
||||
return Ok(new GenericResponse<IReadOnlyDictionary<string, GamesToRedeemInBackgroundResponse>>(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds keys to redeem using BGR to given bot.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("{botNames:required}/GamesToRedeemInBackground")]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, IOrderedDictionary>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<Bot>? 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<string, IOrderedDictionary> result = new(bots.Count, Bot.BotsComparer);
|
||||
|
||||
foreach (Bot bot in bots) {
|
||||
result[bot.BotName] = validGamesToRedeemInBackground;
|
||||
}
|
||||
|
||||
return Ok(new GenericResponse<IReadOnlyDictionary<string, IOrderedDictionary>>(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides input value to given bot for next usage.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("{botNames:required}/Input")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
if ((bots == null) || (bots.Count == 0)) {
|
||||
return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)));
|
||||
}
|
||||
|
||||
IList<bool> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pauses given bots.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("{botNames:required}/Pause")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> PausePost(string botNames, [FromBody] BotPauseRequest request) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
if (request == null) {
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
HashSet<Bot>? 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))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redeems cd-keys on given bot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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).
|
||||
/// </remarks>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("{botNames:required}/Redeem")]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, IReadOnlyDictionary<string, PurchaseResponseCallback>>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
if ((bots == null) || (bots.Count == 0)) {
|
||||
return BadRequest(new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)));
|
||||
}
|
||||
|
||||
IList<PurchaseResponseCallback?> results = await Utilities.InParallel(bots.Select(bot => request.KeysToRedeem.Select(key => bot.Actions.RedeemKey(key))).SelectMany(static task => task)).ConfigureAwait(false);
|
||||
|
||||
Dictionary<string, IReadOnlyDictionary<string, PurchaseResponseCallback?>> result = new(bots.Count, Bot.BotsComparer);
|
||||
|
||||
int count = 0;
|
||||
|
||||
foreach (Bot bot in bots) {
|
||||
Dictionary<string, PurchaseResponseCallback?> 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<IReadOnlyDictionary<string, IReadOnlyDictionary<string, PurchaseResponseCallback?>>>(result.Values.SelectMany(static responses => responses.Values).All(static value => value != null), result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames given bot along with all its related files.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("{botName:required}/Rename")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes given bots.
|
||||
/// </summary>
|
||||
[HttpPost("{botNames:required}/Resume")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> ResumePost(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
HashSet<Bot>? 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))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts given bots.
|
||||
/// </summary>
|
||||
[HttpPost("{botNames:required}/Start")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> StartPost(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
HashSet<Bot>? 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))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops given bots.
|
||||
/// </summary>
|
||||
[HttpPost("{botNames:required}/Stop")]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> StopPost(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
HashSet<Bot>? 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))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// Executes a command.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// </remarks>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(GenericResponse<string>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<string>(response));
|
||||
[Route("Api/Command")]
|
||||
public sealed class CommandController : ArchiController {
|
||||
/// <summary>
|
||||
/// Executes a command.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// </remarks>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(GenericResponse<string>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<string>(response));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// Fetches the most recent GitHub release of ASF project.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[HttpGet("Release")]
|
||||
[ProducesResponseType(typeof(GenericResponse<GitHubReleaseResponse>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)]
|
||||
public async Task<ActionResult<GenericResponse>> GitHubReleaseGet() {
|
||||
GitHub.ReleaseResponse? releaseResponse = await GitHub.GetLatestRelease(false).ConfigureAwait(false);
|
||||
namespace ArchiSteamFarm.IPC.Controllers.Api;
|
||||
|
||||
return releaseResponse != null ? Ok(new GenericResponse<GitHubReleaseResponse>(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 {
|
||||
/// <summary>
|
||||
/// Fetches the most recent GitHub release of ASF project.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[HttpGet("Release")]
|
||||
[ProducesResponseType(typeof(GenericResponse<GitHubReleaseResponse>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)]
|
||||
public async Task<ActionResult<GenericResponse>> GitHubReleaseGet() {
|
||||
GitHub.ReleaseResponse? releaseResponse = await GitHub.GetLatestRelease(false).ConfigureAwait(false);
|
||||
|
||||
return releaseResponse != null ? Ok(new GenericResponse<GitHubReleaseResponse>(new GitHubReleaseResponse(releaseResponse))) : StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches specific GitHub release of ASF project. Use "latest" for latest stable release.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[HttpGet("Release/{version:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse<GitHubReleaseResponse>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)]
|
||||
public async Task<ActionResult<GenericResponse>> GitHubReleaseGet(string version) {
|
||||
if (string.IsNullOrEmpty(version)) {
|
||||
throw new ArgumentNullException(nameof(version));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches specific GitHub release of ASF project. Use "latest" for latest stable release.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[HttpGet("Release/{version:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse<GitHubReleaseResponse>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<GitHubReleaseResponse>(new GitHubReleaseResponse(releaseResponse))) : StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)));
|
||||
break;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches history of specific GitHub page from ASF project.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[HttpGet("Wiki/History/{page:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse<string>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)]
|
||||
public async Task<ActionResult<GenericResponse>> GitHubWikiHistoryGet(string page) {
|
||||
if (string.IsNullOrEmpty(page)) {
|
||||
throw new ArgumentNullException(nameof(page));
|
||||
}
|
||||
return releaseResponse != null ? Ok(new GenericResponse<GitHubReleaseResponse>(new GitHubReleaseResponse(releaseResponse))) : StatusCode((int) HttpStatusCode.ServiceUnavailable, new GenericResponse(false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorRequestFailedTooManyTimes, WebBrowser.MaxTries)));
|
||||
}
|
||||
|
||||
Dictionary<string, DateTime>? revisions = await GitHub.GetWikiHistory(page).ConfigureAwait(false);
|
||||
|
||||
return revisions != null ? revisions.Count > 0 ? Ok(new GenericResponse<ImmutableDictionary<string, DateTime>>(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)));
|
||||
/// <summary>
|
||||
/// Fetches history of specific GitHub page from ASF project.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[HttpGet("Wiki/History/{page:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse<string>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)]
|
||||
public async Task<ActionResult<GenericResponse>> GitHubWikiHistoryGet(string page) {
|
||||
if (string.IsNullOrEmpty(page)) {
|
||||
throw new ArgumentNullException(nameof(page));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches specific GitHub page of ASF project.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[HttpGet("Wiki/Page/{page:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse<string>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)]
|
||||
public async Task<ActionResult<GenericResponse>> GitHubWikiPageGet(string page, [FromQuery] string? revision = null) {
|
||||
if (string.IsNullOrEmpty(page)) {
|
||||
throw new ArgumentNullException(nameof(page));
|
||||
}
|
||||
Dictionary<string, DateTime>? 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<ImmutableDictionary<string, DateTime>>(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<string>(html))
|
||||
};
|
||||
/// <summary>
|
||||
/// Fetches specific GitHub page of ASF project.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[HttpGet("Wiki/Page/{page:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse<string>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.ServiceUnavailable)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<string>(html))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WebSocket, (SemaphoreSlim Semaphore, CancellationToken CancellationToken)> ActiveLogWebSockets = new();
|
||||
namespace ArchiSteamFarm.IPC.Controllers.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches ASF log in realtime.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This API endpoint requires a websocket connection.
|
||||
/// </remarks>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<GenericResponse<string>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult> NLogGet(CancellationToken cancellationToken) {
|
||||
if (HttpContext == null) {
|
||||
throw new InvalidOperationException(nameof(HttpContext));
|
||||
[Route("Api/NLog")]
|
||||
public sealed class NLogController : ArchiController {
|
||||
private static readonly ConcurrentDictionary<WebSocket, (SemaphoreSlim Semaphore, CancellationToken CancellationToken)> ActiveLogWebSockets = new();
|
||||
|
||||
/// <summary>
|
||||
/// Fetches ASF log in realtime.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This API endpoint requires a websocket connection.
|
||||
/// </remarks>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<GenericResponse<string>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult> 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<byte>(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (webSocket.State == WebSocketState.Open) {
|
||||
WebSocketReceiveResult result = await webSocket.ReceiveAsync(Array.Empty<byte>(), 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<string>(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<string>(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<string>(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<string>(loggedMessage));
|
||||
|
||||
await PostLoggedJsonUpdate(webSocket, response, sendSemaphore, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IReadOnlyCollection<IPlugin>>), (int) HttpStatusCode.OK)]
|
||||
public ActionResult<GenericResponse<IReadOnlyCollection<IPlugin>>> PluginsGet() {
|
||||
IReadOnlyCollection<IPlugin> activePlugins = PluginsCore.ActivePlugins ?? (IReadOnlyCollection<IPlugin>) Array.Empty<IPlugin>();
|
||||
namespace ArchiSteamFarm.IPC.Controllers.Api;
|
||||
|
||||
return Ok(new GenericResponse<IReadOnlyCollection<IPlugin>>(activePlugins));
|
||||
}
|
||||
[Route("Api/Plugins")]
|
||||
public sealed class PluginsController : ArchiController {
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyCollection<IPlugin>>), (int) HttpStatusCode.OK)]
|
||||
public ActionResult<GenericResponse<IReadOnlyCollection<IPlugin>>> PluginsGet() {
|
||||
IReadOnlyCollection<IPlugin> activePlugins = PluginsCore.ActivePlugins ?? (IReadOnlyCollection<IPlugin>) Array.Empty<IPlugin>();
|
||||
|
||||
return Ok(new GenericResponse<IReadOnlyCollection<IPlugin>>(activePlugins));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// Deletes entry under specified key from ASF's persistent KeyValue JSON storage.
|
||||
/// </summary>
|
||||
[HttpDelete]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
public ActionResult<GenericResponse> 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 {
|
||||
/// <summary>
|
||||
/// Deletes entry under specified key from ASF's persistent KeyValue JSON storage.
|
||||
/// </summary>
|
||||
[HttpDelete]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
public ActionResult<GenericResponse> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads entry under specified key from ASF's persistent KeyValue JSON storage.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GenericResponse<JToken>), (int) HttpStatusCode.OK)]
|
||||
public ActionResult<GenericResponse> 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<JToken>(true, value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves entry under specified key in ASF's persistent KeyValue JSON storage.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
public ActionResult<GenericResponse> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads entry under specified key from ASF's persistent KeyValue JSON storage.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GenericResponse<JToken>), (int) HttpStatusCode.OK)]
|
||||
public ActionResult<GenericResponse> 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<JToken>(true, value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves entry under specified key in ASF's persistent KeyValue JSON storage.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.OK)]
|
||||
public ActionResult<GenericResponse> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// Fetches structure of given type.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Structure is defined as a representation of given object in its default state.
|
||||
/// </remarks>
|
||||
[HttpGet("{structure:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse<object>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public ActionResult<GenericResponse> 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<object>(obj));
|
||||
[Route("Api/Structure")]
|
||||
public sealed class StructureController : ArchiController {
|
||||
/// <summary>
|
||||
/// Fetches structure of given type.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Structure is defined as a representation of given object in its default state.
|
||||
/// </remarks>
|
||||
[HttpGet("{structure:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse<object>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public ActionResult<GenericResponse> 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<object>(obj));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// Handles 2FA confirmations of given bots, requires ASF 2FA module to be active on them.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("Confirmations")]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, GenericResponse<IReadOnlyCollection<Confirmation>>>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> 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<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
if ((bots == null) || (bots.Count == 0)) {
|
||||
return BadRequest(new GenericResponse<IReadOnlyDictionary<string, GenericResponse<IReadOnlyCollection<Confirmation>>>>(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)));
|
||||
}
|
||||
|
||||
IList<(bool Success, IReadOnlyCollection<Confirmation>? 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<string, GenericResponse<IReadOnlyCollection<Confirmation>>> result = new(bots.Count, Bot.BotsComparer);
|
||||
|
||||
foreach (Bot bot in bots) {
|
||||
(bool success, IReadOnlyCollection<Confirmation>? handledConfirmations, string message) = results[result.Count];
|
||||
result[bot.BotName] = new GenericResponse<IReadOnlyCollection<Confirmation>>(success, message, handledConfirmations);
|
||||
}
|
||||
|
||||
return Ok(new GenericResponse<IReadOnlyDictionary<string, GenericResponse<IReadOnlyCollection<Confirmation>>>>(result));
|
||||
[Route("Api/Bot/{botNames:required}/TwoFactorAuthentication")]
|
||||
public sealed class TwoFactorAuthenticationController : ArchiController {
|
||||
/// <summary>
|
||||
/// Handles 2FA confirmations of given bots, requires ASF 2FA module to be active on them.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost("Confirmations")]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, GenericResponse<IReadOnlyCollection<Confirmation>>>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> ConfirmationsPost(string botNames, [FromBody] TwoFactorAuthenticationConfirmationsRequest request) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the MobileAuthenticator of given bots if an ASF 2FA module is active on them.
|
||||
/// </summary>
|
||||
[HttpDelete]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, GenericResponse<string>>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> Delete(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
HashSet<Bot>? 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<string, GenericResponse<string>> result = new(bots.Count, Bot.BotsComparer);
|
||||
|
||||
foreach (Bot bot in bots) {
|
||||
(bool success, string? message) = results[result.Count];
|
||||
result[bot.BotName] = new GenericResponse<string>(success, message);
|
||||
}
|
||||
|
||||
return Ok(new GenericResponse<IReadOnlyDictionary<string, GenericResponse<string>>>(result));
|
||||
if (request == null) {
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports a MobileAuthenticator into the ASF 2FA module of a given bot.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, GenericResponse>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> Post(string botNames, [FromBody] MobileAuthenticator authenticator) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
if (authenticator == null) {
|
||||
throw new ArgumentNullException(nameof(authenticator));
|
||||
}
|
||||
|
||||
HashSet<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
if ((bots == null) || (bots.Count == 0)) {
|
||||
return BadRequest(new GenericResponse<IReadOnlyDictionary<string, GenericResponse<string>>>(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)));
|
||||
}
|
||||
|
||||
IList<bool> results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.TryImportAuthenticator(authenticator)))).ConfigureAwait(false);
|
||||
|
||||
Dictionary<string, GenericResponse> 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<IReadOnlyDictionary<string, 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))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches 2FA tokens of given bots, requires ASF 2FA module to be active on them.
|
||||
/// </summary>
|
||||
[HttpGet("Token")]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, GenericResponse<string>>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> TokenGet(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
HashSet<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
HashSet<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
if ((bots == null) || (bots.Count == 0)) {
|
||||
return BadRequest(new GenericResponse<IReadOnlyDictionary<string, GenericResponse<string>>>(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<string, GenericResponse<string>> 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<string>(success, message, token);
|
||||
}
|
||||
|
||||
return Ok(new GenericResponse<IReadOnlyDictionary<string, GenericResponse<string>>>(result));
|
||||
if ((bots == null) || (bots.Count == 0)) {
|
||||
return BadRequest(new GenericResponse<IReadOnlyDictionary<string, GenericResponse<IReadOnlyCollection<Confirmation>>>>(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)));
|
||||
}
|
||||
|
||||
IList<(bool Success, IReadOnlyCollection<Confirmation>? 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<string, GenericResponse<IReadOnlyCollection<Confirmation>>> result = new(bots.Count, Bot.BotsComparer);
|
||||
|
||||
foreach (Bot bot in bots) {
|
||||
(bool success, IReadOnlyCollection<Confirmation>? handledConfirmations, string message) = results[result.Count];
|
||||
result[bot.BotName] = new GenericResponse<IReadOnlyCollection<Confirmation>>(success, message, handledConfirmations);
|
||||
}
|
||||
|
||||
return Ok(new GenericResponse<IReadOnlyDictionary<string, GenericResponse<IReadOnlyCollection<Confirmation>>>>(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the MobileAuthenticator of given bots if an ASF 2FA module is active on them.
|
||||
/// </summary>
|
||||
[HttpDelete]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, GenericResponse<string>>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> Delete(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
HashSet<Bot>? 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<string, GenericResponse<string>> result = new(bots.Count, Bot.BotsComparer);
|
||||
|
||||
foreach (Bot bot in bots) {
|
||||
(bool success, string? message) = results[result.Count];
|
||||
result[bot.BotName] = new GenericResponse<string>(success, message);
|
||||
}
|
||||
|
||||
return Ok(new GenericResponse<IReadOnlyDictionary<string, GenericResponse<string>>>(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports a MobileAuthenticator into the ASF 2FA module of a given bot.
|
||||
/// </summary>
|
||||
[Consumes("application/json")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, GenericResponse>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> Post(string botNames, [FromBody] MobileAuthenticator authenticator) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
if (authenticator == null) {
|
||||
throw new ArgumentNullException(nameof(authenticator));
|
||||
}
|
||||
|
||||
HashSet<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
if ((bots == null) || (bots.Count == 0)) {
|
||||
return BadRequest(new GenericResponse<IReadOnlyDictionary<string, GenericResponse<string>>>(false, string.Format(CultureInfo.CurrentCulture, Strings.BotNotFound, botNames)));
|
||||
}
|
||||
|
||||
IList<bool> results = await Utilities.InParallel(bots.Select(bot => Task.Run(() => bot.TryImportAuthenticator(authenticator)))).ConfigureAwait(false);
|
||||
|
||||
Dictionary<string, GenericResponse> 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<IReadOnlyDictionary<string, GenericResponse>>(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches 2FA tokens of given bots, requires ASF 2FA module to be active on them.
|
||||
/// </summary>
|
||||
[HttpGet("Token")]
|
||||
[ProducesResponseType(typeof(GenericResponse<IReadOnlyDictionary<string, GenericResponse<string>>>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public async Task<ActionResult<GenericResponse>> TokenGet(string botNames) {
|
||||
if (string.IsNullOrEmpty(botNames)) {
|
||||
throw new ArgumentNullException(nameof(botNames));
|
||||
}
|
||||
|
||||
HashSet<Bot>? bots = Bot.GetBots(botNames);
|
||||
|
||||
if ((bots == null) || (bots.Count == 0)) {
|
||||
return BadRequest(new GenericResponse<IReadOnlyDictionary<string, GenericResponse<string>>>(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<string, GenericResponse<string>> 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<string>(success, message, token);
|
||||
}
|
||||
|
||||
return Ok(new GenericResponse<IReadOnlyDictionary<string, GenericResponse<string>>>(result));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// Fetches type info of given type.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[HttpGet("{type:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse<TypeResponse>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public ActionResult<GenericResponse> 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<string> customAttributes = targetType.CustomAttributes.Select(static attribute => attribute.AttributeType.GetUnifiedName()).Where(static customAttribute => !string.IsNullOrEmpty(customAttribute)).ToHashSet(StringComparer.Ordinal)!;
|
||||
string? underlyingType = null;
|
||||
|
||||
Dictionary<string, string> 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<JsonPropertyAttribute>();
|
||||
|
||||
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<JsonPropertyAttribute>();
|
||||
|
||||
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<TypeResponse>(response));
|
||||
[Route("Api/Type")]
|
||||
public sealed class TypeController : ArchiController {
|
||||
/// <summary>
|
||||
/// Fetches type info of given type.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[HttpGet("{type:required}")]
|
||||
[ProducesResponseType(typeof(GenericResponse<TypeResponse>), (int) HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(GenericResponse), (int) HttpStatusCode.BadRequest)]
|
||||
public ActionResult<GenericResponse> 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<string> customAttributes = targetType.CustomAttributes.Select(static attribute => attribute.AttributeType.GetUnifiedName()).Where(static customAttribute => !string.IsNullOrEmpty(customAttribute)).ToHashSet(StringComparer.Ordinal)!;
|
||||
string? underlyingType = null;
|
||||
|
||||
Dictionary<string, string> 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<JsonPropertyAttribute>();
|
||||
|
||||
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<JsonPropertyAttribute>();
|
||||
|
||||
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<TypeResponse>(response));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IPAddress, Task> AuthorizationTasks = new();
|
||||
private static readonly Timer ClearFailedAuthorizationsTimer = new(ClearFailedAuthorizations);
|
||||
private static readonly ConcurrentDictionary<IPAddress, byte> FailedAuthorizations = new();
|
||||
private const byte FailedAuthorizationsCooldownInHours = 1;
|
||||
private const byte MaxFailedAuthorizationAttempts = 5;
|
||||
|
||||
private readonly ForwardedHeadersOptions ForwardedHeadersOptions;
|
||||
private readonly RequestDelegate Next;
|
||||
private static readonly ConcurrentDictionary<IPAddress, Task> AuthorizationTasks = new();
|
||||
private static readonly Timer ClearFailedAuthorizationsTimer = new(ClearFailedAuthorizations);
|
||||
private static readonly ConcurrentDictionary<IPAddress, byte> FailedAuthorizations = new();
|
||||
|
||||
public ApiAuthenticationMiddleware(RequestDelegate next, IOptions<ForwardedHeadersOptions> 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> 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<MvcNewtonsoftJsonOptions> 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<StatusCodeResponse>(false, statusCodeResponse), jsonOptions.Value.SerializerSettings).ConfigureAwait(false);
|
||||
[UsedImplicitly]
|
||||
public async Task InvokeAsync(HttpContext context, IOptions<MvcNewtonsoftJsonOptions> 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<StatusCodeResponse>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<T>(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<T>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string> CultureConversions = new Dictionary<string, string>(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<string, string> CultureConversions = new Dictionary<string, string>(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<StringWithQualityHeaderValue>? 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<StringWithQualityHeaderValue> acceptLanguageHeader = headers.AcceptLanguage;
|
||||
|
||||
if (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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// Encryption method used for encrypting this string.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public ArchiCryptoHelper.ECryptoMethod CryptoMethod { get; private set; }
|
||||
namespace ArchiSteamFarm.IPC.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// String to encrypt with provided <see cref="CryptoMethod" />.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public string StringToEncrypt { get; private set; } = "";
|
||||
[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")]
|
||||
public sealed class ASFEncryptRequest {
|
||||
/// <summary>
|
||||
/// Encryption method used for encrypting this string.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public ArchiCryptoHelper.ECryptoMethod CryptoMethod { get; private set; }
|
||||
|
||||
[JsonConstructor]
|
||||
private ASFEncryptRequest() { }
|
||||
}
|
||||
/// <summary>
|
||||
/// String to encrypt with provided <see cref="CryptoMethod" />.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public string StringToEncrypt { get; private set; } = "";
|
||||
|
||||
[JsonConstructor]
|
||||
private ASFEncryptRequest() { }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// Hashing method used for hashing this string.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public ArchiCryptoHelper.EHashingMethod HashingMethod { get; private set; }
|
||||
namespace ArchiSteamFarm.IPC.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// String to hash with provided <see cref="HashingMethod" />.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public string StringToHash { get; private set; } = "";
|
||||
[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")]
|
||||
public sealed class ASFHashRequest {
|
||||
/// <summary>
|
||||
/// Hashing method used for hashing this string.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public ArchiCryptoHelper.EHashingMethod HashingMethod { get; private set; }
|
||||
|
||||
[JsonConstructor]
|
||||
private ASFHashRequest() { }
|
||||
}
|
||||
/// <summary>
|
||||
/// String to hash with provided <see cref="HashingMethod" />.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public string StringToHash { get; private set; } = "";
|
||||
|
||||
[JsonConstructor]
|
||||
private ASFHashRequest() { }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// ASF's global config structure.
|
||||
/// </summary>
|
||||
[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 {
|
||||
/// <summary>
|
||||
/// ASF's global config structure.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public GlobalConfig GlobalConfig { get; private set; } = new();
|
||||
|
||||
[JsonConstructor]
|
||||
private ASFRequest() { }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// A string-string map that maps cd-key to redeem (key) to its name (value).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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).
|
||||
/// </remarks>
|
||||
[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 {
|
||||
/// <summary>
|
||||
/// A string-string map that maps cd-key to redeem (key) to its name (value).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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).
|
||||
/// </remarks>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public OrderedDictionary GamesToRedeemInBackground { get; private set; } = new();
|
||||
|
||||
[JsonConstructor]
|
||||
private BotGamesToRedeemInBackgroundRequest() { }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// Specifies the type of the input.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public ASF.EUserInputType Type { get; private set; }
|
||||
namespace ArchiSteamFarm.IPC.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the value for given input type (declared in <see cref="Type" />)
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public string Value { get; private set; } = "";
|
||||
[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")]
|
||||
public sealed class BotInputRequest {
|
||||
/// <summary>
|
||||
/// Specifies the type of the input.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public ASF.EUserInputType Type { get; private set; }
|
||||
|
||||
[JsonConstructor]
|
||||
private BotInputRequest() { }
|
||||
}
|
||||
/// <summary>
|
||||
/// Specifies the value for given input type (declared in <see cref="Type" />)
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public string Value { get; private set; } = "";
|
||||
|
||||
[JsonConstructor]
|
||||
private BotInputRequest() { }
|
||||
}
|
||||
|
||||
@@ -22,22 +22,22 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ArchiSteamFarm.IPC.Requests {
|
||||
[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")]
|
||||
public sealed class BotPauseRequest {
|
||||
/// <summary>
|
||||
/// Specifies if pause is permanent or temporary (default).
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
public bool Permanent { get; private set; }
|
||||
namespace ArchiSteamFarm.IPC.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies automatic resume action in given seconds. Default value of 0 disables automatic resume.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
public ushort ResumeInSeconds { get; private set; }
|
||||
[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")]
|
||||
public sealed class BotPauseRequest {
|
||||
/// <summary>
|
||||
/// Specifies if pause is permanent or temporary (default).
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
public bool Permanent { get; private set; }
|
||||
|
||||
[JsonConstructor]
|
||||
private BotPauseRequest() { }
|
||||
}
|
||||
/// <summary>
|
||||
/// Specifies automatic resume action in given seconds. Default value of 0 disables automatic resume.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
public ushort ResumeInSeconds { get; private set; }
|
||||
|
||||
[JsonConstructor]
|
||||
private BotPauseRequest() { }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// A collection (set) of keys to redeem.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public ImmutableHashSet<string> KeysToRedeem { get; private set; } = ImmutableHashSet<string>.Empty;
|
||||
namespace ArchiSteamFarm.IPC.Requests;
|
||||
|
||||
[JsonConstructor]
|
||||
private BotRedeemRequest() { }
|
||||
}
|
||||
[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")]
|
||||
public sealed class BotRedeemRequest {
|
||||
/// <summary>
|
||||
/// A collection (set) of keys to redeem.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public ImmutableHashSet<string> KeysToRedeem { get; private set; } = ImmutableHashSet<string>.Empty;
|
||||
|
||||
[JsonConstructor]
|
||||
private BotRedeemRequest() { }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// Specifies the new name for the bot. The new name can't be "ASF", neither the one used by any existing bot.
|
||||
/// </summary>
|
||||
[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 {
|
||||
/// <summary>
|
||||
/// Specifies the new name for the bot. The new name can't be "ASF", neither the one used by any existing bot.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public string NewName { get; private set; } = "";
|
||||
|
||||
[JsonConstructor]
|
||||
private BotRenameRequest() { }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// ASF's bot config structure.
|
||||
/// </summary>
|
||||
[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 {
|
||||
/// <summary>
|
||||
/// ASF's bot config structure.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public BotConfig BotConfig { get; private set; } = new();
|
||||
|
||||
[JsonConstructor]
|
||||
private BotRequest() { }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// Specifies the command that will be executed by ASF.
|
||||
/// </summary>
|
||||
[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 {
|
||||
/// <summary>
|
||||
/// Specifies the command that will be executed by ASF.
|
||||
/// </summary>
|
||||
[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() { }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// <summary>
|
||||
/// Specifies the target action, whether we should accept the confirmations (true), or decline them (false).
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public bool Accept { get; private set; }
|
||||
namespace ArchiSteamFarm.IPC.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
public ImmutableHashSet<ulong> AcceptedCreatorIDs { get; private set; } = ImmutableHashSet<ulong>.Empty;
|
||||
[SuppressMessage("ReSharper", "ClassCannotBeInstantiated")]
|
||||
public sealed class TwoFactorAuthenticationConfirmationsRequest {
|
||||
/// <summary>
|
||||
/// Specifies the target action, whether we should accept the confirmations (true), or decline them (false).
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public bool Accept { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the type of confirmations to handle. If not provided, all confirmation types are considered for an action.
|
||||
/// </summary>
|
||||
[JsonProperty]
|
||||
public Confirmation.EType? AcceptedType { get; private set; }
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
public ImmutableHashSet<ulong> AcceptedCreatorIDs { get; private set; } = ImmutableHashSet<ulong>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// A helper property which works the same as <see cref="AcceptedCreatorIDs" /> but with values written as strings - for javascript compatibility purposes. Use either this one, or <see cref="AcceptedCreatorIDs" />, not both.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = SharedInfo.UlongCompatibilityStringPrefix + nameof(AcceptedCreatorIDs), Required = Required.DisallowNull)]
|
||||
public ImmutableHashSet<string> SAcceptedCreatorIDs {
|
||||
get => AcceptedCreatorIDs.Select(static creatorID => creatorID.ToString(CultureInfo.InvariantCulture)).ToImmutableHashSet();
|
||||
set {
|
||||
if (value == null) {
|
||||
throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
/// <summary>
|
||||
/// Specifies the type of confirmations to handle. If not provided, all confirmation types are considered for an action.
|
||||
/// </summary>
|
||||
[JsonProperty]
|
||||
public Confirmation.EType? AcceptedType { get; private set; }
|
||||
|
||||
HashSet<ulong> 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();
|
||||
/// <summary>
|
||||
/// A helper property which works the same as <see cref="AcceptedCreatorIDs" /> but with values written as strings - for javascript compatibility purposes. Use either this one, or <see cref="AcceptedCreatorIDs" />, not both.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = SharedInfo.UlongCompatibilityStringPrefix + nameof(AcceptedCreatorIDs), Required = Required.DisallowNull)]
|
||||
public ImmutableHashSet<string> SAcceptedCreatorIDs {
|
||||
get => AcceptedCreatorIDs.Select(static creatorID => creatorID.ToString(CultureInfo.InvariantCulture)).ToImmutableHashSet();
|
||||
set {
|
||||
if (value == null) {
|
||||
throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
HashSet<ulong> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies whether we should wait for the confirmations to arrive, in case they're not available immediately. This option makes sense only if <see cref="AcceptedCreatorIDs" /> 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.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
public bool WaitIfNeeded { get; private set; }
|
||||
|
||||
[JsonConstructor]
|
||||
private TwoFactorAuthenticationConfirmationsRequest() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies whether we should wait for the confirmations to arrive, in case they're not available immediately. This option makes sense only if <see cref="AcceptedCreatorIDs" /> 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.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.DisallowNull)]
|
||||
public bool WaitIfNeeded { get; private set; }
|
||||
|
||||
[JsonConstructor]
|
||||
private TwoFactorAuthenticationConfirmationsRequest() { }
|
||||
}
|
||||
|
||||
@@ -24,57 +24,57 @@ using System.ComponentModel.DataAnnotations;
|
||||
using ArchiSteamFarm.Storage;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ArchiSteamFarm.IPC.Responses {
|
||||
public sealed class ASFResponse {
|
||||
/// <summary>
|
||||
/// ASF's build variant.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public string BuildVariant { get; private set; }
|
||||
namespace ArchiSteamFarm.IPC.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// A value specifying whether this variant of ASF is capable of auto-update.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public bool CanUpdate { get; private set; }
|
||||
public sealed class ASFResponse {
|
||||
/// <summary>
|
||||
/// ASF's build variant.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public string BuildVariant { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Currently loaded ASF's global config.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public GlobalConfig GlobalConfig { get; private set; }
|
||||
/// <summary>
|
||||
/// A value specifying whether this variant of ASF is capable of auto-update.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public bool CanUpdate { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current amount of managed memory being used by the process, in kilobytes.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public uint MemoryUsage { get; private set; }
|
||||
/// <summary>
|
||||
/// Currently loaded ASF's global config.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public GlobalConfig GlobalConfig { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Start date of the process.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public DateTime ProcessStartTime { get; private set; }
|
||||
/// <summary>
|
||||
/// Current amount of managed memory being used by the process, in kilobytes.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public uint MemoryUsage { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// ASF version of currently running binary.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public Version Version { get; private set; }
|
||||
/// <summary>
|
||||
/// Start date of the process.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
/// <summary>
|
||||
/// ASF version of currently running binary.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,23 +22,23 @@
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ArchiSteamFarm.IPC.Responses {
|
||||
public sealed class GamesToRedeemInBackgroundResponse {
|
||||
/// <summary>
|
||||
/// Keys that were redeemed and not used during the process, if available.
|
||||
/// </summary>
|
||||
[JsonProperty]
|
||||
public Dictionary<string, string>? UnusedKeys { get; private set; }
|
||||
namespace ArchiSteamFarm.IPC.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// Keys that were redeemed and used during the process, if available.
|
||||
/// </summary>
|
||||
[JsonProperty]
|
||||
public Dictionary<string, string>? UsedKeys { get; private set; }
|
||||
public sealed class GamesToRedeemInBackgroundResponse {
|
||||
/// <summary>
|
||||
/// Keys that were redeemed and not used during the process, if available.
|
||||
/// </summary>
|
||||
[JsonProperty]
|
||||
public Dictionary<string, string>? UnusedKeys { get; private set; }
|
||||
|
||||
internal GamesToRedeemInBackgroundResponse(Dictionary<string, string>? unusedKeys = null, Dictionary<string, string>? usedKeys = null) {
|
||||
UnusedKeys = unusedKeys;
|
||||
UsedKeys = usedKeys;
|
||||
}
|
||||
/// <summary>
|
||||
/// Keys that were redeemed and used during the process, if available.
|
||||
/// </summary>
|
||||
[JsonProperty]
|
||||
public Dictionary<string, string>? UsedKeys { get; private set; }
|
||||
|
||||
internal GamesToRedeemInBackgroundResponse(Dictionary<string, string>? unusedKeys = null, Dictionary<string, string>? usedKeys = null) {
|
||||
UnusedKeys = unusedKeys;
|
||||
UsedKeys = usedKeys;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,45 +23,45 @@ using System.ComponentModel.DataAnnotations;
|
||||
using ArchiSteamFarm.Localization;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ArchiSteamFarm.IPC.Responses {
|
||||
public sealed class GenericResponse<T> : GenericResponse where T : class {
|
||||
/// <summary>
|
||||
/// The actual result of the request, if available.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The type of the result depends on the API endpoint that you've called.
|
||||
/// </remarks>
|
||||
[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<T> : GenericResponse where T : class {
|
||||
/// <summary>
|
||||
/// The actual result of the request, if available.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The type of the result depends on the API endpoint that you've called.
|
||||
/// </remarks>
|
||||
[JsonProperty]
|
||||
public T? Result { get; private set; }
|
||||
|
||||
public class GenericResponse {
|
||||
/// <summary>
|
||||
/// A message that describes what happened with the request, if available.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This property will provide exact reason for majority of expected failures.
|
||||
/// </remarks>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Boolean type that specifies if the request has succeeded.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public bool Success { get; private set; }
|
||||
public class GenericResponse {
|
||||
/// <summary>
|
||||
/// A message that describes what happened with the request, if available.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This property will provide exact reason for majority of expected failures.
|
||||
/// </remarks>
|
||||
[JsonProperty]
|
||||
public string Message { get; private set; }
|
||||
|
||||
public GenericResponse(bool success, string? message = null) {
|
||||
Success = success;
|
||||
/// <summary>
|
||||
/// Boolean type that specifies if the request has succeeded.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,45 +24,45 @@ using System.ComponentModel.DataAnnotations;
|
||||
using ArchiSteamFarm.Web;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ArchiSteamFarm.IPC.Responses {
|
||||
public sealed class GitHubReleaseResponse {
|
||||
/// <summary>
|
||||
/// Changelog of the release rendered in HTML.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public string ChangelogHTML { get; private set; }
|
||||
namespace ArchiSteamFarm.IPC.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// Date of the release.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public DateTime ReleasedAt { get; private set; }
|
||||
public sealed class GitHubReleaseResponse {
|
||||
/// <summary>
|
||||
/// Changelog of the release rendered in HTML.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public string ChangelogHTML { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Boolean value that specifies whether the build is stable or not (pre-release).
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public bool Stable { get; private set; }
|
||||
/// <summary>
|
||||
/// Date of the release.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public DateTime ReleasedAt { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the release.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public string Version { get; private set; }
|
||||
/// <summary>
|
||||
/// Boolean value that specifies whether the build is stable or not (pre-release).
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public bool Stable { get; private set; }
|
||||
|
||||
internal GitHubReleaseResponse(GitHub.ReleaseResponse releaseResponse) {
|
||||
if (releaseResponse == null) {
|
||||
throw new ArgumentNullException(nameof(releaseResponse));
|
||||
}
|
||||
/// <summary>
|
||||
/// Version of the release.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,25 +23,25 @@ using System.ComponentModel.DataAnnotations;
|
||||
using System.Net;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ArchiSteamFarm.IPC.Responses {
|
||||
public sealed class StatusCodeResponse {
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public bool Permanent { get; private set; }
|
||||
namespace ArchiSteamFarm.IPC.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// Status code transmitted in addition to the one in HTTP spec.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public HttpStatusCode StatusCode { get; private set; }
|
||||
public sealed class StatusCodeResponse {
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public bool Permanent { get; private set; }
|
||||
|
||||
internal StatusCodeResponse(HttpStatusCode statusCode, bool permanent) {
|
||||
StatusCode = statusCode;
|
||||
Permanent = permanent;
|
||||
}
|
||||
/// <summary>
|
||||
/// Status code transmitted in addition to the one in HTTP spec.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public HttpStatusCode StatusCode { get; private set; }
|
||||
|
||||
internal StatusCodeResponse(HttpStatusCode statusCode, bool permanent) {
|
||||
StatusCode = statusCode;
|
||||
Permanent = permanent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,39 +23,39 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ArchiSteamFarm.IPC.Responses {
|
||||
public sealed class TypeProperties {
|
||||
/// <summary>
|
||||
/// Base type of given type, if available.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This can be used for determining how the body of the response should be interpreted.
|
||||
/// </remarks>
|
||||
[JsonProperty]
|
||||
public string? BaseType { get; private set; }
|
||||
namespace ArchiSteamFarm.IPC.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// Custom attributes of given type, if available.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This can be used for determining main enum type if <see cref="BaseType" /> is <see cref="Enum" />.
|
||||
/// </remarks>
|
||||
[JsonProperty]
|
||||
public HashSet<string>? CustomAttributes { get; private set; }
|
||||
public sealed class TypeProperties {
|
||||
/// <summary>
|
||||
/// Base type of given type, if available.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This can be used for determining how the body of the response should be interpreted.
|
||||
/// </remarks>
|
||||
[JsonProperty]
|
||||
public string? BaseType { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Underlying type of given type, if available.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This can be used for determining underlying enum type if <see cref="BaseType" /> is <see cref="Enum" />.
|
||||
/// </remarks>
|
||||
[JsonProperty]
|
||||
public string? UnderlyingType { get; private set; }
|
||||
/// <summary>
|
||||
/// Custom attributes of given type, if available.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This can be used for determining main enum type if <see cref="BaseType" /> is <see cref="Enum" />.
|
||||
/// </remarks>
|
||||
[JsonProperty]
|
||||
public HashSet<string>? CustomAttributes { get; private set; }
|
||||
|
||||
internal TypeProperties(string? baseType = null, HashSet<string>? customAttributes = null, string? underlyingType = null) {
|
||||
BaseType = baseType;
|
||||
CustomAttributes = customAttributes;
|
||||
UnderlyingType = underlyingType;
|
||||
}
|
||||
/// <summary>
|
||||
/// Underlying type of given type, if available.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This can be used for determining underlying enum type if <see cref="BaseType" /> is <see cref="Enum" />.
|
||||
/// </remarks>
|
||||
[JsonProperty]
|
||||
public string? UnderlyingType { get; private set; }
|
||||
|
||||
internal TypeProperties(string? baseType = null, HashSet<string>? customAttributes = null, string? underlyingType = null) {
|
||||
BaseType = baseType;
|
||||
CustomAttributes = customAttributes;
|
||||
UnderlyingType = underlyingType;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,30 +24,30 @@ using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ArchiSteamFarm.IPC.Responses {
|
||||
public sealed class TypeResponse {
|
||||
/// <summary>
|
||||
/// A string-string map representing a decomposition of given type.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The actual structure of this field depends on the type that was requested. You can determine that type based on <see cref="Properties" /> 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.
|
||||
/// </remarks>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public Dictionary<string, string> Body { get; private set; }
|
||||
namespace ArchiSteamFarm.IPC.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata of given type.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public TypeProperties Properties { get; private set; }
|
||||
public sealed class TypeResponse {
|
||||
/// <summary>
|
||||
/// A string-string map representing a decomposition of given type.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The actual structure of this field depends on the type that was requested. You can determine that type based on <see cref="Properties" /> 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.
|
||||
/// </remarks>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public Dictionary<string, string> Body { get; private set; }
|
||||
|
||||
internal TypeResponse(Dictionary<string, string> body, TypeProperties properties) {
|
||||
Body = body ?? throw new ArgumentNullException(nameof(body));
|
||||
Properties = properties ?? throw new ArgumentNullException(nameof(properties));
|
||||
}
|
||||
/// <summary>
|
||||
/// Metadata of given type.
|
||||
/// </summary>
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
[Required]
|
||||
public TypeProperties Properties { get; private set; }
|
||||
|
||||
internal TypeResponse(Dictionary<string, string> body, TypeProperties properties) {
|
||||
Body = body ?? throw new ArgumentNullException(nameof(body));
|
||||
Properties = properties ?? throw new ArgumentNullException(nameof(properties));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,331 +51,324 @@ 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<PathString>("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<PathString>("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<LocalizationMiddleware>();
|
||||
// Add support for additional localization mappings
|
||||
app.UseMiddleware<LocalizationMiddleware>();
|
||||
|
||||
// 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<ApiAuthenticationMiddleware>());
|
||||
// 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<ApiAuthenticationMiddleware>());
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 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
|
||||
#if NETFRAMEWORK
|
||||
app.UseMvcWithDefaultRoute();
|
||||
#else
|
||||
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 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");
|
||||
}
|
||||
);
|
||||
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();
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Prepare knownNetworks that we'll use in a second
|
||||
HashSet<string>? knownNetworksTexts = Configuration.GetSection("Kestrel:KnownNetworks").Get<HashSet<string>>();
|
||||
|
||||
HashSet<IPNetwork>? knownNetworks = null;
|
||||
|
||||
if (knownNetworksTexts?.Count > 0) {
|
||||
// Use specified known networks
|
||||
knownNetworks = new HashSet<IPNetwork>();
|
||||
|
||||
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}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
knownNetworks.Add(new IPNetwork(ipAddress, prefixLength));
|
||||
}
|
||||
}
|
||||
|
||||
// Add support for proxies
|
||||
services.Configure<ForwardedHeadersOptions>(
|
||||
options => {
|
||||
options.ForwardedHeaders = ForwardedHeaders.All;
|
||||
|
||||
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();
|
||||
|
||||
// Add support for localization
|
||||
services.AddLocalization();
|
||||
|
||||
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
|
||||
|
||||
try {
|
||||
CultureInfo lolcatCulture = CultureInfo.CreateSpecificCulture(SharedInfo.LolcatCultureName);
|
||||
|
||||
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);
|
||||
|
||||
options.SupportedCultures = options.SupportedUICultures = CultureInfo.GetCultures(CultureTypes.AllCultures);
|
||||
}
|
||||
|
||||
// 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<IRequestCultureProvider>(1) { new AcceptLanguageHeaderRequestCultureProvider() };
|
||||
}
|
||||
);
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
// 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.AddSecurityRequirement(
|
||||
new OpenApiSecurityRequirement {
|
||||
{
|
||||
new OpenApiSecurityScheme {
|
||||
Reference = new OpenApiReference {
|
||||
Id = nameof(GlobalConfig.IPCPassword),
|
||||
Type = ReferenceType.SecurityScheme
|
||||
}
|
||||
},
|
||||
|
||||
Array.Empty<string>()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
options.CustomSchemaIds(static type => type.GetUnifiedName());
|
||||
options.EnableAnnotations(true, true);
|
||||
|
||||
options.SchemaFilter<CustomAttributesSchemaFilter>();
|
||||
options.SchemaFilter<EnumSchemaFilter>();
|
||||
|
||||
options.SwaggerDoc(
|
||||
SharedInfo.ASF, new OpenApiInfo {
|
||||
Contact = new OpenApiContact {
|
||||
Name = SharedInfo.GithubRepo,
|
||||
Url = new Uri(SharedInfo.ProjectURL)
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 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<Assembly>? assemblies = PluginsCore.LoadAssemblies();
|
||||
|
||||
if (assemblies != null) {
|
||||
foreach (Assembly assembly in assemblies) {
|
||||
mvc.AddApplicationPart(assembly);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mvc.AddControllersAsServices();
|
||||
// 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
|
||||
#if NETFRAMEWORK
|
||||
// Use latest compatibility version for MVC
|
||||
mvc.SetCompatibilityVersion(CompatibilityVersion.Latest);
|
||||
|
||||
// Add standard formatters
|
||||
mvc.AddFormatterMappings();
|
||||
|
||||
// Add API explorer for swagger
|
||||
mvc.AddApiExplorer();
|
||||
#endif
|
||||
|
||||
#if NETFRAMEWORK
|
||||
// Add JSON formatters that will be used as default ones if no specific formatters are asked for
|
||||
mvc.AddJsonFormatters();
|
||||
|
||||
mvc.AddJsonOptions(
|
||||
app.UseMvcWithDefaultRoute();
|
||||
#else
|
||||
mvc.AddNewtonsoftJson(
|
||||
app.UseEndpoints(static endpoints => endpoints.MapControllers());
|
||||
#endif
|
||||
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;
|
||||
// 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");
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Prepare knownNetworks that we'll use in a second
|
||||
HashSet<string>? knownNetworksTexts = Configuration.GetSection("Kestrel:KnownNetworks").Get<HashSet<string>>();
|
||||
|
||||
HashSet<IPNetwork>? knownNetworks = null;
|
||||
|
||||
if (knownNetworksTexts?.Count > 0) {
|
||||
// Use specified known networks
|
||||
knownNetworks = new HashSet<IPNetwork>();
|
||||
|
||||
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}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
knownNetworks.Add(new IPNetwork(ipAddress, prefixLength));
|
||||
}
|
||||
}
|
||||
|
||||
// Add support for proxies
|
||||
services.Configure<ForwardedHeadersOptions>(
|
||||
options => {
|
||||
options.ForwardedHeaders = ForwardedHeaders.All;
|
||||
|
||||
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();
|
||||
|
||||
// Add support for localization
|
||||
services.AddLocalization();
|
||||
|
||||
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
|
||||
|
||||
try {
|
||||
CultureInfo lolcatCulture = CultureInfo.CreateSpecificCulture(SharedInfo.LolcatCultureName);
|
||||
|
||||
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);
|
||||
|
||||
options.SupportedCultures = options.SupportedUICultures = CultureInfo.GetCultures(CultureTypes.AllCultures);
|
||||
}
|
||||
|
||||
// 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<IRequestCultureProvider>(1) { new AcceptLanguageHeaderRequestCultureProvider() };
|
||||
}
|
||||
);
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
// 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.AddSecurityRequirement(
|
||||
new OpenApiSecurityRequirement {
|
||||
{
|
||||
new OpenApiSecurityScheme {
|
||||
Reference = new OpenApiReference {
|
||||
Id = nameof(GlobalConfig.IPCPassword),
|
||||
Type = ReferenceType.SecurityScheme
|
||||
}
|
||||
},
|
||||
|
||||
Array.Empty<string>()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
options.CustomSchemaIds(static type => type.GetUnifiedName());
|
||||
options.EnableAnnotations(true, true);
|
||||
|
||||
options.SchemaFilter<CustomAttributesSchemaFilter>();
|
||||
options.SchemaFilter<EnumSchemaFilter>();
|
||||
|
||||
options.SwaggerDoc(
|
||||
SharedInfo.ASF, new OpenApiInfo {
|
||||
Contact = new OpenApiContact {
|
||||
Name = SharedInfo.GithubRepo,
|
||||
Url = new Uri(SharedInfo.ProjectURL)
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 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<Assembly>? assemblies = PluginsCore.LoadAssemblies();
|
||||
|
||||
if (assemblies != null) {
|
||||
foreach (Assembly assembly in assemblies) {
|
||||
mvc.AddApplicationPart(assembly);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mvc.AddControllersAsServices();
|
||||
|
||||
#if NETFRAMEWORK
|
||||
// .NET Framework serializes Version as object by default, serialize it as string just like .NET Core
|
||||
options.SerializerSettings.Converters.Add(new VersionConverter());
|
||||
// Use latest compatibility version for MVC
|
||||
mvc.SetCompatibilityVersion(CompatibilityVersion.Latest);
|
||||
|
||||
// Add standard formatters
|
||||
mvc.AddFormatterMappings();
|
||||
|
||||
// Add API explorer for swagger
|
||||
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();
|
||||
|
||||
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
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
#if NETFRAMEWORK
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
#endif
|
||||
using System;
|
||||
@@ -31,72 +32,94 @@ 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) {
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
return services.AddMvcCore();
|
||||
internal static IMvcCoreBuilder AddControllers(this IServiceCollection services) {
|
||||
if (services == null) {
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
internal static IServiceCollection AddRequestLocalization(this IServiceCollection services, Action<RequestLocalizationOptions> action) {
|
||||
if (services == null) {
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
return services.AddMvcCore();
|
||||
}
|
||||
|
||||
return services.Configure(action);
|
||||
internal static IMvcCoreBuilder AddNewtonsoftJson(this IMvcCoreBuilder mvc, Action<MvcJsonOptions> setupAction) {
|
||||
if (mvc == null) {
|
||||
throw new ArgumentNullException(nameof(mvc));
|
||||
}
|
||||
|
||||
if (setupAction == null) {
|
||||
throw new ArgumentNullException(nameof(setupAction));
|
||||
}
|
||||
|
||||
// Add JSON formatters that will be used as default ones if no specific formatters are asked for
|
||||
mvc.AddJsonFormatters();
|
||||
|
||||
mvc.AddJsonOptions(setupAction);
|
||||
|
||||
return mvc;
|
||||
}
|
||||
|
||||
internal static IServiceCollection AddRequestLocalization(this IServiceCollection services, Action<RequestLocalizationOptions> action) {
|
||||
if (services == null) {
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
if (action == null) {
|
||||
throw new ArgumentNullException(nameof(action));
|
||||
}
|
||||
|
||||
return services.Configure(action);
|
||||
}
|
||||
#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<TValue>(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<TValue>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,408 +38,409 @@ 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<LoggingRule> 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<LoggingRule> 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<string?> 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<string?> 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<HistoryTarget>().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<HistoryTarget>().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;
|
||||
|
||||
// ReSharper disable once RedundantSuppressNullableWarningExpression - required for .NET Framework
|
||||
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<HistoryTarget>().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<HistoryTarget>().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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> ArchivedMessages => HistoryQueue;
|
||||
private const byte DefaultMaxCount = 20;
|
||||
|
||||
private readonly FixedSizeConcurrentQueue<string> HistoryQueue = new(DefaultMaxCount);
|
||||
internal IEnumerable<string> 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<string> 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<NewHistoryEntryArgs>? 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<NewHistoryEntryArgs>? NewHistoryEntry;
|
||||
|
||||
internal sealed class NewHistoryEntryArgs : EventArgs {
|
||||
internal readonly string Message;
|
||||
|
||||
internal NewHistoryEntryArgs(string message) => Message = message ?? throw new ArgumentNullException(nameof(message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,13 @@ using JetBrains.Annotations;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface IASF : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method right after global config initialization.
|
||||
/// </summary>
|
||||
/// <param name="additionalConfigProperties">Extra config properties made out of <see cref="JsonExtensionDataAttribute" />. Can be null if no extra properties are found.</param>
|
||||
void OnASFInit(IReadOnlyDictionary<string, JToken>? additionalConfigProperties = null);
|
||||
}
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
[PublicAPI]
|
||||
public interface IASF : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method right after global config initialization.
|
||||
/// </summary>
|
||||
/// <param name="additionalConfigProperties">Extra config properties made out of <see cref="JsonExtensionDataAttribute" />. Can be null if no extra properties are found.</param>
|
||||
void OnASFInit(IReadOnlyDictionary<string, JToken>? additionalConfigProperties = null);
|
||||
}
|
||||
|
||||
@@ -22,22 +22,22 @@
|
||||
using ArchiSteamFarm.Steam;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface IBot : IPlugin {
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
void OnBotDestroy(Bot bot);
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
void OnBotInit(Bot bot);
|
||||
}
|
||||
[PublicAPI]
|
||||
public interface IBot : IPlugin {
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
void OnBotDestroy(Bot bot);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
void OnBotInit(Bot bot);
|
||||
}
|
||||
|
||||
@@ -22,26 +22,26 @@
|
||||
using ArchiSteamFarm.Steam;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface IBotCardsFarmerInfo : IPlugin {
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="farmedSomething">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.</param>
|
||||
void OnBotFarmingFinished(Bot bot, bool farmedSomething);
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
void OnBotFarmingStarted(Bot bot);
|
||||
[PublicAPI]
|
||||
public interface IBotCardsFarmerInfo : IPlugin {
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="farmedSomething">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.</param>
|
||||
void OnBotFarmingFinished(Bot bot, bool farmedSomething);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
void OnBotFarmingStopped(Bot bot);
|
||||
}
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
void OnBotFarmingStarted(Bot bot);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
void OnBotFarmingStopped(Bot bot);
|
||||
}
|
||||
|
||||
@@ -24,17 +24,17 @@ using ArchiSteamFarm.Steam;
|
||||
using ArchiSteamFarm.Storage;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface IBotCommand : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method for unrecognized commands.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="steamID">64-bit long unsigned integer of steamID executing the command.</param>
|
||||
/// <param name="message">Command message in its raw format, stripped of <see cref="GlobalConfig.CommandPrefix" />.</param>
|
||||
/// <param name="args">Pre-parsed message using standard ASF delimiters.</param>
|
||||
/// <returns>Response to the command, or null/empty (as the task value) if the command isn't handled by this plugin.</returns>
|
||||
Task<string?> OnBotCommand(Bot bot, ulong steamID, string message, string[] args);
|
||||
}
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
[PublicAPI]
|
||||
public interface IBotCommand : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method for unrecognized commands.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="steamID">64-bit long unsigned integer of steamID executing the command.</param>
|
||||
/// <param name="message">Command message in its raw format, stripped of <see cref="GlobalConfig.CommandPrefix" />.</param>
|
||||
/// <param name="args">Pre-parsed message using standard ASF delimiters.</param>
|
||||
/// <returns>Response to the command, or null/empty (as the task value) if the command isn't handled by this plugin.</returns>
|
||||
Task<string?> OnBotCommand(Bot bot, ulong steamID, string message, string[] args);
|
||||
}
|
||||
|
||||
@@ -23,20 +23,20 @@ using ArchiSteamFarm.Steam;
|
||||
using JetBrains.Annotations;
|
||||
using SteamKit2;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface IBotConnection : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method when bot gets disconnected from Steam network.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="reason">Reason for disconnection, or <see cref="EResult.OK" /> if the disconnection was initiated by ASF (e.g. as a result of a command).</param>
|
||||
void OnBotDisconnected(Bot bot, EResult reason);
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// ASF will call this method when bot successfully connects to Steam network.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
void OnBotLoggedOn(Bot bot);
|
||||
}
|
||||
[PublicAPI]
|
||||
public interface IBotConnection : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method when bot gets disconnected from Steam network.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="reason">Reason for disconnection, or <see cref="EResult.OK" /> if the disconnection was initiated by ASF (e.g. as a result of a command).</param>
|
||||
void OnBotDisconnected(Bot bot, EResult reason);
|
||||
|
||||
/// <summary>
|
||||
/// ASF will call this method when bot successfully connects to Steam network.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
void OnBotLoggedOn(Bot bot);
|
||||
}
|
||||
|
||||
@@ -23,15 +23,15 @@ using System.Threading.Tasks;
|
||||
using ArchiSteamFarm.Steam;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface IBotFriendRequest : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method for unhandled (ignored and rejected) friend requests and Steam group invites received by the bot.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="steamID">64-bit Steam identificator of the user that sent a friend request, or a group that the bot has been invited to.</param>
|
||||
/// <returns>True if the request should be accepted as part of this plugin, false otherwise.</returns>
|
||||
Task<bool> OnBotFriendRequest(Bot bot, ulong steamID);
|
||||
}
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
[PublicAPI]
|
||||
public interface IBotFriendRequest : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method for unhandled (ignored and rejected) friend requests and Steam group invites received by the bot.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="steamID">64-bit Steam identificator of the user that sent a friend request, or a group that the bot has been invited to.</param>
|
||||
/// <returns>True if the request should be accepted as part of this plugin, false otherwise.</returns>
|
||||
Task<bool> OnBotFriendRequest(Bot bot, ulong steamID);
|
||||
}
|
||||
|
||||
@@ -24,16 +24,16 @@ using ArchiSteamFarm.Steam;
|
||||
using ArchiSteamFarm.Storage;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface IBotMessage : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method for messages that are not commands, so ones that do not start from <see cref="GlobalConfig.CommandPrefix" />.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="steamID">64-bit long unsigned integer of steamID executing the command.</param>
|
||||
/// <param name="message">Message in its raw format.</param>
|
||||
/// <returns>Response to the message, or null/empty (as the task value) for silence.</returns>
|
||||
Task<string?> OnBotMessage(Bot bot, ulong steamID, string message);
|
||||
}
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
[PublicAPI]
|
||||
public interface IBotMessage : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method for messages that are not commands, so ones that do not start from <see cref="GlobalConfig.CommandPrefix" />.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="steamID">64-bit long unsigned integer of steamID executing the command.</param>
|
||||
/// <param name="message">Message in its raw format.</param>
|
||||
/// <returns>Response to the message, or null/empty (as the task value) for silence.</returns>
|
||||
Task<string?> OnBotMessage(Bot bot, ulong steamID, string message);
|
||||
}
|
||||
|
||||
@@ -25,14 +25,14 @@ using JetBrains.Annotations;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface IBotModules : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method right after bot config initialization.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="additionalConfigProperties">Extra config properties made out of <see cref="JsonExtensionDataAttribute" />. Can be null if no extra properties are found.</param>
|
||||
void OnBotInitModules(Bot bot, IReadOnlyDictionary<string, JToken>? additionalConfigProperties = null);
|
||||
}
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
[PublicAPI]
|
||||
public interface IBotModules : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method right after bot config initialization.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="additionalConfigProperties">Extra config properties made out of <see cref="JsonExtensionDataAttribute" />. Can be null if no extra properties are found.</param>
|
||||
void OnBotInitModules(Bot bot, IReadOnlyDictionary<string, JToken>? additionalConfigProperties = null);
|
||||
}
|
||||
|
||||
@@ -24,21 +24,21 @@ using ArchiSteamFarm.Steam;
|
||||
using JetBrains.Annotations;
|
||||
using SteamKit2;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface IBotSteamClient : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method right after custom SK2 client handler initialization in order to allow you listening for callbacks in your own code.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="callbackManager">Callback manager object which can be used for establishing subscriptions to standard and custom callbacks.</param>
|
||||
void OnBotSteamCallbacksInit(Bot bot, CallbackManager callbackManager);
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// ASF will call this method right after bot initialization in order to allow you hooking custom SK2 client handlers into the SteamClient.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <returns>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.</returns>
|
||||
IReadOnlyCollection<ClientMsgHandler>? OnBotSteamHandlersInit(Bot bot);
|
||||
}
|
||||
[PublicAPI]
|
||||
public interface IBotSteamClient : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method right after custom SK2 client handler initialization in order to allow you listening for callbacks in your own code.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="callbackManager">Callback manager object which can be used for establishing subscriptions to standard and custom callbacks.</param>
|
||||
void OnBotSteamCallbacksInit(Bot bot, CallbackManager callbackManager);
|
||||
|
||||
/// <summary>
|
||||
/// ASF will call this method right after bot initialization in order to allow you hooking custom SK2 client handlers into the SteamClient.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <returns>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.</returns>
|
||||
IReadOnlyCollection<ClientMsgHandler>? OnBotSteamHandlersInit(Bot bot);
|
||||
}
|
||||
|
||||
@@ -24,15 +24,15 @@ using ArchiSteamFarm.Steam;
|
||||
using ArchiSteamFarm.Steam.Data;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface IBotTradeOffer : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method for unhandled (ignored and rejected) trade offers received by the bot.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="tradeOffer">Trade offer related to this callback.</param>
|
||||
/// <returns>True if the trade offer should be accepted as part of this plugin, false otherwise.</returns>
|
||||
Task<bool> OnBotTradeOffer(Bot bot, TradeOffer tradeOffer);
|
||||
}
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
[PublicAPI]
|
||||
public interface IBotTradeOffer : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method for unhandled (ignored and rejected) trade offers received by the bot.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="tradeOffer">Trade offer related to this callback.</param>
|
||||
/// <returns>True if the trade offer should be accepted as part of this plugin, false otherwise.</returns>
|
||||
Task<bool> OnBotTradeOffer(Bot bot, TradeOffer tradeOffer);
|
||||
}
|
||||
|
||||
@@ -24,14 +24,14 @@ using ArchiSteamFarm.Steam;
|
||||
using ArchiSteamFarm.Steam.Exchange;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface IBotTradeOfferResults : IPlugin {
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="tradeResults">Trade results related to this callback.</param>
|
||||
void OnBotTradeOfferResults(Bot bot, IReadOnlyCollection<ParseTradeResult> tradeResults);
|
||||
}
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
[PublicAPI]
|
||||
public interface IBotTradeOfferResults : IPlugin {
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="tradeResults">Trade results related to this callback.</param>
|
||||
void OnBotTradeOfferResults(Bot bot, IReadOnlyCollection<ParseTradeResult> tradeResults);
|
||||
}
|
||||
|
||||
@@ -24,14 +24,14 @@ using ArchiSteamFarm.Steam;
|
||||
using ArchiSteamFarm.Steam.Integration.Callbacks;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface IBotUserNotifications : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method when number of notifications for one or more notification types changes.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="newNotifications">Collection containing those notification types that are new (that is, when new count > previous count of that notification type).</param>
|
||||
void OnBotUserNotifications(Bot bot, IReadOnlyCollection<UserNotificationsCallback.EUserNotification> newNotifications);
|
||||
}
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
[PublicAPI]
|
||||
public interface IBotUserNotifications : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method when number of notifications for one or more notification types changes.
|
||||
/// </summary>
|
||||
/// <param name="bot">Bot object related to this callback.</param>
|
||||
/// <param name="newNotifications">Collection containing those notification types that are new (that is, when new count > previous count of that notification type).</param>
|
||||
void OnBotUserNotifications(Bot bot, IReadOnlyCollection<UserNotificationsCallback.EUserNotification> newNotifications);
|
||||
}
|
||||
|
||||
@@ -22,14 +22,14 @@
|
||||
using System;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface IBotsComparer : IPlugin {
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns>Comparer that will be used for the bots, as well as bot regexes.</returns>
|
||||
StringComparer BotsComparer { get; }
|
||||
}
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
[PublicAPI]
|
||||
public interface IBotsComparer : IPlugin {
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns>Comparer that will be used for the bots, as well as bot regexes.</returns>
|
||||
StringComparer BotsComparer { get; }
|
||||
}
|
||||
|
||||
@@ -23,14 +23,14 @@ using System.Threading.Tasks;
|
||||
using ArchiSteamFarm.Helpers;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface ICrossProcessSemaphoreProvider : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method when initializing instance of <see cref="ICrossProcessSemaphore" /> for its internal limiters.
|
||||
/// </summary>
|
||||
/// <param name="resourceName">Unique resource name provided by ASF for identification purposes.</param>
|
||||
/// <returns>Concrete implementation of <see cref="ICrossProcessSemaphore" /> providing required functionality. It's allowed to return null if you want to use ASF's default implementation for specified resource instead.</returns>
|
||||
Task<ICrossProcessSemaphore?> GetCrossProcessSemaphore(string resourceName);
|
||||
}
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
[PublicAPI]
|
||||
public interface ICrossProcessSemaphoreProvider : IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will call this method when initializing instance of <see cref="ICrossProcessSemaphore" /> for its internal limiters.
|
||||
/// </summary>
|
||||
/// <param name="resourceName">Unique resource name provided by ASF for identification purposes.</param>
|
||||
/// <returns>Concrete implementation of <see cref="ICrossProcessSemaphore" /> providing required functionality. It's allowed to return null if you want to use ASF's default implementation for specified resource instead.</returns>
|
||||
Task<ICrossProcessSemaphore?> GetCrossProcessSemaphore(string resourceName);
|
||||
}
|
||||
|
||||
@@ -23,27 +23,27 @@ using System;
|
||||
using JetBrains.Annotations;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will use this property as general plugin identifier for the user.
|
||||
/// </summary>
|
||||
/// <returns>String that will be used as the name of this plugin.</returns>
|
||||
[JsonProperty]
|
||||
string Name { get; }
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns>Version that will be shown to the user when plugin is loaded.</returns>
|
||||
[JsonProperty]
|
||||
Version Version { get; }
|
||||
[PublicAPI]
|
||||
public interface IPlugin {
|
||||
/// <summary>
|
||||
/// ASF will use this property as general plugin identifier for the user.
|
||||
/// </summary>
|
||||
/// <returns>String that will be used as the name of this plugin.</returns>
|
||||
[JsonProperty]
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ASF will call this method right after plugin initialization.
|
||||
/// </summary>
|
||||
void OnLoaded();
|
||||
}
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns>Version that will be shown to the user when plugin is loaded.</returns>
|
||||
[JsonProperty]
|
||||
Version Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ASF will call this method right after plugin initialization.
|
||||
/// </summary>
|
||||
void OnLoaded();
|
||||
}
|
||||
|
||||
@@ -24,27 +24,27 @@ using System.Threading.Tasks;
|
||||
using JetBrains.Annotations;
|
||||
using SteamKit2;
|
||||
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces {
|
||||
[PublicAPI]
|
||||
public interface ISteamPICSChanges : IPlugin {
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns>The most recent change number from which you're fine to receive <see cref="OnPICSChanges" /></returns>
|
||||
Task<uint> GetPreferredChangeNumberToStartFrom();
|
||||
namespace ArchiSteamFarm.Plugins.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// ASF will call this method upon receiving any app/package PICS changes. The history is guaranteed to be precise and continuous starting from <see cref="GetPreferredChangeNumberToStartFrom" /> until <see cref="OnPICSChangesRestart" /> is called. It's possible for this method to have duplicated calls across different runs, in particular when some other plugin asks for lower <see cref="GetPreferredChangeNumberToStartFrom" />, therefore you should keep that in mind (and refer to change number of standalone apps/packages).
|
||||
/// </summary>
|
||||
/// <param name="currentChangeNumber">The change number of current callback.</param>
|
||||
/// <param name="appChanges">App changes that happened since the previous call of this method. Can be empty.</param>
|
||||
/// <param name="packageChanges">Package changes that happened since the previous call of this method. Can be empty.</param>
|
||||
void OnPICSChanges(uint currentChangeNumber, IReadOnlyDictionary<uint, SteamApps.PICSChangesCallback.PICSChangeData> appChanges, IReadOnlyDictionary<uint, SteamApps.PICSChangesCallback.PICSChangeData> packageChanges);
|
||||
[PublicAPI]
|
||||
public interface ISteamPICSChanges : IPlugin {
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns>The most recent change number from which you're fine to receive <see cref="OnPICSChanges" /></returns>
|
||||
Task<uint> GetPreferredChangeNumberToStartFrom();
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="OnPICSChanges" />, ASF can no longer guarantee that upon calling this method, therefore you should start clean.
|
||||
/// </summary>
|
||||
/// <param name="currentChangeNumber">The change number from which we're restarting the PICS history.</param>
|
||||
void OnPICSChangesRestart(uint currentChangeNumber);
|
||||
}
|
||||
/// <summary>
|
||||
/// ASF will call this method upon receiving any app/package PICS changes. The history is guaranteed to be precise and continuous starting from <see cref="GetPreferredChangeNumberToStartFrom" /> until <see cref="OnPICSChangesRestart" /> is called. It's possible for this method to have duplicated calls across different runs, in particular when some other plugin asks for lower <see cref="GetPreferredChangeNumberToStartFrom" />, therefore you should keep that in mind (and refer to change number of standalone apps/packages).
|
||||
/// </summary>
|
||||
/// <param name="currentChangeNumber">The change number of current callback.</param>
|
||||
/// <param name="appChanges">App changes that happened since the previous call of this method. Can be empty.</param>
|
||||
/// <param name="packageChanges">Package changes that happened since the previous call of this method. Can be empty.</param>
|
||||
void OnPICSChanges(uint currentChangeNumber, IReadOnlyDictionary<uint, SteamApps.PICSChangesCallback.PICSChangeData> appChanges, IReadOnlyDictionary<uint, SteamApps.PICSChangesCallback.PICSChangeData> packageChanges);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="OnPICSChanges" />, ASF can no longer guarantee that upon calling this method, therefore you should start clean.
|
||||
/// </summary>
|
||||
/// <param name="currentChangeNumber">The change number from which we're restarting the PICS history.</param>
|
||||
void OnPICSChangesRestart(uint currentChangeNumber);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user