Automatic translations update
[ArchiSteamFarm.git] / ArchiSteamFarm.OfficialPlugins.SteamTokenDumper / SteamTokenDumperPlugin.cs
blob167cace8ee0711ec500884be5ed12eb8ceeb75a5
1 // ----------------------------------------------------------------------------------------------
2 // _ _ _ ____ _ _____
3 // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___
4 // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \
5 // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | |
6 // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_|
7 // ----------------------------------------------------------------------------------------------
8 // |
9 // Copyright 2015-2025 Ɓukasz "JustArchi" Domeradzki
10 // Contact: JustArchi@JustArchi.net
11 // |
12 // Licensed under the Apache License, Version 2.0 (the "License");
13 // you may not use this file except in compliance with the License.
14 // You may obtain a copy of the License at
15 // |
16 // http://www.apache.org/licenses/LICENSE-2.0
17 // |
18 // Unless required by applicable law or agreed to in writing, software
19 // distributed under the License is distributed on an "AS IS" BASIS,
20 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21 // See the License for the specific language governing permissions and
22 // limitations under the License.
24 using System;
25 using System.Collections.Concurrent;
26 using System.Collections.Frozen;
27 using System.Collections.Generic;
28 using System.ComponentModel;
29 using System.Composition;
30 using System.Linq;
31 using System.Net;
32 using System.Text.Json;
33 using System.Text.Json.Serialization;
34 using System.Threading;
35 using System.Threading.Tasks;
36 using ArchiSteamFarm.Core;
37 using ArchiSteamFarm.Helpers;
38 using ArchiSteamFarm.Helpers.Json;
39 using ArchiSteamFarm.OfficialPlugins.SteamTokenDumper.Data;
40 using ArchiSteamFarm.OfficialPlugins.SteamTokenDumper.Localization;
41 using ArchiSteamFarm.Plugins;
42 using ArchiSteamFarm.Plugins.Interfaces;
43 using ArchiSteamFarm.Steam;
44 using ArchiSteamFarm.Steam.Interaction;
45 using ArchiSteamFarm.Storage;
46 using ArchiSteamFarm.Web;
47 using ArchiSteamFarm.Web.Responses;
48 using SteamKit2;
50 namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper;
52 [Export(typeof(IPlugin))]
53 internal sealed class SteamTokenDumperPlugin : OfficialPlugin, IASF, IBot, IBotCommand2, IBotSteamClient, ISteamPICSChanges {
54 private const ushort DepotsRateLimitingDelay = 500;
56 internal static SteamTokenDumperConfig? Config { get; private set; }
58 private static readonly ConcurrentDictionary<Bot, IDisposable> BotSubscriptions = new();
59 private static readonly ConcurrentDictionary<Bot, (SemaphoreSlim RefreshSemaphore, Timer RefreshTimer)> BotSynchronizations = new();
60 private static readonly SemaphoreSlim SubmissionSemaphore = new(1, 1);
61 private static readonly Timer SubmissionTimer = new(OnSubmissionTimer);
63 private static GlobalCache? GlobalCache;
64 private static DateTimeOffset LastUploadAt = DateTimeOffset.MinValue;
66 [JsonInclude]
67 public override string Name => nameof(SteamTokenDumperPlugin);
69 [JsonInclude]
70 public override Version Version => typeof(SteamTokenDumperPlugin).Assembly.GetName().Version ?? throw new InvalidOperationException(nameof(Version));
72 public Task<uint> GetPreferredChangeNumberToStartFrom() => Task.FromResult(GlobalCache?.LastChangeNumber ?? 0);
74 public async Task OnASFInit(IReadOnlyDictionary<string, JsonElement>? additionalConfigProperties = null) {
75 if (!SharedInfo.HasValidToken) {
76 ASF.ArchiLogger.LogGenericError(Strings.FormatPluginDisabledMissingBuildToken(nameof(SteamTokenDumperPlugin)));
78 return;
81 bool isEnabled = false;
82 SteamTokenDumperConfig? config = null;
84 if (additionalConfigProperties != null) {
85 foreach ((string configProperty, JsonElement configValue) in additionalConfigProperties) {
86 try {
87 switch (configProperty) {
88 case nameof(GlobalConfigExtension.SteamTokenDumperPlugin):
89 config = configValue.ToJsonObject<SteamTokenDumperConfig>();
91 break;
92 case nameof(GlobalConfigExtension.SteamTokenDumperPluginEnabled) when configValue.ValueKind == JsonValueKind.False:
93 isEnabled = false;
95 break;
96 case nameof(GlobalConfigExtension.SteamTokenDumperPluginEnabled) when configValue.ValueKind == JsonValueKind.True:
97 isEnabled = true;
99 break;
101 } catch (Exception e) {
102 ASF.ArchiLogger.LogGenericException(e);
103 ASF.ArchiLogger.LogGenericWarning(Strings.FormatPluginDisabledInConfig(nameof(SteamTokenDumperPlugin)));
105 return;
110 if (GlobalCache == null) {
111 GlobalCache? globalCache = await GlobalCache.Load().ConfigureAwait(false);
113 if (globalCache == null) {
114 ASF.ArchiLogger.LogGenericError(Strings.FormatFileCouldNotBeLoadedFreshInit(nameof(GlobalCache)));
116 GlobalCache = new GlobalCache();
117 } else {
118 GlobalCache = globalCache;
122 if (!isEnabled && (config == null)) {
123 ASF.ArchiLogger.LogGenericInfo(Strings.FormatPluginDisabledInConfig(nameof(SteamTokenDumperPlugin)));
125 return;
128 config ??= new SteamTokenDumperConfig();
130 if (isEnabled) {
131 config.Enabled = true;
134 if (!config.Enabled) {
135 ASF.ArchiLogger.LogGenericInfo(Strings.FormatPluginDisabledInConfig(nameof(SteamTokenDumperPlugin)));
138 if (!config.SecretAppIDs.IsEmpty) {
139 ASF.ArchiLogger.LogGenericInfo(Strings.FormatPluginSecretListInitialized(nameof(config.SecretAppIDs), string.Join(", ", config.SecretAppIDs)));
142 if (!config.SecretPackageIDs.IsEmpty) {
143 ASF.ArchiLogger.LogGenericInfo(Strings.FormatPluginSecretListInitialized(nameof(config.SecretPackageIDs), string.Join(", ", config.SecretPackageIDs)));
146 if (!config.SecretDepotIDs.IsEmpty) {
147 ASF.ArchiLogger.LogGenericInfo(Strings.FormatPluginSecretListInitialized(nameof(config.SecretDepotIDs), string.Join(", ", config.SecretDepotIDs)));
150 Config = config;
152 if (!config.Enabled) {
153 return;
156 #pragma warning disable CA5394 // This call isn't used in a security-sensitive manner
157 TimeSpan startIn = TimeSpan.FromMinutes(Random.Shared.Next(SharedInfo.MinimumMinutesBeforeFirstUpload, SharedInfo.MaximumMinutesBeforeFirstUpload));
158 #pragma warning restore CA5394 // This call isn't used in a security-sensitive manner
160 // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that
161 lock (SubmissionSemaphore) {
162 SubmissionTimer.Change(startIn, TimeSpan.FromHours(SharedInfo.HoursBetweenUploads));
165 ASF.ArchiLogger.LogGenericInfo(Strings.FormatPluginInitializedAndEnabled(nameof(SteamTokenDumperPlugin), startIn.ToHumanReadable()));
168 public Task<string?> OnBotCommand(Bot bot, EAccess access, string message, string[] args, ulong steamID = 0) {
169 ArgumentNullException.ThrowIfNull(bot);
171 if (!Enum.IsDefined(access)) {
172 throw new InvalidEnumArgumentException(nameof(access), (int) access, typeof(EAccess));
175 if ((args == null) || (args.Length == 0)) {
176 throw new ArgumentNullException(nameof(args));
179 switch (args.Length) {
180 case 1:
181 switch (args[0].ToUpperInvariant()) {
182 case "STD":
183 return Task.FromResult(ResponseRefreshManually(access, bot));
186 break;
187 default:
188 switch (args[0].ToUpperInvariant()) {
189 case "STD":
190 return Task.FromResult(ResponseRefreshManually(access, Utilities.GetArgsAsText(args, 1, ","), steamID));
193 break;
196 return Task.FromResult<string?>(null);
199 public async Task OnBotDestroy(Bot bot) {
200 ArgumentNullException.ThrowIfNull(bot);
202 if (BotSubscriptions.TryRemove(bot, out IDisposable? subscription)) {
203 subscription.Dispose();
206 if (BotSynchronizations.TryRemove(bot, out (SemaphoreSlim RefreshSemaphore, Timer RefreshTimer) synchronization)) {
207 // Ensure the semaphore is empty, otherwise we're risking disposed exceptions
208 await synchronization.RefreshSemaphore.WaitAsync().ConfigureAwait(false);
210 synchronization.RefreshSemaphore.Dispose();
212 await synchronization.RefreshTimer.DisposeAsync().ConfigureAwait(false);
216 public async Task OnBotInit(Bot bot) {
217 ArgumentNullException.ThrowIfNull(bot);
219 if (GlobalCache == null) {
220 // We can't operate like this anyway, skip initialization of synchronization structures
221 return;
224 SemaphoreSlim refreshSemaphore = new(1, 1);
225 Timer refreshTimer = new(OnBotRefreshTimer, bot, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
227 if (!BotSynchronizations.TryAdd(bot, (refreshSemaphore, refreshTimer))) {
228 refreshSemaphore.Dispose();
230 await refreshTimer.DisposeAsync().ConfigureAwait(false);
234 public Task OnBotSteamCallbacksInit(Bot bot, CallbackManager callbackManager) {
235 ArgumentNullException.ThrowIfNull(bot);
236 ArgumentNullException.ThrowIfNull(callbackManager);
238 if (BotSubscriptions.TryRemove(bot, out IDisposable? subscription)) {
239 subscription.Dispose();
242 if (Config is not { Enabled: true }) {
243 return Task.CompletedTask;
246 subscription = callbackManager.Subscribe<SteamApps.LicenseListCallback>(callback => OnLicenseList(bot, callback));
248 if (!BotSubscriptions.TryAdd(bot, subscription)) {
249 subscription.Dispose();
252 return Task.CompletedTask;
255 public Task<IReadOnlyCollection<ClientMsgHandler>?> OnBotSteamHandlersInit(Bot bot) => Task.FromResult<IReadOnlyCollection<ClientMsgHandler>?>(null);
257 public override Task OnLoaded() {
258 Utilities.WarnAboutIncompleteTranslation(Strings.ResourceManager);
260 return Task.CompletedTask;
263 public Task OnPICSChanges(uint currentChangeNumber, IReadOnlyDictionary<uint, SteamApps.PICSChangesCallback.PICSChangeData> appChanges, IReadOnlyDictionary<uint, SteamApps.PICSChangesCallback.PICSChangeData> packageChanges) {
264 ArgumentOutOfRangeException.ThrowIfZero(currentChangeNumber);
265 ArgumentNullException.ThrowIfNull(appChanges);
266 ArgumentNullException.ThrowIfNull(packageChanges);
268 GlobalCache?.OnPICSChanges(currentChangeNumber, appChanges);
270 return Task.CompletedTask;
273 public Task OnPICSChangesRestart(uint currentChangeNumber) {
274 ArgumentOutOfRangeException.ThrowIfZero(currentChangeNumber);
276 GlobalCache?.OnPICSChangesRestart(currentChangeNumber);
278 return Task.CompletedTask;
281 private static async void OnBotRefreshTimer(object? state) {
282 if (state is not Bot bot) {
283 throw new InvalidOperationException(nameof(state));
286 await Refresh(bot).ConfigureAwait(false);
289 private static async void OnLicenseList(Bot bot, SteamApps.LicenseListCallback callback) {
290 ArgumentNullException.ThrowIfNull(bot);
291 ArgumentNullException.ThrowIfNull(callback);
293 if (Config is not { Enabled: true }) {
294 return;
297 // Schedule a refresh in a while from now
298 if (!BotSynchronizations.TryGetValue(bot, out (SemaphoreSlim RefreshSemaphore, Timer RefreshTimer) synchronization)) {
299 return;
302 if (!await synchronization.RefreshSemaphore.WaitAsync(0).ConfigureAwait(false)) {
303 // Another refresh is in progress, skip the refresh for now
304 return;
307 try {
308 synchronization.RefreshTimer.Change(TimeSpan.FromMinutes(1), TimeSpan.FromHours(SharedInfo.MaximumHoursBetweenRefresh));
309 } finally {
310 synchronization.RefreshSemaphore.Release();
314 private static async void OnSubmissionTimer(object? state = null) => await SubmitData().ConfigureAwait(false);
316 private static async Task Refresh(Bot bot) {
317 ArgumentNullException.ThrowIfNull(bot);
319 if (GlobalCache == null) {
320 throw new InvalidOperationException(nameof(GlobalCache));
323 if (ASF.GlobalDatabase == null) {
324 throw new InvalidOperationException(nameof(ASF.GlobalDatabase));
327 if (!BotSynchronizations.TryGetValue(bot, out (SemaphoreSlim RefreshSemaphore, Timer RefreshTimer) synchronization)) {
328 throw new InvalidOperationException(nameof(synchronization));
331 if (!await synchronization.RefreshSemaphore.WaitAsync(0).ConfigureAwait(false)) {
332 return;
335 SemaphoreSlim depotsRateLimitingSemaphore = new(1, 1);
337 try {
338 if (!bot.IsConnectedAndLoggedOn) {
339 return;
342 HashSet<uint> packageIDs = bot.OwnedPackages.Where(static package => (Config?.SecretPackageIDs.Contains(package.Key) != true) && ((package.Value.PaymentMethod != EPaymentMethod.AutoGrant) || (Config?.SkipAutoGrantPackages == false))).Select(static package => package.Key).ToHashSet();
344 HashSet<uint> appIDsToRefresh = [];
346 foreach (uint packageID in packageIDs.Where(static packageID => Config?.SecretPackageIDs.Contains(packageID) != true)) {
347 if (!ASF.GlobalDatabase.PackagesDataReadOnly.TryGetValue(packageID, out PackageData? packageData) || (packageData.AppIDs == null)) {
348 // ASF might not have the package info for us at the moment, we'll retry later
349 continue;
352 appIDsToRefresh.UnionWith(packageData.AppIDs.Where(static appID => (Config?.SecretAppIDs.Contains(appID) != true) && GlobalCache.ShouldRefreshAppInfo(appID)));
355 if (appIDsToRefresh.Count == 0) {
356 bot.ArchiLogger.LogGenericDebug(Strings.BotNoAppsToRefresh);
358 return;
361 bot.ArchiLogger.LogGenericInfo(Strings.FormatBotRetrievingTotalAppAccessTokens(appIDsToRefresh.Count));
363 HashSet<uint> appIDsThisRound = new(Math.Min(appIDsToRefresh.Count, SharedInfo.AppInfosPerSingleRequest));
365 using (HashSet<uint>.Enumerator enumerator = appIDsToRefresh.GetEnumerator()) {
366 while (true) {
367 if (!bot.IsConnectedAndLoggedOn) {
368 return;
371 while ((appIDsThisRound.Count < SharedInfo.AppInfosPerSingleRequest) && enumerator.MoveNext()) {
372 appIDsThisRound.Add(enumerator.Current);
375 if (appIDsThisRound.Count == 0) {
376 break;
379 bot.ArchiLogger.LogGenericInfo(Strings.FormatBotRetrievingAppAccessTokens(appIDsThisRound.Count));
381 SteamApps.PICSTokensCallback response;
383 try {
384 response = await bot.SteamApps.PICSGetAccessTokens(appIDsThisRound, []).ToLongRunningTask().ConfigureAwait(false);
385 } catch (Exception e) {
386 bot.ArchiLogger.LogGenericWarningException(e);
388 appIDsThisRound.Clear();
390 continue;
393 bot.ArchiLogger.LogGenericInfo(Strings.FormatBotFinishedRetrievingAppAccessTokens(appIDsThisRound.Count));
395 appIDsThisRound.Clear();
397 GlobalCache.UpdateAppTokens(response.AppTokens, response.AppTokensDenied);
401 bot.ArchiLogger.LogGenericInfo(Strings.FormatBotFinishedRetrievingTotalAppAccessTokens(appIDsToRefresh.Count));
402 bot.ArchiLogger.LogGenericInfo(Strings.FormatBotRetrievingTotalDepots(appIDsToRefresh.Count));
404 (_, FrozenSet<uint>? knownDepotIDs) = await GlobalCache.KnownDepotIDs.GetValue(ECacheFallback.SuccessPreviously).ConfigureAwait(false);
406 using (HashSet<uint>.Enumerator enumerator = appIDsToRefresh.GetEnumerator()) {
407 while (true) {
408 if (!bot.IsConnectedAndLoggedOn) {
409 return;
412 while ((appIDsThisRound.Count < SharedInfo.AppInfosPerSingleRequest) && enumerator.MoveNext()) {
413 appIDsThisRound.Add(enumerator.Current);
416 if (appIDsThisRound.Count == 0) {
417 break;
420 bot.ArchiLogger.LogGenericInfo(Strings.FormatBotRetrievingAppInfos(appIDsThisRound.Count));
422 AsyncJobMultiple<SteamApps.PICSProductInfoCallback>.ResultSet response;
424 try {
425 response = await bot.SteamApps.PICSGetProductInfo(appIDsThisRound.Select(static appID => new SteamApps.PICSRequest(appID, GlobalCache.GetAppToken(appID))), []).ToLongRunningTask().ConfigureAwait(false);
426 } catch (Exception e) {
427 bot.ArchiLogger.LogGenericWarningException(e);
429 appIDsThisRound.Clear();
431 continue;
434 if (response.Results == null) {
435 bot.ArchiLogger.LogGenericWarning(ArchiSteamFarm.Localization.Strings.FormatWarningFailedWithError(nameof(response.Results)));
437 appIDsThisRound.Clear();
439 continue;
442 bot.ArchiLogger.LogGenericInfo(Strings.FormatBotFinishedRetrievingAppInfos(appIDsThisRound.Count));
444 appIDsThisRound.Clear();
446 Dictionary<uint, uint> appChangeNumbers = new();
448 uint depotKeysSuccessful = 0;
449 uint depotKeysTotal = 0;
451 foreach (SteamApps.PICSProductInfoCallback.PICSProductInfo app in response.Results.SelectMany(static result => result.Apps.Values)) {
452 appChangeNumbers[app.ID] = app.ChangeNumber;
454 bool shouldFetchMainKey = false;
456 foreach (KeyValue depot in app.KeyValues["depots"].Children) {
457 if (!uint.TryParse(depot.Name, out uint depotID) || (knownDepotIDs?.Contains(depotID) == true) || (Config?.SecretDepotIDs.Contains(depotID) == true) || !GlobalCache.ShouldRefreshDepotKey(depotID)) {
458 continue;
461 depotKeysTotal++;
463 await depotsRateLimitingSemaphore.WaitAsync().ConfigureAwait(false);
465 try {
466 SteamApps.DepotKeyCallback depotResponse = await bot.SteamApps.GetDepotDecryptionKey(depotID, app.ID).ToLongRunningTask().ConfigureAwait(false);
468 depotKeysSuccessful++;
470 if (depotResponse.Result != EResult.OK) {
471 continue;
474 shouldFetchMainKey = true;
476 GlobalCache.UpdateDepotKey(depotResponse);
477 } catch (Exception e) {
478 // We can still try other depots
479 bot.ArchiLogger.LogGenericWarningException(e);
480 } finally {
481 Utilities.InBackground(
482 async () => {
483 await Task.Delay(DepotsRateLimitingDelay).ConfigureAwait(false);
485 // ReSharper disable once AccessToDisposedClosure - we're waiting for the semaphore to be free before disposing it
486 depotsRateLimitingSemaphore.Release();
492 // Consider fetching main appID key only if we've actually considered some new depots for resolving
493 if (shouldFetchMainKey && (knownDepotIDs?.Contains(app.ID) != true) && GlobalCache.ShouldRefreshDepotKey(app.ID)) {
494 depotKeysTotal++;
496 await depotsRateLimitingSemaphore.WaitAsync().ConfigureAwait(false);
498 try {
499 SteamApps.DepotKeyCallback depotResponse = await bot.SteamApps.GetDepotDecryptionKey(app.ID, app.ID).ToLongRunningTask().ConfigureAwait(false);
501 depotKeysSuccessful++;
503 GlobalCache.UpdateDepotKey(depotResponse);
504 } catch (Exception e) {
505 // We can still try other depots
506 bot.ArchiLogger.LogGenericWarningException(e);
507 } finally {
508 Utilities.InBackground(
509 async () => {
510 await Task.Delay(DepotsRateLimitingDelay).ConfigureAwait(false);
512 // ReSharper disable once AccessToDisposedClosure - we're waiting for the semaphore to be free before disposing it
513 depotsRateLimitingSemaphore.Release();
520 if (depotKeysTotal > 0) {
521 bot.ArchiLogger.LogGenericInfo(Strings.FormatBotFinishedRetrievingDepotKeys(depotKeysSuccessful, depotKeysTotal));
524 if (depotKeysSuccessful < depotKeysTotal) {
525 // We're not going to record app change numbers, as we didn't fetch all the depot keys we wanted
526 continue;
529 GlobalCache.UpdateAppChangeNumbers(appChangeNumbers);
533 bot.ArchiLogger.LogGenericInfo(Strings.FormatBotFinishedRetrievingTotalDepots(appIDsToRefresh.Count));
534 } finally {
535 if (Config?.Enabled == true) {
536 TimeSpan timeSpan = TimeSpan.FromHours(SharedInfo.MaximumHoursBetweenRefresh);
538 synchronization.RefreshTimer.Change(timeSpan, timeSpan);
541 await depotsRateLimitingSemaphore.WaitAsync().ConfigureAwait(false);
543 synchronization.RefreshSemaphore.Release();
545 depotsRateLimitingSemaphore.Dispose();
549 private static string? ResponseRefreshManually(EAccess access, Bot bot) {
550 if (!Enum.IsDefined(access)) {
551 throw new InvalidEnumArgumentException(nameof(access), (int) access, typeof(EAccess));
554 ArgumentNullException.ThrowIfNull(bot);
556 if (access < EAccess.Master) {
557 return access > EAccess.None ? bot.Commands.FormatBotResponse(ArchiSteamFarm.Localization.Strings.ErrorAccessDenied) : null;
560 if (GlobalCache == null) {
561 return bot.Commands.FormatBotResponse(ArchiSteamFarm.Localization.Strings.FormatWarningFailedWithError(nameof(GlobalCache)));
564 Utilities.InBackground(
565 async () => {
566 await Refresh(bot).ConfigureAwait(false);
567 await SubmitData().ConfigureAwait(false);
571 return bot.Commands.FormatBotResponse(ArchiSteamFarm.Localization.Strings.Done);
574 private static string? ResponseRefreshManually(EAccess access, string botNames, ulong steamID = 0) {
575 if (!Enum.IsDefined(access)) {
576 throw new InvalidEnumArgumentException(nameof(access), (int) access, typeof(EAccess));
579 ArgumentException.ThrowIfNullOrEmpty(botNames);
581 if ((steamID != 0) && !new SteamID(steamID).IsIndividualAccount) {
582 throw new ArgumentOutOfRangeException(nameof(steamID));
585 HashSet<Bot>? bots = Bot.GetBots(botNames);
587 if ((bots == null) || (bots.Count == 0)) {
588 return access >= EAccess.Owner ? Commands.FormatStaticResponse(ArchiSteamFarm.Localization.Strings.FormatBotNotFound(botNames)) : null;
591 if (bots.RemoveWhere(bot => Commands.GetProxyAccess(bot, access, steamID) < EAccess.Master) > 0) {
592 if (bots.Count == 0) {
593 return access >= EAccess.Owner ? Commands.FormatStaticResponse(ArchiSteamFarm.Localization.Strings.FormatBotNotFound(botNames)) : null;
597 if (GlobalCache == null) {
598 return Commands.FormatStaticResponse(ArchiSteamFarm.Localization.Strings.FormatWarningFailedWithError(nameof(GlobalCache)));
601 Utilities.InBackground(
602 async () => {
603 await Utilities.InParallel(bots.Select(static bot => Refresh(bot))).ConfigureAwait(false);
605 await SubmitData().ConfigureAwait(false);
609 return Commands.FormatStaticResponse(ArchiSteamFarm.Localization.Strings.Done);
612 private static async Task SubmitData(CancellationToken cancellationToken = default) {
613 if (Bot.Bots == null) {
614 throw new InvalidOperationException(nameof(Bot.Bots));
617 if (GlobalCache == null) {
618 throw new InvalidOperationException(nameof(GlobalCache));
621 if (ASF.WebBrowser == null) {
622 throw new InvalidOperationException(nameof(ASF.WebBrowser));
625 if (LastUploadAt + TimeSpan.FromMinutes(SharedInfo.MinimumMinutesBetweenUploads) > DateTimeOffset.UtcNow) {
626 return;
629 if (!await SubmissionSemaphore.WaitAsync(0, cancellationToken).ConfigureAwait(false)) {
630 return;
633 try {
634 Dictionary<uint, ulong> appTokens = GlobalCache.GetAppTokensForSubmission();
635 Dictionary<uint, ulong> packageTokens = GlobalCache.GetPackageTokensForSubmission();
636 Dictionary<uint, string> depotKeys = GlobalCache.GetDepotKeysForSubmission();
638 if ((appTokens.Count == 0) && (packageTokens.Count == 0) && (depotKeys.Count == 0)) {
639 ASF.ArchiLogger.LogGenericInfo(Strings.SubmissionNoNewData);
641 return;
644 ulong contributorSteamID = ASF.GlobalConfig is { SteamOwnerID: > 0 } && new SteamID(ASF.GlobalConfig.SteamOwnerID).IsIndividualAccount ? ASF.GlobalConfig.SteamOwnerID : Bot.Bots.Values.Where(static bot => bot.SteamID > 0).MaxBy(static bot => bot.OwnedPackages.Count)?.SteamID ?? 0;
646 if (contributorSteamID == 0) {
647 ASF.ArchiLogger.LogGenericError(Strings.FormatSubmissionNoContributorSet(nameof(ASF.GlobalConfig.SteamOwnerID)));
649 return;
652 Uri request = new($"{SharedInfo.ServerURL}/submit");
653 SubmitRequest data = new(contributorSteamID, appTokens, packageTokens, depotKeys);
655 ASF.ArchiLogger.LogGenericInfo(Strings.FormatSubmissionInProgress(appTokens.Count, packageTokens.Count, depotKeys.Count));
657 ObjectResponse<SubmitResponse>? response = await ASF.WebBrowser.UrlPostToJsonObject<SubmitResponse, SubmitRequest>(request, data: data, requestOptions: WebBrowser.ERequestOptions.ReturnClientErrors | WebBrowser.ERequestOptions.AllowInvalidBodyOnErrors, cancellationToken: cancellationToken).ConfigureAwait(false);
659 if (response == null) {
660 ASF.ArchiLogger.LogGenericWarning(ArchiSteamFarm.Localization.Strings.WarningFailed);
662 return;
665 // We've communicated with the server and didn't timeout, regardless of the success, this was the last upload attempt
666 LastUploadAt = DateTimeOffset.UtcNow;
668 if (response.StatusCode.IsClientErrorCode()) {
669 ASF.ArchiLogger.LogGenericWarning(ArchiSteamFarm.Localization.Strings.FormatWarningFailedWithError(response.StatusCode));
671 switch (response.StatusCode) {
672 case HttpStatusCode.Forbidden when Config?.Enabled == true:
673 // SteamDB told us to stop submitting data for now
674 // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that
675 lock (SubmissionSemaphore) {
676 SubmissionTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
679 break;
680 case HttpStatusCode.Conflict:
681 // SteamDB told us to reset our cache
682 GlobalCache.Reset(true);
684 break;
685 case HttpStatusCode.TooManyRequests when Config?.Enabled == true:
686 // SteamDB told us to try again later
687 #pragma warning disable CA5394 // This call isn't used in a security-sensitive manner
688 TimeSpan startIn = TimeSpan.FromMinutes(Random.Shared.Next(SharedInfo.MinimumMinutesBeforeFirstUpload, SharedInfo.MaximumMinutesBeforeFirstUpload));
689 #pragma warning restore CA5394 // This call isn't used in a security-sensitive manner
691 // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that
692 lock (SubmissionSemaphore) {
693 SubmissionTimer.Change(startIn, TimeSpan.FromHours(SharedInfo.HoursBetweenUploads));
696 ASF.ArchiLogger.LogGenericInfo(Strings.FormatSubmissionFailedTooManyRequests(startIn.ToHumanReadable()));
698 break;
701 return;
704 if (response.Content is not { Success: true }) {
705 ASF.ArchiLogger.LogGenericError(ArchiSteamFarm.Localization.Strings.WarningFailed);
707 return;
710 if (response.Content.Data == null) {
711 ASF.ArchiLogger.LogGenericError(ArchiSteamFarm.Localization.Strings.FormatErrorIsInvalid(nameof(response.Content.Data)));
713 return;
716 ASF.ArchiLogger.LogGenericInfo(Strings.FormatSubmissionSuccessful(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));
718 GlobalCache.UpdateSubmittedData(appTokens, packageTokens, depotKeys);
720 if (!response.Content.Data.NewApps.IsEmpty) {
721 ASF.ArchiLogger.LogGenericInfo(Strings.FormatSubmissionSuccessfulNewApps(string.Join(", ", response.Content.Data.NewApps)));
724 if (!response.Content.Data.VerifiedApps.IsEmpty) {
725 ASF.ArchiLogger.LogGenericInfo(Strings.FormatSubmissionSuccessfulVerifiedApps(string.Join(", ", response.Content.Data.VerifiedApps)));
728 if (!response.Content.Data.NewPackages.IsEmpty) {
729 ASF.ArchiLogger.LogGenericInfo(Strings.FormatSubmissionSuccessfulNewPackages(string.Join(", ", response.Content.Data.NewPackages)));
732 if (!response.Content.Data.VerifiedPackages.IsEmpty) {
733 ASF.ArchiLogger.LogGenericInfo(Strings.FormatSubmissionSuccessfulVerifiedPackages(string.Join(", ", response.Content.Data.VerifiedPackages)));
736 if (!response.Content.Data.NewDepots.IsEmpty) {
737 ASF.ArchiLogger.LogGenericInfo(Strings.FormatSubmissionSuccessfulNewDepots(string.Join(", ", response.Content.Data.NewDepots)));
740 if (!response.Content.Data.VerifiedDepots.IsEmpty) {
741 ASF.ArchiLogger.LogGenericInfo(Strings.FormatSubmissionSuccessfulVerifiedDepots(string.Join(", ", response.Content.Data.VerifiedDepots)));
743 } finally {
744 SubmissionSemaphore.Release();