Update ASF-ui digest to 51952a3
[ArchiSteamFarm.git] / ArchiSteamFarm / Steam / Bot.cs
blobff0a9cc5ed482faa0405d22bc1e053e3f4b0f047
1 // ----------------------------------------------------------------------------------------------
2 // _ _ _ ____ _ _____
3 // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___
4 // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \
5 // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | |
6 // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_|
7 // ----------------------------------------------------------------------------------------------
8 // |
9 // Copyright 2015-2024 Ɓ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;
26 using System.Collections.Concurrent;
27 using System.Collections.Frozen;
28 using System.Collections.Generic;
29 using System.Collections.Immutable;
30 using System.Collections.Specialized;
31 using System.ComponentModel;
32 using System.ComponentModel.DataAnnotations;
33 using System.Globalization;
34 using System.IO;
35 using System.Linq;
36 using System.Text.Json.Serialization;
37 using System.Text.RegularExpressions;
38 using System.Threading;
39 using System.Threading.Tasks;
40 using AngleSharp.Dom;
41 using ArchiSteamFarm.Collections;
42 using ArchiSteamFarm.Core;
43 using ArchiSteamFarm.Helpers;
44 using ArchiSteamFarm.Helpers.Json;
45 using ArchiSteamFarm.Localization;
46 using ArchiSteamFarm.NLog;
47 using ArchiSteamFarm.Plugins;
48 using ArchiSteamFarm.Steam.Cards;
49 using ArchiSteamFarm.Steam.Data;
50 using ArchiSteamFarm.Steam.Exchange;
51 using ArchiSteamFarm.Steam.Integration;
52 using ArchiSteamFarm.Steam.Integration.Callbacks;
53 using ArchiSteamFarm.Steam.Interaction;
54 using ArchiSteamFarm.Steam.Security;
55 using ArchiSteamFarm.Steam.Storage;
56 using ArchiSteamFarm.Storage;
57 using ArchiSteamFarm.Web;
58 using JetBrains.Annotations;
59 using Microsoft.IdentityModel.JsonWebTokens;
60 using SteamKit2;
61 using SteamKit2.Authentication;
62 using SteamKit2.Internal;
64 namespace ArchiSteamFarm.Steam;
66 public sealed class Bot : IAsyncDisposable, IDisposable {
67 internal const ushort CallbackSleep = 500; // In milliseconds
68 internal const byte MinCardsPerBadge = 5;
70 private const char DefaultBackgroundKeysRedeemerSeparator = '\t';
71 private const byte LoginCooldownInMinutes = 25; // Captcha disappears after around 20 minutes, so we make it 25
72 private const uint LoginID = 1242; // This must be the same for all ASF bots and all ASF processes
73 private const byte MaxLoginFailures = WebBrowser.MaxTries; // Max login failures in a row before we determine that our credentials are invalid (because Steam wrongly returns those, of course)course)
74 private const byte MinimumAccessTokenValidityMinutes = 5;
75 private const byte RedeemCooldownInHours = 1; // 1 hour since first redeem attempt, this is a limitation enforced by Steam
76 private const byte RegionRestrictionPlayableBlockMonths = 3;
78 [PublicAPI]
79 public static IReadOnlyDictionary<string, Bot>? BotsReadOnly => Bots;
81 internal static ConcurrentDictionary<string, Bot>? Bots { get; private set; }
82 internal static StringComparer? BotsComparer { get; private set; }
83 internal static EOSType OSType { get; private set; } = EOSType.Unknown;
85 private static readonly SemaphoreSlim BotsSemaphore = new(1, 1);
87 [JsonIgnore]
88 [PublicAPI]
89 public Actions Actions { get; }
91 [JsonIgnore]
92 [PublicAPI]
93 public ArchiHandler ArchiHandler { get; }
95 [JsonIgnore]
96 [PublicAPI]
97 public ArchiLogger ArchiLogger { get; }
99 [JsonIgnore]
100 [PublicAPI]
101 public ArchiWebHandler ArchiWebHandler { get; }
103 [JsonIgnore]
104 [PublicAPI]
105 public BotDatabase BotDatabase { get; }
107 [JsonInclude]
108 [PublicAPI]
109 [Required]
110 public string BotName { get; }
112 [JsonInclude]
113 [PublicAPI]
114 [Required]
115 public CardsFarmer CardsFarmer { get; }
117 [JsonIgnore]
118 [PublicAPI]
119 public Commands Commands { get; }
121 [JsonInclude]
122 [PublicAPI]
123 [Required]
124 public uint GamesToRedeemInBackgroundCount => BotDatabase.GamesToRedeemInBackgroundCount;
126 [JsonInclude]
127 [PublicAPI]
128 [Required]
129 public bool HasMobileAuthenticator => BotDatabase.MobileAuthenticator != null;
131 [JsonIgnore]
132 [PublicAPI]
133 public bool IsAccountLimited => AccountFlags.HasFlag(EAccountFlags.LimitedUser) || AccountFlags.HasFlag(EAccountFlags.LimitedUserForce);
135 [JsonIgnore]
136 [PublicAPI]
137 public bool IsAccountLocked => AccountFlags.HasFlag(EAccountFlags.Lockdown);
139 [JsonInclude]
140 [PublicAPI]
141 [Required]
142 public bool IsConnectedAndLoggedOn => SteamClient.SteamID != null;
144 [JsonInclude]
145 [PublicAPI]
146 [Required]
147 public bool IsPlayingPossible => !PlayingBlocked && !LibraryLocked;
149 [JsonInclude]
150 [PublicAPI]
151 public string? PublicIP => SteamClient.PublicIP?.ToString();
153 [JsonInclude]
154 [JsonPropertyName($"{SharedInfo.UlongCompatibilityStringPrefix}{nameof(SteamID)}")]
155 [PublicAPI]
156 [Required]
157 public string SSteamID => SteamID.ToString(CultureInfo.InvariantCulture);
159 [JsonIgnore]
160 [PublicAPI]
161 public SteamApps SteamApps { get; }
163 [JsonIgnore]
164 [PublicAPI]
165 public SteamConfiguration SteamConfiguration { get; }
167 [JsonIgnore]
168 [PublicAPI]
169 public SteamFriends SteamFriends { get; }
171 internal bool CanReceiveSteamCards => !IsAccountLimited && !IsAccountLocked;
172 internal bool HasLoginCodeReady => !string.IsNullOrEmpty(TwoFactorCode) || !string.IsNullOrEmpty(AuthCode);
174 private readonly CallbackManager CallbackManager;
175 private readonly SemaphoreSlim CallbackSemaphore = new(1, 1);
176 private readonly SemaphoreSlim GamesRedeemerInBackgroundSemaphore = new(1, 1);
177 private readonly Timer HeartBeatTimer;
178 private readonly SemaphoreSlim InitializationSemaphore = new(1, 1);
179 private readonly SemaphoreSlim MessagingSemaphore = new(1, 1);
180 private readonly ConcurrentDictionary<UserNotificationsCallback.EUserNotification, uint> PastNotifications = new();
181 private readonly SemaphoreSlim RefreshWebSessionSemaphore = new(1, 1);
182 private readonly SemaphoreSlim SendCompleteTypesSemaphore = new(1, 1);
183 private readonly SteamClient SteamClient;
184 private readonly ConcurrentHashSet<ulong> SteamFamilySharingIDs = [];
185 private readonly SteamUser SteamUser;
186 private readonly Trading Trading;
188 private IEnumerable<(string FilePath, EFileType FileType)> RelatedFiles {
189 get {
190 foreach (EFileType fileType in Enum.GetValues(typeof(EFileType))) {
191 string filePath = GetFilePath(fileType);
193 if (string.IsNullOrEmpty(filePath)) {
194 ArchiLogger.LogNullError(filePath);
196 yield break;
199 yield return (filePath, fileType);
204 [JsonIgnore]
205 [PublicAPI]
206 public string? AccessToken {
207 get => BackingAccessToken;
209 private set {
210 AccessTokenValidUntil = null;
212 if (string.IsNullOrEmpty(value)) {
213 BackingAccessToken = null;
215 return;
218 if (!Utilities.TryReadJsonWebToken(value, out JsonWebToken? accessToken)) {
219 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(accessToken)));
221 return;
224 BackingAccessToken = value;
226 if (accessToken.ValidTo > DateTime.MinValue) {
227 AccessTokenValidUntil = accessToken.ValidTo;
232 [JsonInclude]
233 [JsonRequired]
234 [PublicAPI]
235 [Required]
236 public EAccountFlags AccountFlags { get; private set; }
238 [JsonInclude]
239 [PublicAPI]
240 public string? AvatarHash { get; private set; }
242 [JsonInclude]
243 [JsonRequired]
244 [PublicAPI]
245 [Required]
246 public BotConfig BotConfig { get; private set; }
248 [JsonInclude]
249 [JsonRequired]
250 [PublicAPI]
251 [Required]
252 public bool KeepRunning { get; private set; }
254 [JsonInclude]
255 [PublicAPI]
256 public string? Nickname { get; private set; }
258 [JsonIgnore]
259 [PublicAPI]
260 public FrozenDictionary<uint, (EPaymentMethod PaymentMethod, DateTime TimeCreated)> OwnedPackageIDs { get; private set; } = FrozenDictionary<uint, (EPaymentMethod PaymentMethod, DateTime TimeCreated)>.Empty;
262 [JsonInclude]
263 [JsonRequired]
264 [PublicAPI]
265 [Required]
266 public ASF.EUserInputType RequiredInput { get; private set; }
268 [JsonInclude]
269 [JsonRequired]
270 [PublicAPI]
271 [Required]
272 public ulong SteamID { get; private set; }
274 [JsonInclude]
275 [JsonRequired]
276 [PublicAPI]
277 [Required]
278 public long WalletBalance { get; private set; }
280 [JsonInclude]
281 [JsonRequired]
282 [PublicAPI]
283 [Required]
284 public long WalletBalanceDelayed { get; private set; }
286 [JsonInclude]
287 [JsonRequired]
288 [PublicAPI]
289 [Required]
290 public ECurrencyCode WalletCurrency { get; private set; }
292 internal byte HeartBeatFailures { get; private set; }
293 internal bool PlayingBlocked { get; private set; }
294 internal bool PlayingWasBlocked { get; private set; }
296 private DateTime? AccessTokenValidUntil;
297 private string? AuthCode;
299 private string? BackingAccessToken;
300 private Timer? ConnectionFailureTimer;
301 private bool FirstTradeSent;
302 private Timer? GamesRedeemerInBackgroundTimer;
303 private string? IPCountryCode;
304 private EResult LastLogOnResult;
305 private DateTime LastLogonSessionReplaced;
306 private bool LibraryLocked;
307 private byte LoginFailures;
308 private ulong MasterChatGroupID;
309 private Timer? PlayingWasBlockedTimer;
310 private bool ReconnectOnUserInitiated;
311 private string? RefreshToken;
312 private Timer? RefreshTokensTimer;
313 private bool SendCompleteTypesScheduled;
314 private Timer? SendItemsTimer;
315 private bool SteamParentalActive;
316 private SteamSaleEvent? SteamSaleEvent;
317 private Timer? TradeCheckTimer;
318 private string? TwoFactorCode;
320 private Bot(string botName, BotConfig botConfig, BotDatabase botDatabase) {
321 ArgumentException.ThrowIfNullOrEmpty(botName);
322 ArgumentNullException.ThrowIfNull(botConfig);
323 ArgumentNullException.ThrowIfNull(botDatabase);
325 if (ASF.GlobalDatabase == null) {
326 throw new InvalidOperationException(nameof(ASF.GlobalDatabase));
329 BotName = botName;
330 BotConfig = botConfig;
331 BotDatabase = botDatabase;
333 ArchiLogger = new ArchiLogger(botName);
335 BotDatabase.MobileAuthenticator?.Init(this);
337 ArchiWebHandler = new ArchiWebHandler(this);
339 SteamConfiguration = SteamConfiguration.Create(
340 builder => {
341 builder.WithCellID(ASF.GlobalDatabase.CellID);
342 builder.WithHttpClientFactory(ArchiWebHandler.GenerateDisposableHttpClient);
343 builder.WithProtocolTypes(ASF.GlobalConfig?.SteamProtocols ?? GlobalConfig.DefaultSteamProtocols);
344 builder.WithServerListProvider(ASF.GlobalDatabase.ServerListProvider);
346 IMachineInfoProvider? customMachineInfoProvider = PluginsCore.GetCustomMachineInfoProvider(this).Result;
348 if (customMachineInfoProvider != null) {
349 builder.WithMachineInfoProvider(customMachineInfoProvider);
354 // Initialize
355 SteamClient = new SteamClient(SteamConfiguration, botName);
357 if (Debugging.IsDebugConfigured && Directory.Exists(ASF.DebugDirectory)) {
358 string debugListenerPath = Path.Combine(ASF.DebugDirectory, botName);
360 try {
361 Directory.CreateDirectory(debugListenerPath);
363 SteamClient.DebugNetworkListener = new NetHookNetworkListener(debugListenerPath, SteamClient);
364 } catch (Exception e) {
365 ArchiLogger.LogGenericException(e);
369 ArchiHandler = new ArchiHandler(ArchiLogger, SteamClient.GetHandler<SteamUnifiedMessages>() ?? throw new InvalidOperationException(nameof(SteamUnifiedMessages)));
370 SteamClient.AddHandler(ArchiHandler);
372 CallbackManager = new CallbackManager(SteamClient);
373 CallbackManager.Subscribe<SteamClient.ConnectedCallback>(OnConnected);
374 CallbackManager.Subscribe<SteamClient.DisconnectedCallback>(OnDisconnected);
376 SteamApps = SteamClient.GetHandler<SteamApps>() ?? throw new InvalidOperationException(nameof(SteamApps));
377 CallbackManager.Subscribe<SteamApps.GuestPassListCallback>(OnGuestPassList);
378 CallbackManager.Subscribe<SteamApps.LicenseListCallback>(OnLicenseList);
380 SteamFriends = SteamClient.GetHandler<SteamFriends>() ?? throw new InvalidOperationException(nameof(SteamFriends));
381 CallbackManager.Subscribe<SteamFriends.FriendsListCallback>(OnFriendsList);
382 CallbackManager.Subscribe<SteamFriends.PersonaStateCallback>(OnPersonaState);
384 CallbackManager.Subscribe<SteamUnifiedMessages.ServiceMethodNotification>(OnServiceMethod);
386 SteamUser = SteamClient.GetHandler<SteamUser>() ?? throw new InvalidOperationException(nameof(SteamUser));
387 CallbackManager.Subscribe<SteamUser.LoggedOffCallback>(OnLoggedOff);
388 CallbackManager.Subscribe<SteamUser.LoggedOnCallback>(OnLoggedOn);
389 CallbackManager.Subscribe<SteamUser.PlayingSessionStateCallback>(OnPlayingSessionState);
390 CallbackManager.Subscribe<SteamUser.VanityURLChangedCallback>(OnVanityURLChangedCallback);
391 CallbackManager.Subscribe<SteamUser.WalletInfoCallback>(OnWalletInfo);
393 CallbackManager.Subscribe<SharedLibraryLockStatusCallback>(OnSharedLibraryLockStatus);
394 CallbackManager.Subscribe<UserNotificationsCallback>(OnUserNotifications);
396 Actions = new Actions(this);
397 CardsFarmer = new CardsFarmer(this);
398 Commands = new Commands(this);
399 Trading = new Trading(this);
401 HeartBeatTimer = new Timer(
402 HeartBeat,
403 null,
404 TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(ASF.LoadBalancingDelay * Bots?.Count ?? 0), // Delay
405 TimeSpan.FromMinutes(1) // Period
409 public void Dispose() {
410 // Those are objects that are always being created if constructor doesn't throw exception
411 ArchiWebHandler.Dispose();
412 BotDatabase.Dispose();
413 CallbackSemaphore.Dispose();
414 GamesRedeemerInBackgroundSemaphore.Dispose();
415 InitializationSemaphore.Dispose();
416 MessagingSemaphore.Dispose();
417 RefreshWebSessionSemaphore.Dispose();
418 SendCompleteTypesSemaphore.Dispose();
419 Trading.Dispose();
421 Actions.Dispose();
422 CardsFarmer.Dispose();
423 HeartBeatTimer.Dispose();
425 // Those are objects that might be null and the check should be in-place
426 ConnectionFailureTimer?.Dispose();
427 GamesRedeemerInBackgroundTimer?.Dispose();
428 PlayingWasBlockedTimer?.Dispose();
429 RefreshTokensTimer?.Dispose();
430 SendItemsTimer?.Dispose();
431 SteamSaleEvent?.Dispose();
432 TradeCheckTimer?.Dispose();
435 public async ValueTask DisposeAsync() {
436 // Those are objects that are always being created if constructor doesn't throw exception
437 ArchiWebHandler.Dispose();
438 BotDatabase.Dispose();
439 CallbackSemaphore.Dispose();
440 GamesRedeemerInBackgroundSemaphore.Dispose();
441 InitializationSemaphore.Dispose();
442 MessagingSemaphore.Dispose();
443 RefreshWebSessionSemaphore.Dispose();
444 SendCompleteTypesSemaphore.Dispose();
445 Trading.Dispose();
447 await Actions.DisposeAsync().ConfigureAwait(false);
448 await CardsFarmer.DisposeAsync().ConfigureAwait(false);
449 await HeartBeatTimer.DisposeAsync().ConfigureAwait(false);
451 // Those are objects that might be null and the check should be in-place
452 if (ConnectionFailureTimer != null) {
453 await ConnectionFailureTimer.DisposeAsync().ConfigureAwait(false);
456 if (GamesRedeemerInBackgroundTimer != null) {
457 await GamesRedeemerInBackgroundTimer.DisposeAsync().ConfigureAwait(false);
460 if (PlayingWasBlockedTimer != null) {
461 await PlayingWasBlockedTimer.DisposeAsync().ConfigureAwait(false);
464 if (RefreshTokensTimer != null) {
465 await RefreshTokensTimer.DisposeAsync().ConfigureAwait(false);
468 if (SendItemsTimer != null) {
469 await SendItemsTimer.DisposeAsync().ConfigureAwait(false);
472 if (SteamSaleEvent != null) {
473 await SteamSaleEvent.DisposeAsync().ConfigureAwait(false);
476 if (TradeCheckTimer != null) {
477 await TradeCheckTimer.DisposeAsync().ConfigureAwait(false);
481 [PublicAPI]
482 public async Task<bool> DeleteAllRelatedFiles() {
483 await BotDatabase.MakeReadOnly().ConfigureAwait(false);
485 foreach (string filePath in RelatedFiles.Select(static file => file.FilePath).Where(File.Exists)) {
486 try {
487 File.Delete(filePath);
488 } catch (Exception e) {
489 ArchiLogger.LogGenericException(e);
491 return false;
495 return true;
498 [PublicAPI]
499 public EAccess GetAccess(ulong steamID) {
500 if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) {
501 throw new ArgumentOutOfRangeException(nameof(steamID));
504 if (ASF.IsOwner(steamID)) {
505 return EAccess.Owner;
508 EAccess familySharingAccess = SteamFamilySharingIDs.Contains(steamID) ? EAccess.FamilySharing : EAccess.None;
510 if (!BotConfig.SteamUserPermissions.TryGetValue(steamID, out BotConfig.EAccess permission)) {
511 return familySharingAccess;
514 switch (permission) {
515 case BotConfig.EAccess.None:
516 return EAccess.None;
517 case BotConfig.EAccess.FamilySharing:
518 return EAccess.FamilySharing;
519 case BotConfig.EAccess.Operator:
520 return EAccess.Operator;
521 case BotConfig.EAccess.Master:
522 return EAccess.Master;
523 default:
524 ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(permission), permission));
526 return familySharingAccess;
530 [PublicAPI]
531 public static Bot? GetBot(string botName) {
532 ArgumentException.ThrowIfNullOrEmpty(botName);
534 if (Bots == null) {
535 throw new InvalidOperationException(nameof(Bots));
538 if (Bots.TryGetValue(botName, out Bot? targetBot)) {
539 return targetBot;
542 if (!ulong.TryParse(botName, out ulong steamID) || (steamID == 0) || !new SteamID(steamID).IsIndividualAccount) {
543 return null;
546 return Bots.Values.FirstOrDefault(bot => bot.SteamID == steamID);
549 [PublicAPI]
550 public static HashSet<Bot>? GetBots(string args) {
551 ArgumentException.ThrowIfNullOrEmpty(args);
553 if (Bots == null) {
554 throw new InvalidOperationException(nameof(Bots));
557 if (BotsComparer == null) {
558 throw new InvalidOperationException(nameof(BotsComparer));
561 string[] botNames = args.Split(SharedInfo.ListElementSeparators, StringSplitOptions.RemoveEmptyEntries);
563 HashSet<Bot> result = [];
565 foreach (string botName in botNames) {
566 if (botName.Equals(SharedInfo.ASF, StringComparison.OrdinalIgnoreCase)) {
567 IEnumerable<Bot> allBots = Bots.OrderBy(static bot => bot.Key, BotsComparer).Select(static bot => bot.Value);
568 result.UnionWith(allBots);
570 return result;
573 if ((botName.Length > 2) && SharedInfo.RangeIndicators.Any(rangeIndicator => botName.Contains(rangeIndicator, StringComparison.Ordinal))) {
574 string[] botRange = botName.Split(SharedInfo.RangeIndicators, StringSplitOptions.RemoveEmptyEntries);
576 Bot? firstBot = GetBot(botRange[0]);
578 if (firstBot != null) {
579 switch (botRange.Length) {
580 case 1:
581 // Either bot.. or ..bot
582 IEnumerable<Bot> query = Bots.OrderBy(static bot => bot.Key, BotsComparer).Select(static bot => bot.Value);
584 query = botName.StartsWith("..", StringComparison.Ordinal) ? query.TakeWhile(bot => bot != firstBot) : query.SkipWhile(bot => bot != firstBot);
586 foreach (Bot bot in query) {
587 result.Add(bot);
590 result.Add(firstBot);
592 continue;
593 case 2:
594 // firstBot..lastBot
595 Bot? lastBot = GetBot(botRange[1]);
597 if ((lastBot != null) && (BotsComparer.Compare(firstBot.BotName, lastBot.BotName) <= 0)) {
598 foreach (Bot bot in Bots.OrderBy(static bot => bot.Key, BotsComparer).Select(static bot => bot.Value).SkipWhile(bot => bot != firstBot).TakeWhile(bot => bot != lastBot)) {
599 result.Add(bot);
602 result.Add(lastBot);
604 continue;
607 break;
612 if (botName.StartsWith("r!", StringComparison.OrdinalIgnoreCase)) {
613 string botsPattern = botName[2..];
615 RegexOptions botsRegex = RegexOptions.None;
617 if ((BotsComparer == StringComparer.InvariantCulture) || (BotsComparer == StringComparer.Ordinal)) {
618 botsRegex |= RegexOptions.CultureInvariant;
619 } else if ((BotsComparer == StringComparer.InvariantCultureIgnoreCase) || (BotsComparer == StringComparer.OrdinalIgnoreCase)) {
620 botsRegex |= RegexOptions.CultureInvariant | RegexOptions.IgnoreCase;
623 Regex regex;
625 try {
626 #pragma warning disable CA3012 // We're aware of a potential denial of service here, this is why we limit maximum matching time to a sane value
627 regex = new Regex(botsPattern, botsRegex, TimeSpan.FromSeconds(1));
628 #pragma warning restore CA3012 // We're aware of a potential denial of service here, this is why we limit maximum matching time to a sane value
629 } catch (ArgumentException e) {
630 ASF.ArchiLogger.LogGenericWarningException(e);
632 return null;
635 try {
636 IEnumerable<Bot> regexMatches = Bots.Where(kvp => regex.IsMatch(kvp.Key)).Select(static kvp => kvp.Value);
638 result.UnionWith(regexMatches);
639 } catch (RegexMatchTimeoutException e) {
640 ASF.ArchiLogger.LogGenericException(e);
643 continue;
646 Bot? singleBot = GetBot(botName);
648 if (singleBot == null) {
649 continue;
652 result.Add(singleBot);
655 return result;
658 [PublicAPI]
659 public static string GetFilePath(string botName, EFileType fileType) {
660 ArgumentException.ThrowIfNullOrEmpty(botName);
662 if (!Enum.IsDefined(fileType)) {
663 throw new InvalidEnumArgumentException(nameof(fileType), (int) fileType, typeof(EFileType));
666 string botPath = Path.Combine(SharedInfo.ConfigDirectory, botName);
668 return fileType switch {
669 EFileType.Config => $"{botPath}{SharedInfo.JsonConfigExtension}",
670 EFileType.Database => $"{botPath}{SharedInfo.DatabaseExtension}",
671 EFileType.KeysToRedeem => $"{botPath}{SharedInfo.KeysExtension}",
672 EFileType.KeysToRedeemUnused => $"{botPath}{SharedInfo.KeysExtension}{SharedInfo.KeysUnusedExtension}",
673 EFileType.KeysToRedeemUsed => $"{botPath}{SharedInfo.KeysExtension}{SharedInfo.KeysUsedExtension}",
674 EFileType.MobileAuthenticator => $"{botPath}{SharedInfo.MobileAuthenticatorExtension}",
675 _ => throw new InvalidOperationException(nameof(fileType))
679 [PublicAPI]
680 public string GetFilePath(EFileType fileType) {
681 if (!Enum.IsDefined(fileType)) {
682 throw new InvalidEnumArgumentException(nameof(fileType), (int) fileType, typeof(EFileType));
685 return GetFilePath(BotName, fileType);
688 [PublicAPI]
689 public T? GetHandler<T>() where T : ClientMsgHandler => SteamClient.GetHandler<T>();
691 [PublicAPI]
692 public static HashSet<Asset> GetItemsForFullSets(IReadOnlyCollection<Asset> inventory, IReadOnlyDictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), (uint SetsToExtract, byte ItemsPerSet)> amountsToExtract, ushort maxItems = Trading.MaxItemsPerTrade) {
693 if ((inventory == null) || (inventory.Count == 0)) {
694 throw new ArgumentNullException(nameof(inventory));
697 if ((amountsToExtract == null) || (amountsToExtract.Count == 0)) {
698 throw new ArgumentNullException(nameof(amountsToExtract));
701 ArgumentOutOfRangeException.ThrowIfLessThan(maxItems, MinCardsPerBadge);
703 HashSet<Asset> result = [];
704 Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary<ulong, HashSet<Asset>>> itemsPerClassIDPerSet = inventory.GroupBy(static item => (item.RealAppID, item.Type, item.Rarity)).ToDictionary(static grouping => grouping.Key, static grouping => grouping.GroupBy(static item => item.ClassID).ToDictionary(static group => group.Key, static group => group.ToHashSet()));
706 foreach (((uint RealAppID, EAssetType Type, EAssetRarity Rarity) set, (uint setsToExtract, byte itemsPerSet)) in amountsToExtract.OrderBy(static kv => kv.Value.ItemsPerSet)) {
707 if (!itemsPerClassIDPerSet.TryGetValue(set, out Dictionary<ulong, HashSet<Asset>>? itemsPerClassID)) {
708 continue;
711 if (itemsPerSet < itemsPerClassID.Count) {
712 throw new InvalidOperationException($"{nameof(itemsPerSet)} < {nameof(itemsPerClassID)}");
715 if (itemsPerSet > itemsPerClassID.Count) {
716 continue;
719 ushort maxSetsAllowed = (ushort) ((maxItems - result.Count) / itemsPerSet);
720 ushort realSetsToExtract = (ushort) Math.Min(setsToExtract, maxSetsAllowed);
722 if (realSetsToExtract == 0) {
723 break;
726 foreach (HashSet<Asset> itemsOfClass in itemsPerClassID.Values) {
727 ushort classRemaining = realSetsToExtract;
729 foreach (Asset item in itemsOfClass.TakeWhile(_ => classRemaining > 0)) {
730 if (classRemaining >= item.Amount) {
731 result.Add(item);
733 classRemaining -= (ushort) item.Amount;
734 } else {
735 Asset itemToSend = item.DeepClone();
736 itemToSend.Amount = classRemaining;
737 result.Add(itemToSend);
739 classRemaining = 0;
745 return result;
748 [PublicAPI]
749 public async Task<HashSet<uint>?> GetPossiblyCompletedBadgeAppIDs() {
750 using IDocument? badgePage = await ArchiWebHandler.GetBadgePage(1).ConfigureAwait(false);
752 if (badgePage == null) {
753 ArchiLogger.LogGenericWarning(Strings.WarningCouldNotCheckBadges);
755 return null;
758 byte maxPages = 1;
759 INode? htmlNode = badgePage.SelectSingleNode("(//a[@class='pagelink'])[last()]");
761 if (htmlNode != null) {
762 string lastPage = htmlNode.TextContent;
764 if (string.IsNullOrEmpty(lastPage)) {
765 ArchiLogger.LogNullError(lastPage);
767 return null;
770 if (!byte.TryParse(lastPage, out maxPages) || (maxPages == 0)) {
771 ArchiLogger.LogNullError(maxPages);
773 return null;
777 HashSet<uint>? firstPageResult = GetPossiblyCompletedBadgeAppIDs(badgePage);
779 if (firstPageResult == null) {
780 return null;
783 if (maxPages == 1) {
784 return firstPageResult;
787 switch (ASF.GlobalConfig?.OptimizationMode) {
788 case GlobalConfig.EOptimizationMode.MinMemoryUsage:
789 for (byte page = 2; page <= maxPages; page++) {
790 HashSet<uint>? pageIDs = await GetPossiblyCompletedBadgeAppIDs(page).ConfigureAwait(false);
792 if (pageIDs == null) {
793 return null;
796 firstPageResult.UnionWith(pageIDs);
799 return firstPageResult;
800 default:
801 HashSet<Task<HashSet<uint>?>> tasks = new(maxPages - 1);
803 for (byte page = 2; page <= maxPages; page++) {
804 // ReSharper disable once InlineTemporaryVariable - we need a copy of variable being passed when in for loops, as loop will proceed before our task is launched
805 byte currentPage = page;
806 tasks.Add(GetPossiblyCompletedBadgeAppIDs(currentPage));
809 IList<HashSet<uint>?> results = await Utilities.InParallel(tasks).ConfigureAwait(false);
811 foreach (HashSet<uint>? result in results) {
812 if (result == null) {
813 return null;
816 firstPageResult.UnionWith(result);
819 return firstPageResult;
823 [PublicAPI]
824 public async Task<byte?> GetTradeHoldDuration(ulong steamID, ulong tradeID) {
825 if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) {
826 throw new ArgumentOutOfRangeException(nameof(steamID));
829 ArgumentOutOfRangeException.ThrowIfZero(tradeID);
831 if (Bots == null) {
832 throw new InvalidOperationException(nameof(Bots));
835 if (SteamFriends.GetFriendRelationship(steamID) == EFriendRelationship.Friend) {
836 byte? tradeHoldDuration = await ArchiWebHandler.GetCombinedTradeHoldDurationAgainstUser(steamID).ConfigureAwait(false);
838 if (tradeHoldDuration.HasValue) {
839 return tradeHoldDuration;
843 Bot? targetBot = Bots.Values.FirstOrDefault(bot => bot.SteamID == steamID);
845 if (targetBot?.IsConnectedAndLoggedOn == true) {
846 string? targetTradeToken = await targetBot.ArchiHandler.GetTradeToken().ConfigureAwait(false);
848 if (!string.IsNullOrEmpty(targetTradeToken)) {
849 byte? tradeHoldDuration = await ArchiWebHandler.GetCombinedTradeHoldDurationAgainstUser(steamID, targetTradeToken).ConfigureAwait(false);
851 if (tradeHoldDuration.HasValue) {
852 return tradeHoldDuration;
857 return await ArchiWebHandler.GetTradeHoldDurationForTrade(tradeID).ConfigureAwait(false);
860 [PublicAPI]
861 public async Task<Dictionary<uint, byte>?> LoadCardsPerSet(IReadOnlyCollection<uint> appIDs) {
862 if ((appIDs == null) || (appIDs.Count == 0)) {
863 throw new ArgumentNullException(nameof(appIDs));
866 IReadOnlySet<uint> uniqueAppIDs = appIDs as IReadOnlySet<uint> ?? appIDs.ToHashSet();
868 switch (ASF.GlobalConfig?.OptimizationMode) {
869 case GlobalConfig.EOptimizationMode.MinMemoryUsage:
870 Dictionary<uint, byte> result = new(uniqueAppIDs.Count);
872 foreach (uint appID in uniqueAppIDs) {
873 byte cardCount = await ArchiWebHandler.GetCardCountForGame(appID).ConfigureAwait(false);
875 if (cardCount == 0) {
876 return null;
879 result.Add(appID, cardCount);
882 return result;
883 default:
884 IEnumerable<Task<(uint AppID, byte Cards)>> tasks = uniqueAppIDs.Select(async appID => (AppID: appID, Cards: await ArchiWebHandler.GetCardCountForGame(appID).ConfigureAwait(false)));
885 IList<(uint AppID, byte Cards)> results = await Utilities.InParallel(tasks).ConfigureAwait(false);
887 return results.All(static tuple => tuple.Cards > 0) ? results.ToDictionary(static res => res.AppID, static res => res.Cards) : null;
891 [PublicAPI]
892 public async Task<bool> SendMessage(ulong steamID, string message) {
893 if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) {
894 throw new ArgumentOutOfRangeException(nameof(steamID));
897 ArgumentException.ThrowIfNullOrEmpty(message);
899 if (!IsConnectedAndLoggedOn) {
900 return false;
903 ArchiLogger.LogChatMessage(true, message, steamID: steamID);
905 string? steamMessagePrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.SteamMessagePrefix : GlobalConfig.DefaultSteamMessagePrefix;
907 await foreach (string messagePart in SteamChatMessage.GetMessageParts(message, steamMessagePrefix, IsAccountLimited).ConfigureAwait(false)) {
908 if (!await SendMessagePart(steamID, messagePart).ConfigureAwait(false)) {
909 ArchiLogger.LogGenericWarning(Strings.WarningFailed);
911 return false;
915 return true;
918 [PublicAPI]
919 public async Task<bool> SendMessage(ulong chatGroupID, ulong chatID, string message) {
920 ArgumentOutOfRangeException.ThrowIfZero(chatGroupID);
921 ArgumentOutOfRangeException.ThrowIfZero(chatID);
922 ArgumentException.ThrowIfNullOrEmpty(message);
924 if (!IsConnectedAndLoggedOn) {
925 return false;
928 ArchiLogger.LogChatMessage(true, message, chatGroupID, chatID);
930 string? steamMessagePrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.SteamMessagePrefix : GlobalConfig.DefaultSteamMessagePrefix;
932 await foreach (string messagePart in SteamChatMessage.GetMessageParts(message, steamMessagePrefix, IsAccountLimited).ConfigureAwait(false)) {
933 if (!await SendMessagePart(chatID, messagePart, chatGroupID).ConfigureAwait(false)) {
934 ArchiLogger.LogGenericWarning(Strings.WarningFailed);
936 return false;
940 return true;
943 [PublicAPI]
944 public bool SetUserInput(ASF.EUserInputType inputType, string inputValue) {
945 if ((inputType == ASF.EUserInputType.None) || !Enum.IsDefined(inputType)) {
946 throw new InvalidEnumArgumentException(nameof(inputType), (int) inputType, typeof(ASF.EUserInputType));
949 ArgumentException.ThrowIfNullOrEmpty(inputValue);
951 // This switch should cover ONLY bot properties
952 switch (inputType) {
953 case ASF.EUserInputType.DeviceConfirmation:
954 // Nothing to do for us
955 break;
956 case ASF.EUserInputType.Login:
957 BotConfig.SteamLogin = inputValue;
959 // Do not allow saving this account credential
960 BotConfig.IsSteamLoginSet = false;
962 break;
963 case ASF.EUserInputType.Password:
964 BotConfig.SteamPassword = inputValue;
966 // Do not allow saving this account credential
967 BotConfig.IsSteamPasswordSet = false;
969 // If by any chance user has wrongly configured password format, we reset it back to plaintext
970 BotConfig.PasswordFormat = ArchiCryptoHelper.ECryptoMethod.PlainText;
972 break;
973 case ASF.EUserInputType.SteamGuard:
974 if (inputValue.Length != 5) {
975 return false;
978 AuthCode = inputValue;
980 break;
981 case ASF.EUserInputType.SteamParentalCode:
982 if ((inputValue.Length != BotConfig.SteamParentalCodeLength) || inputValue.Any(static character => character is < '0' or > '9')) {
983 return false;
986 BotConfig.SteamParentalCode = inputValue;
988 // Do not allow saving this account credential
989 BotConfig.IsSteamParentalCodeSet = false;
991 break;
992 case ASF.EUserInputType.TwoFactorAuthentication:
993 switch (inputValue.Length) {
994 case MobileAuthenticator.BackupCodeDigits:
995 case MobileAuthenticator.CodeDigits:
996 break;
997 default:
998 return false;
1001 inputValue = inputValue.ToUpperInvariant();
1003 if (inputValue.Any(static character => !MobileAuthenticator.CodeCharacters.Contains(character))) {
1004 return false;
1007 TwoFactorCode = inputValue;
1009 break;
1010 default:
1011 throw new InvalidOperationException(nameof(inputType));
1014 if (RequiredInput == inputType) {
1015 RequiredInput = ASF.EUserInputType.None;
1018 return true;
1021 internal void AddGamesToRedeemInBackground(IOrderedDictionary gamesToRedeemInBackground) {
1022 if ((gamesToRedeemInBackground == null) || (gamesToRedeemInBackground.Count == 0)) {
1023 throw new ArgumentNullException(nameof(gamesToRedeemInBackground));
1026 BotDatabase.AddGamesToRedeemInBackground(gamesToRedeemInBackground);
1028 if ((GamesRedeemerInBackgroundTimer == null) && BotDatabase.HasGamesToRedeemInBackground && IsConnectedAndLoggedOn) {
1029 Utilities.InBackground(() => RedeemGamesInBackground());
1033 internal async Task CheckOccupationStatus() {
1034 StopPlayingWasBlockedTimer();
1036 if (!IsPlayingPossible) {
1037 PlayingWasBlocked = true;
1038 ArchiLogger.LogGenericInfo(Strings.BotAccountOccupied);
1040 return;
1043 if (PlayingWasBlocked && (PlayingWasBlockedTimer == null)) {
1044 InitPlayingWasBlockedTimer();
1047 ArchiLogger.LogGenericInfo(Strings.BotAccountFree);
1049 if (!await CardsFarmer.Resume(false).ConfigureAwait(false)) {
1050 await ResetGamesPlayed().ConfigureAwait(false);
1054 internal bool DeleteRedeemedKeysFiles() {
1055 string unusedKeysFilePath = GetFilePath(EFileType.KeysToRedeemUnused);
1057 if (string.IsNullOrEmpty(unusedKeysFilePath)) {
1058 ASF.ArchiLogger.LogNullError(unusedKeysFilePath);
1060 return false;
1063 if (File.Exists(unusedKeysFilePath)) {
1064 try {
1065 File.Delete(unusedKeysFilePath);
1066 } catch (Exception e) {
1067 ArchiLogger.LogGenericException(e);
1069 return false;
1073 string usedKeysFilePath = GetFilePath(EFileType.KeysToRedeemUsed);
1075 if (string.IsNullOrEmpty(usedKeysFilePath)) {
1076 ASF.ArchiLogger.LogNullError(usedKeysFilePath);
1078 return false;
1081 if (File.Exists(usedKeysFilePath)) {
1082 try {
1083 File.Delete(usedKeysFilePath);
1084 } catch (Exception e) {
1085 ArchiLogger.LogGenericException(e);
1087 return false;
1091 return true;
1094 internal static string FormatBotResponse(string response, string botName) {
1095 ArgumentException.ThrowIfNullOrEmpty(response);
1096 ArgumentException.ThrowIfNullOrEmpty(botName);
1098 return $"{Environment.NewLine}<{botName}> {response}";
1101 internal async Task<(uint PlayableAppID, DateTime IgnoredUntil, bool IgnoredGlobally)> GetAppDataForIdling(uint appID, float hoursPlayed, bool allowRecursiveDiscovery = true, bool optimisticDiscovery = true) {
1102 ArgumentOutOfRangeException.ThrowIfZero(appID);
1103 ArgumentOutOfRangeException.ThrowIfNegative(hoursPlayed);
1105 HashSet<uint>? packageIDs = ASF.GlobalDatabase?.GetPackageIDs(appID, OwnedPackageIDs.Keys);
1107 if ((packageIDs == null) || (packageIDs.Count == 0)) {
1108 return (0, DateTime.MaxValue, true);
1111 if ((hoursPlayed < CardsFarmer.HoursForRefund) && BotConfig.FarmingPreferences.HasFlag(BotConfig.EFarmingPreferences.SkipRefundableGames)) {
1112 DateTime mostRecent = DateTime.MinValue;
1114 foreach (uint packageID in packageIDs) {
1115 if (!OwnedPackageIDs.TryGetValue(packageID, out (EPaymentMethod PaymentMethod, DateTime TimeCreated) packageData)) {
1116 continue;
1119 if ((packageData.PaymentMethod > EPaymentMethod.None) && IsRefundable(packageData.PaymentMethod) && (packageData.TimeCreated > mostRecent)) {
1120 mostRecent = packageData.TimeCreated;
1124 if (mostRecent > DateTime.MinValue) {
1125 DateTime playableIn = mostRecent.AddDays(CardsFarmer.DaysForRefund);
1127 if (playableIn > DateTime.UtcNow) {
1128 return (0, playableIn, false);
1133 // Check region restrictions
1134 if (!string.IsNullOrEmpty(IPCountryCode)) {
1135 DateTime? regionRestrictedUntil = null;
1137 DateTime safePlayableBefore = DateTime.UtcNow.AddMonths(-RegionRestrictionPlayableBlockMonths);
1139 foreach (uint packageID in packageIDs) {
1140 if (!OwnedPackageIDs.TryGetValue(packageID, out (EPaymentMethod PaymentMethod, DateTime TimeCreated) ownedPackageData)) {
1141 // We don't own that packageID, keep checking
1142 continue;
1145 if (ownedPackageData.TimeCreated < safePlayableBefore) {
1146 // Our package is older than required, this is playable
1147 regionRestrictedUntil = null;
1149 break;
1152 // We've got a package that was activated recently, we should check if we have any playable restrictions on it
1153 if ((ASF.GlobalDatabase == null) || !ASF.GlobalDatabase.PackagesDataReadOnly.TryGetValue(packageID, out PackageData? packageData)) {
1154 // No information about that package, try again later
1155 return (0, DateTime.MaxValue, true);
1158 if ((packageData.ProhibitRunInCountries == null) || packageData.ProhibitRunInCountries.IsEmpty) {
1159 // No restrictions, we're good to go
1160 regionRestrictedUntil = null;
1162 break;
1165 if (packageData.ProhibitRunInCountries.Contains(IPCountryCode)) {
1166 // We are restricted by this package, we can only be saved by another package that is not restricted
1167 DateTime regionRestrictedUntilPackage = ownedPackageData.TimeCreated.AddMonths(RegionRestrictionPlayableBlockMonths);
1169 if (!regionRestrictedUntil.HasValue || (regionRestrictedUntilPackage < regionRestrictedUntil.Value)) {
1170 regionRestrictedUntil = regionRestrictedUntilPackage;
1175 if (regionRestrictedUntil.HasValue) {
1176 // We can't play this game for now
1177 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningRegionRestrictedPackage, appID, IPCountryCode, regionRestrictedUntil.Value));
1179 return (0, regionRestrictedUntil.Value, false);
1183 SteamApps.PICSTokensCallback? tokenCallback = null;
1185 for (byte i = 0; (i < WebBrowser.MaxTries) && (tokenCallback == null) && IsConnectedAndLoggedOn; i++) {
1186 try {
1187 tokenCallback = await SteamApps.PICSGetAccessTokens(appID, null).ToLongRunningTask().ConfigureAwait(false);
1188 } catch (Exception e) {
1189 ArchiLogger.LogGenericWarningException(e);
1193 if (tokenCallback == null) {
1194 return (optimisticDiscovery ? appID : 0, DateTime.MinValue, true);
1197 SteamApps.PICSRequest request = new(appID, tokenCallback.AppTokens.GetValueOrDefault(appID));
1199 AsyncJobMultiple<SteamApps.PICSProductInfoCallback>.ResultSet? productInfoResultSet = null;
1201 for (byte i = 0; (i < WebBrowser.MaxTries) && (productInfoResultSet == null) && IsConnectedAndLoggedOn; i++) {
1202 try {
1203 productInfoResultSet = await SteamApps.PICSGetProductInfo(request.ToEnumerable(), []).ToLongRunningTask().ConfigureAwait(false);
1204 } catch (Exception e) {
1205 ArchiLogger.LogGenericWarningException(e);
1209 if (productInfoResultSet?.Results == null) {
1210 return (optimisticDiscovery ? appID : 0, DateTime.MinValue, true);
1213 foreach (Dictionary<uint, SteamApps.PICSProductInfoCallback.PICSProductInfo> productInfoApps in productInfoResultSet.Results.Select(static result => result.Apps)) {
1214 if (!productInfoApps.TryGetValue(appID, out SteamApps.PICSProductInfoCallback.PICSProductInfo? productInfoApp)) {
1215 continue;
1218 KeyValue productInfo = productInfoApp.KeyValues;
1220 if (productInfo == KeyValue.Invalid) {
1221 ArchiLogger.LogNullError(productInfo);
1223 break;
1226 KeyValue commonProductInfo = productInfo["common"];
1228 if (commonProductInfo == KeyValue.Invalid) {
1229 continue;
1232 string? releaseState = commonProductInfo["ReleaseState"].AsString();
1234 if (!string.IsNullOrEmpty(releaseState)) {
1235 // We must convert this to uppercase, since Valve doesn't stick to any convention and we can have a case mismatch
1236 switch (releaseState.ToUpperInvariant()) {
1237 case "RELEASED":
1238 break;
1239 case "PRELOADONLY" or "PRERELEASE":
1240 return (0, DateTime.MaxValue, true);
1241 default:
1242 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(releaseState), releaseState));
1244 break;
1248 string? type = commonProductInfo["type"].AsString();
1250 if (string.IsNullOrEmpty(type)) {
1251 return (appID, DateTime.MinValue, true);
1254 // We must convert this to uppercase, since Valve doesn't stick to any convention and we can have a case mismatch
1255 switch (type.ToUpperInvariant()) {
1256 case "APPLICATION" or "EPISODE" or "GAME" or "MOD" or "MOVIE" or "SERIES" or "TOOL" or "VIDEO":
1257 // Types that can be idled
1258 return (appID, DateTime.MinValue, true);
1259 case "ADVERTISING" or "DEMO" or "DLC" or "GUIDE" or "HARDWARE" or "MUSIC":
1260 // Types that can't be idled
1261 break;
1262 default:
1263 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(type), type));
1265 break;
1268 if (!allowRecursiveDiscovery) {
1269 return (0, DateTime.MinValue, true);
1272 string? listOfDlc = productInfo["extended"]["listofdlc"].AsString();
1274 if (string.IsNullOrEmpty(listOfDlc)) {
1275 return (appID, DateTime.MinValue, true);
1278 string[] dlcAppIDsTexts = listOfDlc.Split(SharedInfo.ListElementSeparators, StringSplitOptions.RemoveEmptyEntries);
1280 foreach (string dlcAppIDsText in dlcAppIDsTexts) {
1281 if (!uint.TryParse(dlcAppIDsText, out uint dlcAppID) || (dlcAppID == 0)) {
1282 ArchiLogger.LogNullError(dlcAppID);
1284 break;
1287 (uint playableAppID, _, _) = await GetAppDataForIdling(dlcAppID, hoursPlayed, false, false).ConfigureAwait(false);
1289 if (playableAppID != 0) {
1290 return (playableAppID, DateTime.MinValue, true);
1294 return (appID, DateTime.MinValue, true);
1297 return (productInfoResultSet is { Complete: true, Failed: false } || optimisticDiscovery ? appID : 0, DateTime.MinValue, true);
1300 internal static Bot? GetDefaultBot() {
1301 if ((Bots == null) || Bots.IsEmpty) {
1302 return null;
1305 if (!string.IsNullOrEmpty(ASF.GlobalConfig?.DefaultBot) && Bots.TryGetValue(ASF.GlobalConfig.DefaultBot, out Bot? targetBot)) {
1306 return targetBot;
1309 return Bots.OrderBy(static bot => bot.Key, BotsComparer).Select(static bot => bot.Value).FirstOrDefault();
1312 internal Task<HashSet<uint>?> GetMarketableAppIDs() => ArchiWebHandler.GetAppList();
1314 internal async Task<Dictionary<uint, PackageData>?> GetPackagesData(IReadOnlyCollection<uint> packageIDs) {
1315 if ((packageIDs == null) || (packageIDs.Count == 0)) {
1316 throw new ArgumentNullException(nameof(packageIDs));
1319 if (ASF.GlobalDatabase == null) {
1320 throw new InvalidOperationException(nameof(ASF.GlobalDatabase));
1323 HashSet<SteamApps.PICSRequest> packageRequests = [];
1325 foreach (uint packageID in packageIDs) {
1326 if (!ASF.GlobalDatabase.PackageAccessTokensReadOnly.TryGetValue(packageID, out ulong packageAccessToken)) {
1327 continue;
1330 packageRequests.Add(new SteamApps.PICSRequest(packageID, packageAccessToken));
1333 if (packageRequests.Count == 0) {
1334 return new Dictionary<uint, PackageData>(0);
1337 AsyncJobMultiple<SteamApps.PICSProductInfoCallback>.ResultSet? productInfoResultSet = null;
1339 for (byte i = 0; (i < WebBrowser.MaxTries) && (productInfoResultSet == null) && IsConnectedAndLoggedOn; i++) {
1340 try {
1341 productInfoResultSet = await SteamApps.PICSGetProductInfo([], packageRequests).ToLongRunningTask().ConfigureAwait(false);
1342 } catch (Exception e) {
1343 ArchiLogger.LogGenericWarningException(e);
1347 if (productInfoResultSet?.Results == null) {
1348 return null;
1351 DateTime validUntil = DateTime.UtcNow.AddDays(7);
1353 Dictionary<uint, PackageData> result = new();
1355 foreach (SteamApps.PICSProductInfoCallback.PICSProductInfo productInfo in productInfoResultSet.Results.SelectMany(static productInfoResult => productInfoResult.Packages).Where(static productInfoPackages => productInfoPackages.Key != 0).Select(static productInfoPackages => productInfoPackages.Value)) {
1356 if (productInfo.KeyValues == KeyValue.Invalid) {
1357 ArchiLogger.LogNullError(productInfo);
1359 continue;
1362 uint changeNumber = productInfo.ChangeNumber;
1364 HashSet<uint>? appIDs = null;
1366 KeyValue appIDsKv = productInfo.KeyValues["appids"];
1368 if (appIDsKv != KeyValue.Invalid) {
1369 appIDs = new HashSet<uint>(appIDsKv.Children.Count);
1371 foreach (string? appIDText in appIDsKv.Children.Select(static app => app.Value)) {
1372 if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) {
1373 ArchiLogger.LogNullError(appID);
1375 continue;
1378 appIDs.Add(appID);
1382 string[]? prohibitRunInCountries = null;
1384 string? prohibitRunInCountriesText = productInfo.KeyValues["extended"]["prohibitrunincountries"].AsString();
1386 if (!string.IsNullOrEmpty(prohibitRunInCountriesText)) {
1387 prohibitRunInCountries = prohibitRunInCountriesText.Split(' ', StringSplitOptions.RemoveEmptyEntries);
1390 result[productInfo.ID] = new PackageData(changeNumber, validUntil, appIDs?.ToImmutableHashSet(), prohibitRunInCountries?.ToImmutableHashSet(StringComparer.Ordinal));
1393 return result;
1396 internal async Task<(Dictionary<string, string>? UnusedKeys, Dictionary<string, string>? UsedKeys)> GetUsedAndUnusedKeys() {
1397 string[] files = [GetFilePath(EFileType.KeysToRedeemUnused), GetFilePath(EFileType.KeysToRedeemUsed)];
1399 IList<Dictionary<string, string>?> results = await Utilities.InParallel(files.Select(GetKeysFromFile)).ConfigureAwait(false);
1401 return (results[0], results[1]);
1404 internal async Task<bool?> HasPublicInventory() {
1405 if (!IsConnectedAndLoggedOn) {
1406 return null;
1409 CPrivacySettings? privacySettings = await ArchiHandler.GetPrivacySettings().ConfigureAwait(false);
1411 if (privacySettings == null) {
1412 ArchiLogger.LogGenericWarning(Strings.WarningFailed);
1414 return null;
1417 return ((ArchiHandler.EPrivacySetting) privacySettings.privacy_state == ArchiHandler.EPrivacySetting.Public) && ((ArchiHandler.EPrivacySetting) privacySettings.privacy_state_inventory == ArchiHandler.EPrivacySetting.Public);
1420 internal async Task IdleGame(Game game) {
1421 ArgumentNullException.ThrowIfNull(game);
1423 string? gameName = null;
1425 if (!string.IsNullOrEmpty(BotConfig.CustomGamePlayedWhileFarming)) {
1426 gameName = string.Format(CultureInfo.CurrentCulture, BotConfig.CustomGamePlayedWhileFarming, game.AppID, game.GameName);
1429 await ArchiHandler.PlayGames(new HashSet<uint>(1) { game.PlayableAppID }, gameName).ConfigureAwait(false);
1432 internal async Task IdleGames(IReadOnlyCollection<Game> games) {
1433 if ((games == null) || (games.Count == 0)) {
1434 throw new ArgumentNullException(nameof(games));
1437 string? gameName = null;
1439 if (!string.IsNullOrEmpty(BotConfig.CustomGamePlayedWhileFarming)) {
1440 gameName = string.Format(CultureInfo.CurrentCulture, BotConfig.CustomGamePlayedWhileFarming, string.Join(", ", games.Select(static game => game.AppID)), string.Join(", ", games.Select(static game => game.GameName)));
1443 await ArchiHandler.PlayGames(games.Select(static game => game.PlayableAppID).ToHashSet(), gameName).ConfigureAwait(false);
1446 internal async Task ImportKeysToRedeem(string filePath) {
1447 ArgumentException.ThrowIfNullOrEmpty(filePath);
1449 if (!File.Exists(filePath)) {
1450 throw new FileNotFoundException(nameof(filePath), filePath);
1453 try {
1454 OrderedDictionary gamesToRedeemInBackground = new();
1456 using (StreamReader reader = new(filePath)) {
1457 while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line) {
1458 if (line.Length == 0) {
1459 continue;
1462 // Valid formats:
1463 // Key (name will be the same as key and replaced from redemption result, if possible)
1464 // Name + Key (user provides both, if name is equal to key, above logic is used, otherwise name is kept)
1465 // Name + <Ignored> + Key (BGR output format, we include extra properties in the middle, those are ignored during import)
1466 string[] parsedArgs = line.Split(DefaultBackgroundKeysRedeemerSeparator, StringSplitOptions.RemoveEmptyEntries);
1468 if (parsedArgs.Length < 1) {
1469 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, line));
1471 continue;
1474 string name = parsedArgs[0];
1475 string key = parsedArgs[^1];
1477 gamesToRedeemInBackground[key] = name;
1481 if (gamesToRedeemInBackground.Count > 0) {
1482 IOrderedDictionary validGamesToRedeemInBackground = ValidateGamesToRedeemInBackground(gamesToRedeemInBackground);
1484 if (validGamesToRedeemInBackground.Count > 0) {
1485 AddGamesToRedeemInBackground(validGamesToRedeemInBackground);
1489 File.Delete(filePath);
1490 } catch (Exception e) {
1491 ArchiLogger.LogGenericException(e);
1495 internal static void Init(StringComparer botsComparer) {
1496 ArgumentNullException.ThrowIfNull(botsComparer);
1498 if (Bots != null) {
1499 throw new InvalidOperationException(nameof(Bots));
1502 BotsComparer = botsComparer;
1503 Bots = new ConcurrentDictionary<string, Bot>(botsComparer);
1506 internal bool IsBlacklistedFromIdling(uint appID) {
1507 ArgumentOutOfRangeException.ThrowIfZero(appID);
1509 return BotDatabase.FarmingBlacklistAppIDs.Contains(appID);
1512 internal bool IsBlacklistedFromTrades(ulong steamID) {
1513 if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) {
1514 throw new ArgumentOutOfRangeException(nameof(steamID));
1517 return BotDatabase.TradingBlacklistSteamIDs.Contains(steamID);
1520 internal bool IsPriorityIdling(uint appID) {
1521 ArgumentOutOfRangeException.ThrowIfZero(appID);
1523 return BotDatabase.FarmingPriorityQueueAppIDs.Contains(appID);
1526 internal async Task OnConfigChanged(bool deleted) {
1527 if (deleted) {
1528 await Destroy().ConfigureAwait(false);
1530 return;
1533 string configFile = GetFilePath(EFileType.Config);
1535 if (string.IsNullOrEmpty(configFile)) {
1536 throw new InvalidOperationException(nameof(configFile));
1539 (BotConfig? botConfig, _) = await BotConfig.Load(configFile).ConfigureAwait(false);
1541 if (botConfig == null) {
1542 // Invalid config file, we allow user to fix it without destroying the bot right away
1543 return;
1546 if (botConfig == BotConfig) {
1547 return;
1550 await InitializationSemaphore.WaitAsync().ConfigureAwait(false);
1552 try {
1553 if (botConfig == BotConfig) {
1554 return;
1557 // Skip shutdown event as we're actually reinitializing the bot, not fully stopping it
1558 Stop(true);
1560 BotConfig = botConfig;
1562 await InitModules().ConfigureAwait(false);
1563 InitStart();
1564 } finally {
1565 InitializationSemaphore.Release();
1569 internal async Task OnFarmingFinished(bool farmedSomething) {
1570 await OnFarmingStopped().ConfigureAwait(false);
1572 if (BotConfig.FarmingPreferences.HasFlag(BotConfig.EFarmingPreferences.SendOnFarmingFinished) && (BotConfig.LootableTypes.Count > 0) && (farmedSomething || !FirstTradeSent)) {
1573 FirstTradeSent = true;
1575 await Actions.SendInventory(filterFunction: item => BotConfig.LootableTypes.Contains(item.Type)).ConfigureAwait(false);
1578 if (BotConfig.FarmingPreferences.HasFlag(BotConfig.EFarmingPreferences.ShutdownOnFarmingFinished)) {
1579 Stop();
1582 await PluginsCore.OnBotFarmingFinished(this, farmedSomething).ConfigureAwait(false);
1585 internal async Task OnFarmingStopped() {
1586 await ResetGamesPlayed().ConfigureAwait(false);
1587 await PluginsCore.OnBotFarmingStopped(this).ConfigureAwait(false);
1590 internal async Task<bool> RefreshWebSession(bool force = false) {
1591 if (!IsConnectedAndLoggedOn) {
1592 return false;
1595 await RefreshWebSessionSemaphore.WaitAsync().ConfigureAwait(false);
1597 try {
1598 if (!IsConnectedAndLoggedOn) {
1599 return false;
1602 DateTime minimumValidUntil = DateTime.UtcNow.AddMinutes(MinimumAccessTokenValidityMinutes);
1604 if (!force && !string.IsNullOrEmpty(AccessToken) && (!AccessTokenValidUntil.HasValue || (AccessTokenValidUntil.Value >= minimumValidUntil))) {
1605 // We can use the tokens we already have
1606 if (await ArchiWebHandler.Init(SteamID, SteamClient.Universe, AccessToken, SteamParentalActive ? BotConfig.SteamParentalCode : null).ConfigureAwait(false)) {
1607 InitRefreshTokensTimer(AccessTokenValidUntil ?? minimumValidUntil);
1609 return true;
1613 // We need to refresh our session, access token is no longer valid
1614 BotDatabase.AccessToken = AccessToken = null;
1616 if (string.IsNullOrEmpty(RefreshToken)) {
1617 // Without refresh token we can't get fresh access tokens, relog needed
1618 await Connect(true).ConfigureAwait(false);
1620 return false;
1623 AccessTokenGenerateResult response;
1625 try {
1626 response = await SteamClient.Authentication.GenerateAccessTokenForAppAsync(SteamID, RefreshToken, true).ConfigureAwait(false);
1627 } catch (Exception e) {
1628 // The request has failed, in almost all cases this means our refresh token is no longer valid, relog needed
1629 ArchiLogger.LogGenericWarningException(e);
1631 BotDatabase.RefreshToken = RefreshToken = null;
1633 await Connect(true).ConfigureAwait(false);
1635 return false;
1638 if (string.IsNullOrEmpty(response.AccessToken)) {
1639 // The request has failed, in almost all cases this means our refresh token is no longer valid, relog needed
1640 BotDatabase.RefreshToken = RefreshToken = null;
1642 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(SteamClient.Authentication.GenerateAccessTokenForAppAsync)));
1644 await Connect(true).ConfigureAwait(false);
1646 return false;
1649 UpdateTokens(response.AccessToken, response.RefreshToken);
1651 if (await ArchiWebHandler.Init(SteamID, SteamClient.Universe, response.AccessToken, SteamParentalActive ? BotConfig.SteamParentalCode : null).ConfigureAwait(false)) {
1652 InitRefreshTokensTimer(AccessTokenValidUntil ?? minimumValidUntil);
1654 return true;
1657 // We got the tokens, but failed to authorize? Purge them just to be sure and reconnect
1658 BotDatabase.AccessToken = AccessToken = null;
1660 await Connect(true).ConfigureAwait(false);
1662 return false;
1663 } finally {
1664 RefreshWebSessionSemaphore.Release();
1668 internal static async Task RegisterBot(string botName) {
1669 ArgumentException.ThrowIfNullOrEmpty(botName);
1671 if (Bots == null) {
1672 throw new InvalidOperationException(nameof(Bots));
1675 if (Bots.ContainsKey(botName)) {
1676 return;
1679 string configFilePath = GetFilePath(botName, EFileType.Config);
1681 if (string.IsNullOrEmpty(configFilePath)) {
1682 ASF.ArchiLogger.LogNullError(configFilePath);
1684 return;
1687 (BotConfig? botConfig, string? latestJson) = await BotConfig.Load(configFilePath).ConfigureAwait(false);
1689 if (botConfig == null) {
1690 ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorBotConfigInvalid, configFilePath));
1692 return;
1695 if (Debugging.IsDebugConfigured) {
1696 ASF.ArchiLogger.LogGenericDebug($"{configFilePath}: {botConfig.ToJsonText(true)}");
1699 if (!string.IsNullOrEmpty(latestJson)) {
1700 ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.AutomaticFileMigration, configFilePath));
1702 await SerializableFile.Write(configFilePath, latestJson).ConfigureAwait(false);
1704 ASF.ArchiLogger.LogGenericInfo(Strings.Done);
1707 string databaseFilePath = GetFilePath(botName, EFileType.Database);
1709 if (string.IsNullOrEmpty(databaseFilePath)) {
1710 ASF.ArchiLogger.LogNullError(databaseFilePath);
1712 return;
1715 BotDatabase? botDatabase = await BotDatabase.CreateOrLoad(databaseFilePath).ConfigureAwait(false);
1717 if (botDatabase == null) {
1718 ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorDatabaseInvalid, databaseFilePath));
1720 return;
1723 if (Debugging.IsDebugConfigured) {
1724 ASF.ArchiLogger.LogGenericDebug($"{databaseFilePath}: {botDatabase.ToJsonText(true)}");
1727 botDatabase.PerformMaintenance();
1729 Bot bot;
1731 await BotsSemaphore.WaitAsync().ConfigureAwait(false);
1733 try {
1734 if (Bots.ContainsKey(botName)) {
1735 return;
1738 bot = new Bot(botName, botConfig, botDatabase);
1740 if (!Bots.TryAdd(botName, bot)) {
1741 ASF.ArchiLogger.LogNullError(bot);
1743 await bot.DisposeAsync().ConfigureAwait(false);
1745 return;
1747 } finally {
1748 BotsSemaphore.Release();
1751 await PluginsCore.OnBotInit(bot).ConfigureAwait(false);
1753 HashSet<ClientMsgHandler>? customHandlers = await PluginsCore.OnBotSteamHandlersInit(bot).ConfigureAwait(false);
1755 if (customHandlers?.Count > 0) {
1756 foreach (ClientMsgHandler customHandler in customHandlers) {
1757 bot.SteamClient.AddHandler(customHandler);
1761 await PluginsCore.OnBotSteamCallbacksInit(bot, bot.CallbackManager).ConfigureAwait(false);
1763 await bot.InitModules().ConfigureAwait(false);
1765 bot.InitStart();
1768 internal (bool Success, string? Message) RemoveAuthenticator() {
1769 MobileAuthenticator? authenticator = BotDatabase.MobileAuthenticator;
1771 if (authenticator == null) {
1772 return (false, Strings.BotNoASFAuthenticator);
1775 BotDatabase.MobileAuthenticator = null;
1776 authenticator.Dispose();
1778 return (true, null);
1781 internal async Task<bool> Rename(string newBotName) {
1782 ArgumentException.ThrowIfNullOrEmpty(newBotName);
1784 if (Bots == null) {
1785 throw new InvalidOperationException(nameof(Bots));
1788 if (!ASF.IsValidBotName(newBotName) || Bots.ContainsKey(newBotName)) {
1789 return false;
1792 if (KeepRunning) {
1793 Stop(true);
1796 await BotDatabase.MakeReadOnly().ConfigureAwait(false);
1798 // We handle the config file last as it'll trigger new bot creation
1799 foreach ((string filePath, EFileType fileType) in RelatedFiles.Where(static file => File.Exists(file.FilePath)).OrderByDescending(static file => file.FileType != EFileType.Config)) {
1800 string newFilePath = GetFilePath(newBotName, fileType);
1802 if (string.IsNullOrEmpty(newFilePath)) {
1803 ArchiLogger.LogNullError(newFilePath);
1805 return false;
1808 try {
1809 File.Move(filePath, newFilePath);
1810 } catch (Exception e) {
1811 ArchiLogger.LogGenericException(e);
1813 return false;
1817 return true;
1820 internal async Task<string?> RequestInput(ASF.EUserInputType inputType, bool previousCodeWasIncorrect) {
1821 if ((inputType == ASF.EUserInputType.None) || !Enum.IsDefined(inputType)) {
1822 throw new InvalidEnumArgumentException(nameof(inputType), (int) inputType, typeof(ASF.EUserInputType));
1825 switch (inputType) {
1826 case ASF.EUserInputType.SteamGuard when !string.IsNullOrEmpty(AuthCode):
1827 string? savedAuthCode = AuthCode;
1829 AuthCode = null;
1831 return savedAuthCode;
1832 case ASF.EUserInputType.TwoFactorAuthentication when !string.IsNullOrEmpty(TwoFactorCode):
1833 string? savedTwoFactorCode = TwoFactorCode;
1835 TwoFactorCode = null;
1837 return savedTwoFactorCode;
1838 case ASF.EUserInputType.TwoFactorAuthentication when BotDatabase.MobileAuthenticator != null:
1839 if (previousCodeWasIncorrect) {
1840 // There is a possibility that our cached time is no longer appropriate, so we should reset the cache in this case in order to fetch it upon the next login attempt
1841 // Yes, this might as well be just invalid 2FA credentials, but we can't be sure about that, and we have LoginFailures designed to verify that for us
1842 await MobileAuthenticator.ResetSteamTimeDifference().ConfigureAwait(false);
1845 string? generatedTwoFactorCode = await BotDatabase.MobileAuthenticator.GenerateToken().ConfigureAwait(false);
1847 if (!string.IsNullOrEmpty(generatedTwoFactorCode)) {
1848 return generatedTwoFactorCode;
1851 break;
1854 RequiredInput = inputType;
1856 string? input = await Logging.GetUserInput(inputType, BotName).ConfigureAwait(false);
1858 if (string.IsNullOrEmpty(input) || !SetUserInput(inputType, input)) {
1859 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(input)));
1861 Stop();
1863 return null;
1866 // We keep user input set in case we need to use it again due to disconnection, OnLoggedOn() will reset it for us
1867 return input;
1870 internal void RequestPersonaStateUpdate() {
1871 if (!IsConnectedAndLoggedOn) {
1872 return;
1875 SteamFriends.RequestFriendInfo(SteamID, EClientPersonaStateFlag.PlayerName | EClientPersonaStateFlag.Presence);
1878 internal void ResetPersonaState() {
1879 if (BotConfig.OnlineStatus == EPersonaState.Offline) {
1880 return;
1883 SteamFriends.SetPersonaState(BotConfig.OnlineStatus);
1885 if (BotConfig.OnlineFlags > 0) {
1886 ArchiHandler.SetPersonaState(BotConfig.OnlineStatus, BotConfig.OnlineFlags);
1890 internal async Task<bool> SendTypingMessage(ulong steamID) {
1891 if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) {
1892 throw new ArgumentOutOfRangeException(nameof(steamID));
1895 if (!IsConnectedAndLoggedOn) {
1896 return false;
1899 return await ArchiHandler.SendTypingStatus(steamID).ConfigureAwait(false) == EResult.OK;
1902 internal async Task Start() {
1903 if (KeepRunning) {
1904 return;
1907 KeepRunning = true;
1908 Utilities.InBackground(HandleCallbacks, true);
1909 ArchiLogger.LogGenericInfo(Strings.Starting);
1911 // Support and convert 2FA files
1912 if (!HasMobileAuthenticator) {
1913 string mobileAuthenticatorFilePath = GetFilePath(EFileType.MobileAuthenticator);
1915 if (string.IsNullOrEmpty(mobileAuthenticatorFilePath)) {
1916 ArchiLogger.LogNullError(mobileAuthenticatorFilePath);
1918 return;
1921 if (File.Exists(mobileAuthenticatorFilePath)) {
1922 await ImportAuthenticatorFromFile(mobileAuthenticatorFilePath).ConfigureAwait(false);
1926 string keysToRedeemFilePath = GetFilePath(EFileType.KeysToRedeem);
1928 if (string.IsNullOrEmpty(keysToRedeemFilePath)) {
1929 ArchiLogger.LogNullError(keysToRedeemFilePath);
1931 return;
1934 if (File.Exists(keysToRedeemFilePath)) {
1935 await ImportKeysToRedeem(keysToRedeemFilePath).ConfigureAwait(false);
1938 await Connect().ConfigureAwait(false);
1941 internal void Stop(bool skipShutdownEvent = false) {
1942 if (!KeepRunning) {
1943 return;
1946 KeepRunning = false;
1947 ArchiLogger.LogGenericInfo(Strings.BotStopping);
1949 if (SteamClient.IsConnected) {
1950 Disconnect();
1953 if (!skipShutdownEvent) {
1954 Utilities.InBackground(Events.OnBotShutdown);
1958 internal bool TryImportAuthenticator(MobileAuthenticator authenticator) {
1959 ArgumentNullException.ThrowIfNull(authenticator);
1961 if (HasMobileAuthenticator) {
1962 return false;
1965 authenticator.Init(this);
1966 BotDatabase.MobileAuthenticator = authenticator;
1968 ArchiLogger.LogGenericInfo(Strings.BotAuthenticatorImportFinished);
1970 return true;
1973 internal static IOrderedDictionary ValidateGamesToRedeemInBackground(IOrderedDictionary gamesToRedeemInBackground) {
1974 if ((gamesToRedeemInBackground == null) || (gamesToRedeemInBackground.Count == 0)) {
1975 throw new ArgumentNullException(nameof(gamesToRedeemInBackground));
1978 HashSet<object> invalidKeys = gamesToRedeemInBackground.Cast<DictionaryEntry>().Where(static game => !BotDatabase.IsValidGameToRedeemInBackground(game)).Select(static game => game.Key).ToHashSet();
1980 foreach (object invalidKey in invalidKeys) {
1981 gamesToRedeemInBackground.Remove(invalidKey);
1984 return gamesToRedeemInBackground;
1987 private async Task Connect(bool force = false) {
1988 if (!force && (!KeepRunning || SteamClient.IsConnected)) {
1989 return;
1992 await LimitLoginRequestsAsync().ConfigureAwait(false);
1994 if (!force && (!KeepRunning || SteamClient.IsConnected)) {
1995 return;
1998 LastLogOnResult = EResult.Invalid;
1999 ReconnectOnUserInitiated = false;
2001 ArchiLogger.LogGenericInfo(Strings.BotConnecting);
2002 InitConnectionFailureTimer();
2003 SteamClient.Connect();
2006 private async Task Destroy(bool force = false) {
2007 if (Bots == null) {
2008 throw new InvalidOperationException(nameof(Bots));
2011 if (KeepRunning) {
2012 if (!force) {
2013 Stop();
2014 } else {
2015 // Stop() will most likely block due to connection freeze, don't wait for it
2016 Utilities.InBackground(() => Stop());
2020 Bots.TryRemove(BotName, out _);
2021 await PluginsCore.OnBotDestroy(this).ConfigureAwait(false);
2024 private void Disconnect() {
2025 StopConnectionFailureTimer();
2027 LastLogOnResult = EResult.OK;
2028 ReconnectOnUserInitiated = false;
2030 SteamClient.Disconnect();
2033 private async Task<Dictionary<string, string>?> GetKeysFromFile(string filePath) {
2034 ArgumentException.ThrowIfNullOrEmpty(filePath);
2036 if (!File.Exists(filePath)) {
2037 return new Dictionary<string, string>(0, StringComparer.Ordinal);
2040 Dictionary<string, string> keys = new(StringComparer.Ordinal);
2042 try {
2043 using StreamReader reader = new(filePath);
2045 while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line) {
2046 if (line.Length == 0) {
2047 continue;
2050 string[] parsedArgs = line.Split(DefaultBackgroundKeysRedeemerSeparator, StringSplitOptions.RemoveEmptyEntries);
2052 if (parsedArgs.Length < 3) {
2053 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, line));
2055 continue;
2058 string key = parsedArgs[^1];
2060 if (!Utilities.IsValidCdKey(key)) {
2061 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, key));
2063 continue;
2066 string name = parsedArgs[0];
2067 keys[key] = name;
2069 } catch (Exception e) {
2070 ArchiLogger.LogGenericException(e);
2072 return null;
2075 return keys;
2078 private async Task<HashSet<uint>?> GetPossiblyCompletedBadgeAppIDs(byte page) {
2079 ArgumentOutOfRangeException.ThrowIfZero(page);
2081 using IDocument? badgePage = await ArchiWebHandler.GetBadgePage(page).ConfigureAwait(false);
2083 if (badgePage == null) {
2084 ArchiLogger.LogGenericWarning(Strings.WarningCouldNotCheckBadges);
2086 return null;
2089 return GetPossiblyCompletedBadgeAppIDs(badgePage);
2092 private HashSet<uint>? GetPossiblyCompletedBadgeAppIDs(IDocument badgePage) {
2093 ArgumentNullException.ThrowIfNull(badgePage);
2095 // We select badges that are ready to craft, as well as those that are already crafted to a maximum level, as those will not display with a craft button
2096 // Level 5 is maximum level for card badges according to https://steamcommunity.com/tradingcards/faq
2097 IEnumerable<IAttr> linkElements = badgePage.SelectNodes<IAttr>("//a[@class='badge_craft_button']/@href | //div[@class='badges_sheet']/div[contains(@class, 'badge_row') and .//div[@class='badge_info_description']/div[contains(text(), 'Level 5')]]/a[@class='badge_row_overlay']/@href");
2099 HashSet<uint> result = [];
2101 foreach (string badgeUri in linkElements.Select(static htmlNode => htmlNode.Value)) {
2102 if (string.IsNullOrEmpty(badgeUri)) {
2103 ArchiLogger.LogNullError(badgeUri);
2105 return null;
2108 // URIs to foil badges are the same as for normal badges except they end with "?border=1"
2109 string appIDText = badgeUri.Split('?', StringSplitOptions.RemoveEmptyEntries)[0].Split('/', StringSplitOptions.RemoveEmptyEntries)[^1];
2111 if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) {
2112 ArchiLogger.LogNullError(appID);
2114 return null;
2117 result.Add(appID);
2120 return result;
2123 private async Task HandleCallbacks() {
2124 if (!await CallbackSemaphore.WaitAsync(CallbackSleep).ConfigureAwait(false)) {
2125 if (Debugging.IsUserDebugging) {
2126 ArchiLogger.LogGenericDebug(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(CallbackSemaphore)));
2129 return;
2132 try {
2133 TimeSpan timeSpan = TimeSpan.FromMilliseconds(CallbackSleep);
2135 while (KeepRunning || SteamClient.IsConnected) {
2136 CallbackManager.RunWaitAllCallbacks(timeSpan);
2138 } catch (Exception e) {
2139 ArchiLogger.LogGenericException(e);
2140 } finally {
2141 CallbackSemaphore.Release();
2145 private async Task HandleLoginResult(EResult result, EResult extendedResult) {
2146 if (!Enum.IsDefined(result)) {
2147 throw new InvalidEnumArgumentException(nameof(result), (int) result, typeof(EResult));
2150 if (!Enum.IsDefined(extendedResult)) {
2151 throw new InvalidEnumArgumentException(nameof(extendedResult), (int) extendedResult, typeof(EResult));
2154 // Keep LastLogOnResult for OnDisconnected()
2155 LastLogOnResult = result > EResult.OK ? result : EResult.Invalid;
2157 HeartBeatFailures = 0;
2158 StopConnectionFailureTimer();
2160 switch (result) {
2161 case EResult.AccountDisabled:
2162 // Those failures are permanent, we should Stop() the bot if any of those happen
2163 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.BotUnableToLogin, result, extendedResult));
2165 Stop();
2167 break;
2168 case EResult.AccessDenied when string.IsNullOrEmpty(RefreshToken) && (++LoginFailures >= MaxLoginFailures):
2169 case EResult.InvalidPassword when string.IsNullOrEmpty(RefreshToken) && (++LoginFailures >= MaxLoginFailures):
2170 // Likely permanently wrong account credentials
2171 LoginFailures = 0;
2173 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.BotInvalidPasswordDuringLogin, MaxLoginFailures));
2175 Stop();
2177 break;
2178 case EResult.AccountLoginDeniedNeedTwoFactor when HasMobileAuthenticator && (++LoginFailures >= MaxLoginFailures):
2179 case EResult.TwoFactorCodeMismatch when HasMobileAuthenticator && (++LoginFailures >= MaxLoginFailures):
2180 // Likely permanently wrong 2FA credentials that provide automatic TwoFactorAuthentication input
2181 LoginFailures = 0;
2183 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.BotInvalidAuthenticatorDuringLogin, MaxLoginFailures));
2185 Stop();
2187 break;
2188 case EResult.AccountLoginDeniedNeedTwoFactor when HasMobileAuthenticator:
2189 case EResult.TwoFactorCodeMismatch when HasMobileAuthenticator:
2190 // Automatic TwoFactorAuthentication input provided
2191 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.BotUnableToLogin, result, extendedResult));
2193 // There is a possibility that our cached time is no longer appropriate, so we should reset the cache in this case in order to fetch it upon the next login attempt
2194 // Yes, this might as well be just invalid 2FA credentials, but we can't be sure about that, and we have LoginFailures designed to verify that for us
2195 await MobileAuthenticator.ResetSteamTimeDifference().ConfigureAwait(false);
2197 break;
2198 case EResult.AccountLogonDenied:
2199 case EResult.InvalidLoginAuthCode:
2200 // SteamGuard input required
2201 RequiredInput = ASF.EUserInputType.SteamGuard;
2203 string? authCode = await Logging.GetUserInput(ASF.EUserInputType.SteamGuard, BotName).ConfigureAwait(false);
2205 if (string.IsNullOrEmpty(authCode) || !SetUserInput(ASF.EUserInputType.SteamGuard, authCode)) {
2206 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(authCode)));
2208 Stop();
2211 break;
2212 case EResult.AccountLoginDeniedNeedTwoFactor:
2213 case EResult.TwoFactorCodeMismatch:
2214 // TwoFactorAuthentication input required
2215 RequiredInput = ASF.EUserInputType.TwoFactorAuthentication;
2217 string? twoFactorCode = await Logging.GetUserInput(ASF.EUserInputType.TwoFactorAuthentication, BotName).ConfigureAwait(false);
2219 if (string.IsNullOrEmpty(twoFactorCode) || !SetUserInput(ASF.EUserInputType.TwoFactorAuthentication, twoFactorCode)) {
2220 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(twoFactorCode)));
2222 Stop();
2225 break;
2226 case EResult.AccessDenied: // Usually means refresh token is no longer authorized to use, otherwise just try again
2227 case EResult.AccountLoginDeniedThrottle: // Rate-limiting
2228 case EResult.AlreadyLoggedInElsewhere: // No clue, we might need to handle it differenty but it's so rare it's unknown for now why it happens
2229 case EResult.Busy: // No clue, might be some internal gateway timeout, just try again
2230 case EResult.DuplicateRequest: // This will happen if user reacts to popup and tries to use the code afterwards, we have the code saved in ASF, we just need to try again
2231 case EResult.Expired: // Usually means refresh token is no longer authorized to use, otherwise just try again
2232 case EResult.FileNotFound: // User denied approval despite telling us that they accepted it, just try again
2233 case EResult.InvalidPassword: // Usually means refresh token is no longer authorized to use, otherwise just try again
2234 case EResult.NoConnection: // Usually network issues
2235 case EResult.PasswordRequiredToKickSession: // Not sure about this one, it seems to be just generic "try again"? #694
2236 case EResult.RateLimitExceeded: // Rate-limiting
2237 case EResult.ServiceUnavailable: // Usually Steam maintenance
2238 case EResult.Timeout: // Usually network issues
2239 case EResult.TryAnotherCM: // Usually Steam maintenance
2240 // Generic retry pattern against common/expected problems
2241 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.BotUnableToLogin, result, extendedResult));
2243 break;
2244 case EResult.OK:
2245 // Login succeeded
2246 break;
2247 default:
2248 // Unexpected result, shutdown immediately
2249 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(result), result));
2250 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.BotUnableToLogin, result, extendedResult));
2251 Stop();
2253 break;
2257 private async void HeartBeat(object? state = null) {
2258 if (!KeepRunning || !IsConnectedAndLoggedOn || (HeartBeatFailures == byte.MaxValue)) {
2259 return;
2262 byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout;
2264 try {
2265 if (DateTime.UtcNow.Subtract(ArchiHandler.LastPacketReceived).TotalSeconds > connectionTimeout) {
2266 await SteamFriends.RequestProfileInfo(SteamID).ToLongRunningTask().ConfigureAwait(false);
2269 HeartBeatFailures = 0;
2270 } catch (Exception e) {
2271 ArchiLogger.LogGenericDebuggingException(e);
2273 if (!KeepRunning || !IsConnectedAndLoggedOn || (HeartBeatFailures == byte.MaxValue)) {
2274 return;
2277 if (++HeartBeatFailures >= (byte) Math.Ceiling(connectionTimeout / 10.0)) {
2278 HeartBeatFailures = byte.MaxValue;
2279 ArchiLogger.LogGenericWarning(Strings.BotConnectionLost);
2280 Utilities.InBackground(() => Connect(true));
2285 private async Task ImportAuthenticatorFromFile(string maFilePath) {
2286 if (HasMobileAuthenticator || !File.Exists(maFilePath)) {
2287 return;
2290 ArchiLogger.LogGenericInfo(Strings.BotAuthenticatorConverting);
2292 try {
2293 string json = await File.ReadAllTextAsync(maFilePath).ConfigureAwait(false);
2295 if (string.IsNullOrEmpty(json)) {
2296 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(json)));
2298 return;
2301 MobileAuthenticator? authenticator = json.ToJsonObject<MobileAuthenticator>();
2303 if (authenticator == null) {
2304 ArchiLogger.LogNullError(authenticator);
2306 return;
2309 if (!TryImportAuthenticator(authenticator)) {
2310 return;
2313 File.Delete(maFilePath);
2314 } catch (Exception e) {
2315 ArchiLogger.LogGenericException(e);
2319 private void InitConnectionFailureTimer() {
2320 if (ConnectionFailureTimer != null) {
2321 return;
2324 byte connectionTimeout = ASF.GlobalConfig?.ConnectionTimeout ?? GlobalConfig.DefaultConnectionTimeout;
2326 ConnectionFailureTimer = new Timer(
2327 InitPermanentConnectionFailure,
2328 null,
2329 TimeSpan.FromMinutes(Math.Ceiling(connectionTimeout / 30.0)), // Delay
2330 Timeout.InfiniteTimeSpan // Period
2334 private async Task InitializeFamilySharing() {
2335 // TODO: Old call should be removed eventually when Steam stops supporting both systems at once
2336 Task<HashSet<ulong>?> oldFamilySharingSteamIDsTask = ArchiWebHandler.GetFamilySharingSteamIDs();
2338 HashSet<ulong>? steamIDs = await ArchiHandler.GetFamilyGroupSteamIDs().ConfigureAwait(false);
2339 HashSet<ulong>? oldSteamIDs = await oldFamilySharingSteamIDsTask.ConfigureAwait(false);
2341 if ((steamIDs == null) && (oldSteamIDs == null)) {
2342 return;
2345 SteamFamilySharingIDs.Clear();
2347 if (steamIDs is { Count: > 0 }) {
2348 SteamFamilySharingIDs.UnionWith(steamIDs);
2351 if (oldSteamIDs is { Count: > 0 }) {
2352 SteamFamilySharingIDs.UnionWith(oldSteamIDs);
2356 private async Task<bool> InitLoginAndPassword(bool requiresPassword) {
2357 if (string.IsNullOrEmpty(BotConfig.SteamLogin)) {
2358 RequiredInput = ASF.EUserInputType.Login;
2360 string? steamLogin = await Logging.GetUserInput(ASF.EUserInputType.Login, BotName).ConfigureAwait(false);
2362 if (string.IsNullOrEmpty(steamLogin) || !SetUserInput(ASF.EUserInputType.Login, steamLogin)) {
2363 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(steamLogin)));
2365 return false;
2369 if (requiresPassword) {
2370 string? decryptedSteamPassword = await BotConfig.GetDecryptedSteamPassword().ConfigureAwait(false);
2372 if (string.IsNullOrEmpty(decryptedSteamPassword)) {
2373 RequiredInput = ASF.EUserInputType.Password;
2375 string? steamPassword = await Logging.GetUserInput(ASF.EUserInputType.Password, BotName).ConfigureAwait(false);
2377 if (string.IsNullOrEmpty(steamPassword) || !SetUserInput(ASF.EUserInputType.Password, steamPassword)) {
2378 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(steamPassword)));
2380 return false;
2385 return true;
2388 private async Task InitModules() {
2389 if (Bots == null) {
2390 throw new InvalidOperationException(nameof(Bots));
2393 AccountFlags = EAccountFlags.NormalUser;
2394 AvatarHash = IPCountryCode = Nickname = null;
2395 MasterChatGroupID = 0;
2396 RequiredInput = ASF.EUserInputType.None;
2397 WalletBalance = 0;
2398 WalletCurrency = ECurrencyCode.Invalid;
2400 string? accessTokenText = BotDatabase.AccessToken;
2401 string? refreshTokenText = BotDatabase.RefreshToken;
2403 if (BotConfig.PasswordFormat.HasTransformation()) {
2404 if (!string.IsNullOrEmpty(accessTokenText)) {
2405 accessTokenText = await ArchiCryptoHelper.Decrypt(BotConfig.PasswordFormat, accessTokenText).ConfigureAwait(false);
2408 if (!string.IsNullOrEmpty(refreshTokenText)) {
2409 refreshTokenText = await ArchiCryptoHelper.Decrypt(BotConfig.PasswordFormat, refreshTokenText).ConfigureAwait(false);
2413 if (!string.IsNullOrEmpty(accessTokenText) && Utilities.TryReadJsonWebToken(accessTokenText, out JsonWebToken? accessToken) && ((accessToken.ValidTo == DateTime.MinValue) || (accessToken.ValidTo >= DateTime.UtcNow))) {
2414 AccessToken = accessTokenText;
2415 } else {
2416 AccessToken = null;
2419 if (!string.IsNullOrEmpty(refreshTokenText) && Utilities.TryReadJsonWebToken(refreshTokenText, out JsonWebToken? refreshToken) && ((refreshToken.ValidTo == DateTime.MinValue) || (refreshToken.ValidTo >= DateTime.UtcNow))) {
2420 RefreshToken = refreshTokenText;
2421 } else {
2422 RefreshToken = null;
2425 CardsFarmer.SetInitialState(BotConfig.FarmingPreferences.HasFlag(BotConfig.EFarmingPreferences.FarmingPausedByDefault));
2427 if (SendItemsTimer != null) {
2428 await SendItemsTimer.DisposeAsync().ConfigureAwait(false);
2430 SendItemsTimer = null;
2433 if (SteamSaleEvent != null) {
2434 await SteamSaleEvent.DisposeAsync().ConfigureAwait(false);
2436 SteamSaleEvent = null;
2439 if (TradeCheckTimer != null) {
2440 await TradeCheckTimer.DisposeAsync().ConfigureAwait(false);
2442 TradeCheckTimer = null;
2445 if (BotConfig is { SendTradePeriod: > 0, LootableTypes.Count: > 0 } && BotConfig.SteamUserPermissions.Values.Any(static permission => permission >= BotConfig.EAccess.Master)) {
2446 SendItemsTimer = new Timer(
2447 OnSendItemsTimer,
2448 null,
2449 TimeSpan.FromHours(BotConfig.SendTradePeriod) + TimeSpan.FromSeconds(ASF.LoadBalancingDelay * Bots.Count), // Delay
2450 TimeSpan.FromHours(BotConfig.SendTradePeriod) // Period
2454 if (BotConfig.FarmingPreferences.HasFlag(BotConfig.EFarmingPreferences.AutoSteamSaleEvent)) {
2455 SteamSaleEvent = new SteamSaleEvent(this);
2458 if (BotConfig.TradeCheckPeriod > 0) {
2459 TradeCheckTimer = new Timer(
2460 OnTradeCheckTimer,
2461 null,
2462 TimeSpan.FromMinutes(BotConfig.TradeCheckPeriod) + TimeSpan.FromSeconds(ASF.LoadBalancingDelay * Bots.Count), // Delay
2463 TimeSpan.FromMinutes(BotConfig.TradeCheckPeriod) // Period
2467 BotDatabase.MobileAuthenticator?.OnInitModules();
2469 await PluginsCore.OnBotInitModules(this, BotConfig.AdditionalProperties).ConfigureAwait(false);
2472 private async void InitPermanentConnectionFailure(object? state = null) {
2473 if (!KeepRunning) {
2474 return;
2477 ArchiLogger.LogGenericWarning(Strings.BotHeartBeatFailed);
2478 await Destroy(true).ConfigureAwait(false);
2479 await RegisterBot(BotName).ConfigureAwait(false);
2482 private void InitPlayingWasBlockedTimer() {
2483 if (PlayingWasBlockedTimer != null) {
2484 return;
2487 byte minFarmingDelayAfterBlock = ASF.GlobalConfig?.MinFarmingDelayAfterBlock ?? GlobalConfig.DefaultMinFarmingDelayAfterBlock;
2489 PlayingWasBlockedTimer = new Timer(
2490 ResetPlayingWasBlockedWithTimer,
2491 null,
2492 TimeSpan.FromSeconds(minFarmingDelayAfterBlock), // Delay
2493 Timeout.InfiniteTimeSpan // Period
2497 private void InitRefreshTokensTimer(DateTime validUntil) {
2498 ArgumentOutOfRangeException.ThrowIfEqual(validUntil, DateTime.MinValue);
2500 if (validUntil == DateTime.MaxValue) {
2501 // OK, tokens do not require refreshing
2502 StopRefreshTokensTimer();
2504 return;
2507 TimeSpan delay = validUntil - DateTime.UtcNow;
2509 // Start refreshing token before it's invalid
2510 if (delay.TotalMinutes > MinimumAccessTokenValidityMinutes) {
2511 delay -= TimeSpan.FromMinutes(MinimumAccessTokenValidityMinutes);
2512 } else {
2513 delay = TimeSpan.Zero;
2516 // Timer can accept only dueTimes up to 2^32 - 2
2517 uint dueTime = (uint) Math.Min(uint.MaxValue - 1, (ulong) delay.TotalMilliseconds);
2519 if (RefreshTokensTimer == null) {
2520 RefreshTokensTimer = new Timer(
2521 OnRefreshTokensTimer,
2522 null,
2523 TimeSpan.FromMilliseconds(dueTime), // Delay
2524 TimeSpan.FromMinutes(1) // Period
2526 } else {
2527 RefreshTokensTimer.Change(TimeSpan.FromMilliseconds(dueTime), TimeSpan.FromMinutes(1));
2531 private void InitStart() {
2532 if (!BotConfig.Enabled) {
2533 ArchiLogger.LogGenericWarning(Strings.BotInstanceNotStartingBecauseDisabled);
2535 return;
2538 // Start
2539 Utilities.InBackground(Start);
2542 private bool IsMasterClanID(ulong steamID) {
2543 if ((steamID == 0) || !new SteamID(steamID).IsClanAccount) {
2544 throw new ArgumentOutOfRangeException(nameof(steamID));
2547 return steamID == BotConfig.SteamMasterClanID;
2550 private static bool IsRefundable(EPaymentMethod paymentMethod) {
2551 if (paymentMethod == EPaymentMethod.None) {
2552 throw new ArgumentOutOfRangeException(nameof(paymentMethod));
2555 #pragma warning disable CA2248 // This is actually a fair warning, EPaymentMethod is not a flags enum on itself, but there is nothing we can do about Steam using it like that here
2556 return paymentMethod switch {
2557 EPaymentMethod.ActivationCode => false,
2558 EPaymentMethod.Complimentary => false,
2559 EPaymentMethod.HardwarePromo => false,
2560 _ => !paymentMethod.HasFlag(EPaymentMethod.Complimentary) // Complimentary can also be a flag
2562 #pragma warning restore CA2248 // This is actually a fair warning, EPaymentMethod is not a flags enum on itself, but there is nothing we can do about Steam using it like that here
2565 private async Task JoinMasterChatGroupID() {
2566 if ((BotConfig.SteamMasterClanID == 0) || IsAccountLimited) {
2567 return;
2570 if (MasterChatGroupID == 0) {
2571 CClanChatRooms_GetClanChatRoomInfo_Response? clanChatRoomInfo = await ArchiHandler.GetClanChatRoomInfo(BotConfig.SteamMasterClanID).ConfigureAwait(false);
2573 if ((clanChatRoomInfo == null) || (clanChatRoomInfo.chat_group_summary.chat_group_id == 0)) {
2574 return;
2577 MasterChatGroupID = clanChatRoomInfo.chat_group_summary.chat_group_id;
2580 HashSet<ulong>? chatGroupIDs = await ArchiHandler.GetMyChatGroupIDs().ConfigureAwait(false);
2582 if (chatGroupIDs?.Contains(MasterChatGroupID) != false) {
2583 return;
2586 if (!await ArchiHandler.JoinChatRoomGroup(MasterChatGroupID).ConfigureAwait(false)) {
2587 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiHandler.JoinChatRoomGroup)));
2591 private static async Task LimitLoginRequestsAsync() {
2592 if (ASF.LoginSemaphore == null) {
2593 ASF.ArchiLogger.LogNullError(ASF.LoginSemaphore);
2595 return;
2598 if (ASF.LoginRateLimitingSemaphore == null) {
2599 ASF.ArchiLogger.LogNullError(ASF.LoginRateLimitingSemaphore);
2601 return;
2604 byte loginLimiterDelay = ASF.GlobalConfig?.LoginLimiterDelay ?? GlobalConfig.DefaultLoginLimiterDelay;
2606 if (loginLimiterDelay == 0) {
2607 await ASF.LoginRateLimitingSemaphore.WaitAsync().ConfigureAwait(false);
2608 ASF.LoginRateLimitingSemaphore.Release();
2610 return;
2613 await ASF.LoginSemaphore.WaitAsync().ConfigureAwait(false);
2615 try {
2616 await ASF.LoginRateLimitingSemaphore.WaitAsync().ConfigureAwait(false);
2617 ASF.LoginRateLimitingSemaphore.Release();
2618 } finally {
2619 Utilities.InBackground(
2620 async () => {
2621 await Task.Delay(loginLimiterDelay * 1000).ConfigureAwait(false);
2622 ASF.LoginSemaphore.Release();
2628 private async void OnConnected(SteamClient.ConnectedCallback callback) {
2629 ArgumentNullException.ThrowIfNull(callback);
2631 HeartBeatFailures = 0;
2632 ReconnectOnUserInitiated = false;
2633 StopConnectionFailureTimer();
2635 ArchiLogger.LogGenericInfo(Strings.BotConnected);
2637 if (!KeepRunning) {
2638 ArchiLogger.LogGenericInfo(Strings.BotDisconnecting);
2639 Disconnect();
2641 return;
2644 if (!await InitLoginAndPassword(string.IsNullOrEmpty(RefreshToken)).ConfigureAwait(false)) {
2645 Stop();
2647 return;
2650 if (string.IsNullOrEmpty(BotConfig.SteamLogin)) {
2651 throw new InvalidOperationException(nameof(BotConfig.SteamLogin));
2654 // Steam login and password fields can contain ASCII characters only, including spaces
2655 string username = GeneratedRegexes.NonAscii().Replace(BotConfig.SteamLogin, "");
2657 if (string.IsNullOrEmpty(username)) {
2658 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(BotConfig.SteamLogin)));
2660 Stop();
2662 return;
2665 string? password = await BotConfig.GetDecryptedSteamPassword().ConfigureAwait(false);
2667 if (!string.IsNullOrEmpty(password)) {
2668 password = GeneratedRegexes.NonAscii().Replace(password, "");
2670 if (string.IsNullOrEmpty(password)) {
2671 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(BotConfig.SteamPassword)));
2673 Stop();
2675 return;
2678 // Steam artificially cuts passwords to first 64 characters
2679 if (password.Length > 64) {
2680 password = password[..64];
2684 if (!SteamClient.IsConnected) {
2685 // Possible if user spent too much time entering password, try again after reconnect
2686 return;
2689 ArchiLogger.LogGenericInfo(Strings.BotLoggingIn);
2691 InitConnectionFailureTimer();
2693 if (string.IsNullOrEmpty(RefreshToken)) {
2694 AuthPollResult pollResult;
2696 try {
2697 using CancellationTokenSource authCancellationTokenSource = new();
2699 CredentialsAuthSession authSession = await SteamClient.Authentication.BeginAuthSessionViaCredentialsAsync(
2700 new AuthSessionDetails {
2701 Authenticator = new BotCredentialsProvider(this, authCancellationTokenSource),
2702 DeviceFriendlyName = SharedInfo.PublicIdentifier,
2703 GuardData = BotConfig.UseLoginKeys ? BotDatabase.SteamGuardData : null,
2704 IsPersistentSession = true,
2705 Password = password,
2706 Username = username
2708 ).ConfigureAwait(false);
2710 pollResult = await authSession.PollingWaitForResultAsync(authCancellationTokenSource.Token).ConfigureAwait(false);
2711 } catch (AuthenticationException e) {
2712 ArchiLogger.LogGenericWarningException(e);
2714 await HandleLoginResult(e.Result, e.Result).ConfigureAwait(false);
2716 ReconnectOnUserInitiated = true;
2717 SteamClient.Disconnect();
2719 return;
2720 } catch (OperationCanceledException) {
2721 // This is okay, we already took care of that and can ignore it here
2722 return;
2725 if (!string.IsNullOrEmpty(pollResult.NewGuardData) && BotConfig.UseLoginKeys) {
2726 BotDatabase.SteamGuardData = pollResult.NewGuardData;
2729 if (string.IsNullOrEmpty(pollResult.AccessToken)) {
2730 // The fuck is this?
2731 ArchiLogger.LogNullError(pollResult.AccessToken);
2733 ReconnectOnUserInitiated = true;
2734 SteamClient.Disconnect();
2736 return;
2739 if (string.IsNullOrEmpty(pollResult.RefreshToken)) {
2740 // The fuck is that?
2741 ArchiLogger.LogNullError(pollResult.RefreshToken);
2743 ReconnectOnUserInitiated = true;
2744 SteamClient.Disconnect();
2746 return;
2749 UpdateTokens(pollResult.AccessToken, pollResult.RefreshToken);
2752 SteamUser.LogOnDetails logOnDetails = new() {
2753 AccessToken = RefreshToken,
2754 CellID = ASF.GlobalDatabase?.CellID,
2755 ClientLanguage = CultureInfo.CurrentCulture.ToSteamClientLanguage(),
2756 LoginID = LoginID,
2757 ShouldRememberPassword = BotConfig.UseLoginKeys,
2758 Username = username
2761 if (OSType == EOSType.Unknown) {
2762 OSType = logOnDetails.ClientOSType;
2765 SteamUser.LogOn(logOnDetails);
2768 private async void OnDisconnected(SteamClient.DisconnectedCallback callback) {
2769 ArgumentNullException.ThrowIfNull(callback);
2771 if (ASF.LoginRateLimitingSemaphore == null) {
2772 throw new InvalidOperationException(nameof(ASF.LoginRateLimitingSemaphore));
2775 HeartBeatFailures = 0;
2776 StopConnectionFailureTimer();
2777 StopPlayingWasBlockedTimer();
2778 StopRefreshTokensTimer();
2780 ArchiLogger.LogGenericInfo(Strings.BotDisconnected);
2782 PastNotifications.Clear();
2784 Actions.OnDisconnected();
2785 ArchiWebHandler.OnDisconnected();
2786 CardsFarmer.OnDisconnected();
2787 Trading.OnDisconnected();
2789 FirstTradeSent = false;
2790 OwnedPackageIDs = FrozenDictionary<uint, (EPaymentMethod PaymentMethod, DateTime TimeCreated)>.Empty;
2792 EResult lastLogOnResult = LastLogOnResult;
2794 for (byte i = 0; (i < WebBrowser.MaxTries) && (lastLogOnResult == EResult.Invalid); i++) {
2795 await Task.Delay(200).ConfigureAwait(false);
2797 lastLogOnResult = LastLogOnResult;
2800 LastLogOnResult = EResult.Invalid;
2802 await PluginsCore.OnBotDisconnected(this, lastLogOnResult).ConfigureAwait(false);
2804 // If we initiated disconnect, do not attempt to reconnect
2805 if (callback.UserInitiated && !ReconnectOnUserInitiated) {
2806 return;
2809 switch (lastLogOnResult) {
2810 case EResult.AccountDisabled:
2811 // Do not attempt to reconnect, those failures are permanent
2812 return;
2813 case EResult.AccessDenied when !string.IsNullOrEmpty(RefreshToken):
2814 case EResult.Expired when !string.IsNullOrEmpty(RefreshToken):
2815 case EResult.InvalidPassword when !string.IsNullOrEmpty(RefreshToken):
2816 // We can retry immediately
2817 BotDatabase.RefreshToken = RefreshToken = null;
2818 ArchiLogger.LogGenericInfo(Strings.BotRemovedExpiredLoginKey);
2820 break;
2821 case EResult.AccessDenied:
2822 case EResult.AccountLoginDeniedThrottle:
2823 case EResult.RateLimitExceeded:
2824 ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRateLimitExceeded, TimeSpan.FromMinutes(LoginCooldownInMinutes).ToHumanReadable()));
2826 if (!await ASF.LoginRateLimitingSemaphore.WaitAsync(1000 * WebBrowser.MaxTries).ConfigureAwait(false)) {
2827 break;
2830 try {
2831 await Task.Delay(LoginCooldownInMinutes * 60 * 1000).ConfigureAwait(false);
2832 } finally {
2833 ASF.LoginRateLimitingSemaphore.Release();
2836 break;
2837 default:
2838 // Generic delay before retrying
2839 await Task.Delay(5000).ConfigureAwait(false);
2841 break;
2844 if (!KeepRunning || SteamClient.IsConnected) {
2845 return;
2848 // Wait with reconnection until we're done with the prompt, not earlier
2849 while (RequiredInput != ASF.EUserInputType.None) {
2850 await Task.Delay(1000).ConfigureAwait(false);
2852 if (!KeepRunning || SteamClient.IsConnected) {
2853 return;
2857 ArchiLogger.LogGenericInfo(Strings.BotReconnecting);
2858 await Connect().ConfigureAwait(false);
2861 private async void OnFriendsList(SteamFriends.FriendsListCallback callback) {
2862 ArgumentNullException.ThrowIfNull(callback);
2863 ArgumentNullException.ThrowIfNull(callback.FriendList);
2865 foreach (SteamFriends.FriendsListCallback.Friend friend in callback.FriendList.Where(static friend => friend.Relationship == EFriendRelationship.RequestRecipient)) {
2866 switch (friend.SteamID.AccountType) {
2867 case EAccountType.Clan when IsMasterClanID(friend.SteamID):
2868 ArchiLogger.LogInvite(friend.SteamID, true);
2870 ArchiHandler.AcknowledgeClanInvite(friend.SteamID, true);
2871 await JoinMasterChatGroupID().ConfigureAwait(false);
2873 break;
2874 case EAccountType.Clan:
2875 bool acceptGroupRequest = await PluginsCore.OnBotFriendRequest(this, friend.SteamID).ConfigureAwait(false);
2877 if (acceptGroupRequest) {
2878 ArchiLogger.LogInvite(friend.SteamID, true);
2880 ArchiHandler.AcknowledgeClanInvite(friend.SteamID, true);
2881 await JoinMasterChatGroupID().ConfigureAwait(false);
2883 break;
2886 if (BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.RejectInvalidGroupInvites)) {
2887 ArchiLogger.LogInvite(friend.SteamID, false);
2889 ArchiHandler.AcknowledgeClanInvite(friend.SteamID, false);
2891 break;
2894 ArchiLogger.LogInvite(friend.SteamID);
2896 break;
2897 default:
2898 if (GetAccess(friend.SteamID) >= EAccess.FamilySharing) {
2899 ArchiLogger.LogInvite(friend.SteamID, true);
2901 if (!await ArchiHandler.AddFriend(friend.SteamID).ConfigureAwait(false)) {
2902 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiHandler.AddFriend)));
2905 break;
2908 bool acceptFriendRequest = await PluginsCore.OnBotFriendRequest(this, friend.SteamID).ConfigureAwait(false);
2910 if (acceptFriendRequest) {
2911 ArchiLogger.LogInvite(friend.SteamID, true);
2913 if (!await ArchiHandler.AddFriend(friend.SteamID).ConfigureAwait(false)) {
2914 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiHandler.AddFriend)));
2917 break;
2920 if (BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.RejectInvalidFriendInvites)) {
2921 ArchiLogger.LogInvite(friend.SteamID, false);
2923 if (!await ArchiHandler.RemoveFriend(friend.SteamID).ConfigureAwait(false)) {
2924 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiHandler.RemoveFriend)));
2927 break;
2930 ArchiLogger.LogInvite(friend.SteamID);
2932 break;
2937 private async void OnGuestPassList(SteamApps.GuestPassListCallback callback) {
2938 ArgumentNullException.ThrowIfNull(callback);
2939 ArgumentNullException.ThrowIfNull(callback.GuestPasses);
2941 if ((callback.CountGuestPassesToRedeem == 0) || (callback.GuestPasses.Count == 0) || !BotConfig.AcceptGifts) {
2942 return;
2945 HashSet<ulong> guestPassIDs = callback.GuestPasses.Select(static guestPass => guestPass["gid"].AsUnsignedLong()).Where(static gid => gid != 0).ToHashSet();
2947 if (guestPassIDs.Count == 0) {
2948 return;
2951 await Actions.AcceptGuestPasses(guestPassIDs).ConfigureAwait(false);
2954 private async Task OnIncomingChatMessage(CChatRoom_IncomingChatMessage_Notification notification) {
2955 ArgumentNullException.ThrowIfNull(notification);
2957 if (notification.chat_group_id == 0) {
2958 ArchiLogger.LogNullError(notification.chat_group_id);
2960 return;
2963 if (notification.chat_id == 0) {
2964 ArchiLogger.LogNullError(notification.chat_id);
2966 return;
2969 if (notification.steamid_sender == 0) {
2970 ArchiLogger.LogNullError(notification.steamid_sender);
2972 return;
2975 // Under normal circumstances, timestamp must always be greater than 0, but Steam already proved that it's capable of going against the logic
2976 if ((notification.steamid_sender != SteamID) && (notification.timestamp > 0)) {
2977 if (ShouldAckChatMessage(notification.steamid_sender)) {
2978 Utilities.InBackground(() => ArchiHandler.AckChatMessage(notification.chat_group_id, notification.chat_id, notification.timestamp));
2982 string message;
2984 // Prefer to use message without bbcode, but only if it's available
2985 if (!string.IsNullOrEmpty(notification.message_no_bbcode)) {
2986 message = notification.message_no_bbcode;
2987 } else if (!string.IsNullOrEmpty(notification.message)) {
2988 message = SteamChatMessage.Unescape(notification.message);
2989 } else {
2990 return;
2993 ArchiLogger.LogChatMessage(false, message, notification.chat_group_id, notification.chat_id, notification.steamid_sender);
2995 // Steam network broadcasts chat events also when we don't explicitly sign into Steam community
2996 // We'll explicitly ignore those messages when using offline mode, as it was done in the first version of Steam chat when no messages were broadcasted at all before signing in
2997 // Handling messages will still work correctly in invisible mode, which is how it should work in the first place
2998 // This goes in addition to usual logic that ignores irrelevant messages from being parsed further
2999 if ((notification.chat_group_id != MasterChatGroupID) || (BotConfig.OnlineStatus == EPersonaState.Offline)) {
3000 return;
3003 await Commands.HandleMessage(notification.chat_group_id, notification.chat_id, notification.steamid_sender, message).ConfigureAwait(false);
3006 private async Task OnIncomingMessage(CFriendMessages_IncomingMessage_Notification notification) {
3007 ArgumentNullException.ThrowIfNull(notification);
3009 if (notification.steamid_friend == 0) {
3010 ArchiLogger.LogNullError(notification.steamid_friend);
3012 return;
3015 if ((EChatEntryType) notification.chat_entry_type != EChatEntryType.ChatMsg) {
3016 return;
3019 // Under normal circumstances, timestamp must always be greater than 0, but Steam already proved that it's capable of going against the logic
3020 if (notification is { local_echo: false, rtime32_server_timestamp: > 0 }) {
3021 if (ShouldAckChatMessage(notification.steamid_friend)) {
3022 Utilities.InBackground(() => ArchiHandler.AckMessage(notification.steamid_friend, notification.rtime32_server_timestamp));
3026 string message;
3028 // Prefer to use message without bbcode, but only if it's available
3029 if (!string.IsNullOrEmpty(notification.message_no_bbcode)) {
3030 message = notification.message_no_bbcode;
3031 } else if (!string.IsNullOrEmpty(notification.message)) {
3032 message = SteamChatMessage.Unescape(notification.message);
3033 } else {
3034 return;
3037 ArchiLogger.LogChatMessage(notification.local_echo, message, steamID: notification.steamid_friend);
3039 // Steam network broadcasts chat events also when we don't explicitly sign into Steam community
3040 // We'll explicitly ignore those messages when using offline mode, as it was done in the first version of Steam chat when no messages were broadcasted at all before signing in
3041 // Handling messages will still work correctly in invisible mode, which is how it should work in the first place
3042 // This goes in addition to usual logic that ignores irrelevant messages from being parsed further
3043 if (notification.local_echo || (BotConfig.OnlineStatus == EPersonaState.Offline)) {
3044 return;
3047 await Commands.HandleMessage(notification.steamid_friend, message).ConfigureAwait(false);
3050 private void OnInventoryChanged() {
3051 Utilities.InBackground(CardsFarmer.OnNewItemsNotification);
3053 if (BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.DismissInventoryNotifications)) {
3054 Utilities.InBackground(ArchiWebHandler.MarkInventory);
3057 if (BotConfig.CompleteTypesToSend.Count > 0) {
3058 Utilities.InBackground(SendCompletedSets);
3062 private async void OnLicenseList(SteamApps.LicenseListCallback callback) {
3063 ArgumentNullException.ThrowIfNull(callback);
3064 ArgumentNullException.ThrowIfNull(callback.LicenseList);
3066 if (ASF.GlobalDatabase == null) {
3067 throw new InvalidOperationException(nameof(ASF.GlobalDatabase));
3070 if (callback.LicenseList.Count == 0) {
3071 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(callback.LicenseList)));
3073 return;
3076 // Wait a short time for eventual LastChangeNumber initialization
3077 for (byte i = 0; (i < WebBrowser.MaxTries) && !SteamPICSChanges.LiveUpdate; i++) {
3078 await Task.Delay(1000).ConfigureAwait(false);
3081 Commands.OnNewLicenseList();
3083 Dictionary<uint, (EPaymentMethod PaymentMethod, DateTime TimeCreated)> ownedPackageIDs = new();
3085 Dictionary<uint, ulong> packageAccessTokens = new();
3086 Dictionary<uint, uint> packagesToRefresh = new();
3088 bool hasNewEntries = false;
3090 foreach (SteamApps.LicenseListCallback.License license in callback.LicenseList.GroupBy(static license => license.PackageID, static (_, licenses) => licenses.OrderByDescending(static license => license.TimeCreated).First())) {
3091 ownedPackageIDs[license.PackageID] = (license.PaymentMethod, license.TimeCreated);
3093 if (!OwnedPackageIDs.ContainsKey(license.PackageID)) {
3094 hasNewEntries = true;
3097 if (!ASF.GlobalDatabase.PackageAccessTokensReadOnly.TryGetValue(license.PackageID, out ulong packageAccessToken) || (packageAccessToken != license.AccessToken)) {
3098 packageAccessTokens[license.PackageID] = license.AccessToken;
3100 // Package is always due to refresh with access token change
3101 packagesToRefresh[license.PackageID] = (uint) license.LastChangeNumber;
3102 } else if (!ASF.GlobalDatabase.PackagesDataReadOnly.TryGetValue(license.PackageID, out PackageData? packageData) || (packageData.ChangeNumber < license.LastChangeNumber)) {
3103 packagesToRefresh[license.PackageID] = (uint) license.LastChangeNumber;
3107 OwnedPackageIDs = ownedPackageIDs.ToFrozenDictionary();
3109 if (packageAccessTokens.Count > 0) {
3110 ASF.GlobalDatabase.RefreshPackageAccessTokens(packageAccessTokens);
3113 if (packagesToRefresh.Count > 0) {
3114 // Since Steam spams with this call, display message on info level only if refresh takes longer time
3115 ArchiLogger.LogGenericTrace(Strings.BotRefreshingPackagesData);
3117 bool displayFinish = false;
3119 Task refreshTask = ASF.GlobalDatabase.RefreshPackages(this, packagesToRefresh);
3121 try {
3122 await refreshTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
3123 } catch (TimeoutException) {
3124 ArchiLogger.LogGenericInfo(Strings.BotRefreshingPackagesData);
3126 displayFinish = true;
3129 if (await Task.WhenAny(refreshTask, Task.Delay(5000)).ConfigureAwait(false) != refreshTask) {
3130 ArchiLogger.LogGenericInfo(Strings.BotRefreshingPackagesData);
3132 displayFinish = true;
3135 await refreshTask.ConfigureAwait(false);
3137 if (displayFinish) {
3138 ArchiLogger.LogGenericInfo(Strings.Done);
3141 ArchiLogger.LogGenericTrace(Strings.Done);
3144 if (hasNewEntries) {
3145 await CardsFarmer.OnNewGameAdded().ConfigureAwait(false);
3149 private void OnLoggedOff(SteamUser.LoggedOffCallback callback) {
3150 ArgumentNullException.ThrowIfNull(callback);
3152 // Keep LastLogOnResult for OnDisconnected()
3153 LastLogOnResult = callback.Result > EResult.OK ? callback.Result : EResult.Invalid;
3155 ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotLoggedOff, callback.Result));
3157 switch (callback.Result) {
3158 case EResult.LoggedInElsewhere:
3159 // This result directly indicates that playing was blocked when we got (forcefully) disconnected
3160 PlayingWasBlocked = true;
3162 break;
3163 case EResult.LogonSessionReplaced:
3164 DateTime now = DateTime.UtcNow;
3166 if (now.Subtract(LastLogonSessionReplaced).TotalHours < 1) {
3167 ArchiLogger.LogGenericError(Strings.BotLogonSessionReplaced);
3168 Stop();
3170 return;
3173 LastLogonSessionReplaced = now;
3175 break;
3178 ReconnectOnUserInitiated = true;
3179 SteamClient.Disconnect();
3182 private async void OnLoggedOn(SteamUser.LoggedOnCallback callback) {
3183 ArgumentNullException.ThrowIfNull(callback);
3185 // Always reset one-time-only access tokens when we get OnLoggedOn() response
3186 AuthCode = TwoFactorCode = null;
3188 await HandleLoginResult(callback.Result, callback.ExtendedResult).ConfigureAwait(false);
3190 if (callback.Result != EResult.OK) {
3191 return;
3194 AccountFlags = callback.AccountFlags;
3195 IPCountryCode = callback.IPCountryCode;
3196 SteamID = callback.ClientSteamID ?? throw new InvalidOperationException(nameof(callback.ClientSteamID));
3198 ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotLoggedOn, $"{SteamID}{(!string.IsNullOrEmpty(callback.VanityURL) ? $"/{callback.VanityURL}" : "")}"));
3200 // Old status for these doesn't matter, we'll update them if needed
3201 LoginFailures = 0;
3202 LibraryLocked = PlayingBlocked = false;
3204 if (PlayingWasBlocked && (PlayingWasBlockedTimer == null)) {
3205 InitPlayingWasBlockedTimer();
3208 if (IsAccountLimited) {
3209 ArchiLogger.LogGenericWarning(Strings.BotAccountLimited);
3212 if (IsAccountLocked) {
3213 ArchiLogger.LogGenericWarning(Strings.BotAccountLocked);
3216 if ((callback.CellID != 0) && (ASF.GlobalDatabase != null) && (callback.CellID != ASF.GlobalDatabase.CellID)) {
3217 ASF.GlobalDatabase.CellID = callback.CellID;
3220 // Handle steamID-based maFile
3221 if (!HasMobileAuthenticator) {
3222 string maFilePath = Path.Combine(SharedInfo.ConfigDirectory, $"{SteamID}{SharedInfo.MobileAuthenticatorExtension}");
3224 if (File.Exists(maFilePath)) {
3225 await ImportAuthenticatorFromFile(maFilePath).ConfigureAwait(false);
3229 if (callback.ParentalSettings != null) {
3230 (SteamParentalActive, string? steamParentalCode) = ValidateSteamParental(callback.ParentalSettings, BotConfig.SteamParentalCode, Program.SteamParentalGeneration);
3232 if (SteamParentalActive) {
3233 // Steam parental enabled
3234 if (!string.IsNullOrEmpty(steamParentalCode)) {
3235 // We were able to automatically generate it, potentially with help of the config
3236 if (BotConfig.SteamParentalCode != steamParentalCode) {
3237 if (!SetUserInput(ASF.EUserInputType.SteamParentalCode, steamParentalCode)) {
3238 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(steamParentalCode)));
3240 Stop();
3242 return;
3245 } else {
3246 // We failed to generate the pin ourselves, ask the user
3247 RequiredInput = ASF.EUserInputType.SteamParentalCode;
3249 steamParentalCode = await Logging.GetUserInput(ASF.EUserInputType.SteamParentalCode, BotName).ConfigureAwait(false);
3251 if (string.IsNullOrEmpty(steamParentalCode) || !SetUserInput(ASF.EUserInputType.SteamParentalCode, steamParentalCode)) {
3252 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(steamParentalCode)));
3254 Stop();
3256 return;
3260 } else {
3261 // Steam parental disabled
3262 SteamParentalActive = false;
3265 ArchiWebHandler.OnVanityURLChanged(callback.VanityURL);
3267 // Establish web session
3268 if (!await RefreshWebSession().ConfigureAwait(false)) {
3269 return;
3272 if ((GamesRedeemerInBackgroundTimer == null) && BotDatabase.HasGamesToRedeemInBackground) {
3273 Utilities.InBackground(() => RedeemGamesInBackground());
3276 ArchiHandler.SetCurrentMode(BotConfig.UserInterfaceMode);
3277 ArchiHandler.RequestItemAnnouncements();
3279 // Sometimes Steam won't send us our own PersonaStateCallback, so request it explicitly
3280 RequestPersonaStateUpdate();
3282 Utilities.InBackground(InitializeFamilySharing);
3284 ResetPersonaState();
3286 if (BotConfig.SteamMasterClanID != 0) {
3287 Utilities.InBackground(
3288 async () => {
3289 if (!await ArchiWebHandler.JoinGroup(BotConfig.SteamMasterClanID).ConfigureAwait(false)) {
3290 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, nameof(ArchiWebHandler.JoinGroup)));
3293 await JoinMasterChatGroupID().ConfigureAwait(false);
3298 if (BotConfig.RemoteCommunication.HasFlag(BotConfig.ERemoteCommunication.SteamGroup)) {
3299 Utilities.InBackground(() => ArchiWebHandler.JoinGroup(SharedInfo.ASFGroupSteamID));
3302 if (CardsFarmer.Paused) {
3303 // Emit initial game playing status in this case
3304 Utilities.InBackground(ResetGamesPlayed);
3307 SteamPICSChanges.OnBotLoggedOn();
3309 await PluginsCore.OnBotLoggedOn(this).ConfigureAwait(false);
3312 private async void OnPersonaState(SteamFriends.PersonaStateCallback callback) {
3313 ArgumentNullException.ThrowIfNull(callback);
3315 if (callback.FriendID != SteamID) {
3316 return;
3319 // Empty name should be converted to null, this is actually lack of value, but it's transmitted as empty in protobufs
3320 Nickname = !string.IsNullOrEmpty(callback.Name) ? callback.Name : null;
3322 string? avatarHash = null;
3324 if ((callback.AvatarHash?.Length > 0) && callback.AvatarHash.Any(static singleByte => singleByte > 0)) {
3325 #pragma warning disable CA1308 // False positive, we're intentionally converting this part to lowercase and it's not used for any security decisions based on the result of the normalization
3326 avatarHash = Convert.ToHexString(callback.AvatarHash).ToLowerInvariant();
3327 #pragma warning restore CA1308 // False positive, we're intentionally converting this part to lowercase and it's not used for any security decisions based on the result of the normalization
3329 if (string.IsNullOrEmpty(avatarHash) || avatarHash.All(static singleChar => singleChar == '0')) {
3330 avatarHash = null;
3334 AvatarHash = avatarHash;
3336 await PluginsCore.OnSelfPersonaState(this, callback, Nickname, AvatarHash).ConfigureAwait(false);
3339 private async void OnPlayingSessionState(SteamUser.PlayingSessionStateCallback callback) {
3340 ArgumentNullException.ThrowIfNull(callback);
3342 if (callback.PlayingBlocked == PlayingBlocked) {
3343 return; // No status update, we're not interested
3346 PlayingBlocked = callback.PlayingBlocked;
3347 await CheckOccupationStatus().ConfigureAwait(false);
3350 private async void OnRefreshTokensTimer(object? state = null) {
3351 DateTime accessTokenValidUntil = AccessTokenValidUntil.GetValueOrDefault();
3353 if ((accessTokenValidUntil > DateTime.MinValue) && (accessTokenValidUntil > DateTime.UtcNow.AddMinutes(MinimumAccessTokenValidityMinutes + 1))) {
3354 // We don't need to refresh just yet
3355 InitRefreshTokensTimer(accessTokenValidUntil);
3357 return;
3360 await RefreshWebSession().ConfigureAwait(false);
3363 private async void OnSendItemsTimer(object? state = null) => await Actions.SendInventory(filterFunction: item => BotConfig.LootableTypes.Contains(item.Type)).ConfigureAwait(false);
3365 private async void OnServiceMethod(SteamUnifiedMessages.ServiceMethodNotification notification) {
3366 ArgumentNullException.ThrowIfNull(notification);
3368 switch (notification.MethodName) {
3369 case "ChatRoomClient.NotifyIncomingChatMessage#1":
3370 await OnIncomingChatMessage((CChatRoom_IncomingChatMessage_Notification) notification.Body).ConfigureAwait(false);
3372 break;
3373 case "FriendMessagesClient.IncomingMessage#1":
3374 await OnIncomingMessage((CFriendMessages_IncomingMessage_Notification) notification.Body).ConfigureAwait(false);
3376 break;
3380 private async void OnSharedLibraryLockStatus(SharedLibraryLockStatusCallback callback) {
3381 ArgumentNullException.ThrowIfNull(callback);
3383 // Ignore no status updates
3384 if (LibraryLocked) {
3385 if ((callback.LibraryLockedBySteamID != 0) && (callback.LibraryLockedBySteamID != SteamID)) {
3386 return;
3389 LibraryLocked = false;
3390 } else {
3391 if ((callback.LibraryLockedBySteamID == 0) || (callback.LibraryLockedBySteamID == SteamID)) {
3392 return;
3395 LibraryLocked = true;
3398 await CheckOccupationStatus().ConfigureAwait(false);
3401 private void OnTradeCheckTimer(object? state = null) {
3402 if (IsConnectedAndLoggedOn) {
3403 Utilities.InBackground(Trading.OnNewTrade);
3407 private void OnUserNotifications(UserNotificationsCallback callback) {
3408 ArgumentNullException.ThrowIfNull(callback);
3409 ArgumentNullException.ThrowIfNull(callback.Notifications);
3411 if (callback.Notifications.Count == 0) {
3412 return;
3415 HashSet<UserNotificationsCallback.EUserNotification> newPluginNotifications = [];
3417 foreach ((UserNotificationsCallback.EUserNotification notification, uint count) in callback.Notifications) {
3418 bool newNotification;
3420 if (count > 0) {
3421 newNotification = !PastNotifications.TryGetValue(notification, out uint previousCount) || (count > previousCount);
3422 PastNotifications[notification] = count;
3424 if (newNotification) {
3425 newPluginNotifications.Add(notification);
3427 } else {
3428 newNotification = false;
3429 PastNotifications.TryRemove(notification, out _);
3432 ArchiLogger.LogGenericTrace($"{notification} = {count}");
3434 switch (notification) {
3435 case UserNotificationsCallback.EUserNotification.Gifts when newNotification && BotConfig.AcceptGifts:
3436 Utilities.InBackground(Actions.AcceptDigitalGiftCards);
3438 break;
3439 case UserNotificationsCallback.EUserNotification.Items when newNotification:
3440 OnInventoryChanged();
3442 break;
3443 case UserNotificationsCallback.EUserNotification.Trading when newNotification:
3444 if ((TradeCheckTimer != null) && (BotConfig.TradeCheckPeriod > 0)) {
3445 TradeCheckTimer.Change(TimeSpan.FromMinutes(BotConfig.TradeCheckPeriod), TimeSpan.FromMinutes(BotConfig.TradeCheckPeriod));
3448 Utilities.InBackground(Trading.OnNewTrade);
3450 break;
3454 if (newPluginNotifications.Count > 0) {
3455 Utilities.InBackground(() => PluginsCore.OnBotUserNotifications(this, newPluginNotifications));
3459 private void OnVanityURLChangedCallback(SteamUser.VanityURLChangedCallback callback) {
3460 ArgumentNullException.ThrowIfNull(callback);
3462 ArchiWebHandler.OnVanityURLChanged(callback.VanityURL);
3465 private void OnWalletInfo(SteamUser.WalletInfoCallback callback) {
3466 ArgumentNullException.ThrowIfNull(callback);
3468 WalletBalance = callback.LongBalance;
3469 WalletBalanceDelayed = callback.LongBalanceDelayed;
3470 WalletCurrency = callback.Currency;
3473 private async void RedeemGamesInBackground(object? state = null) {
3474 if (!await GamesRedeemerInBackgroundSemaphore.WaitAsync(0).ConfigureAwait(false)) {
3475 return;
3478 try {
3479 if (GamesRedeemerInBackgroundTimer != null) {
3480 await GamesRedeemerInBackgroundTimer.DisposeAsync().ConfigureAwait(false);
3482 GamesRedeemerInBackgroundTimer = null;
3485 ArchiLogger.LogGenericInfo(Strings.Starting);
3487 bool assumeWalletKeyOnBadActivationCode = BotConfig.RedeemingPreferences.HasFlag(BotConfig.ERedeemingPreferences.AssumeWalletKeyOnBadActivationCode);
3489 while (IsConnectedAndLoggedOn && BotDatabase.HasGamesToRedeemInBackground) {
3490 (string? key, string? name) = BotDatabase.GetGameToRedeemInBackground();
3492 if (string.IsNullOrEmpty(key)) {
3493 ArchiLogger.LogNullError(key);
3495 break;
3498 if (string.IsNullOrEmpty(name)) {
3499 ArchiLogger.LogNullError(name);
3501 break;
3504 CStore_RegisterCDKey_Response? response = await Actions.RedeemKey(key).ConfigureAwait(false);
3506 if (response == null) {
3507 continue;
3510 EResult result = (EResult) response.purchase_receipt_info.purchase_status;
3511 EPurchaseResultDetail purchaseResultDetail = (EPurchaseResultDetail) response.purchase_result_details;
3513 string? balanceText = null;
3515 if ((purchaseResultDetail == EPurchaseResultDetail.CannotRedeemCodeFromClient) || ((purchaseResultDetail == EPurchaseResultDetail.BadActivationCode) && assumeWalletKeyOnBadActivationCode)) {
3516 // If it's a wallet code, we try to redeem it first, then handle the inner result as our primary one
3517 (EResult Result, EPurchaseResultDetail? PurchaseResult, string? BalanceText)? walletResult = await ArchiWebHandler.RedeemWalletKey(key).ConfigureAwait(false);
3519 if (walletResult != null) {
3520 result = walletResult.Value.Result;
3521 purchaseResultDetail = walletResult.Value.PurchaseResult.GetValueOrDefault(walletResult.Value.Result == EResult.OK ? EPurchaseResultDetail.NoDetail : EPurchaseResultDetail.BadActivationCode); // BadActivationCode is our smart guess in this case
3522 balanceText = walletResult.Value.BalanceText;
3523 } else {
3524 result = EResult.Timeout;
3525 purchaseResultDetail = EPurchaseResultDetail.Timeout;
3529 Dictionary<uint, string>? items = response.purchase_receipt_info.line_items.Count > 0 ? response.purchase_receipt_info.line_items.ToDictionary(static lineItem => lineItem.packageid, static lineItem => lineItem.line_item_description) : null;
3531 ArchiLogger.LogGenericDebug(items?.Count > 0 ? string.Format(CultureInfo.CurrentCulture, Strings.BotRedeemWithItems, key, $"{result}/{purchaseResultDetail}{(!string.IsNullOrEmpty(balanceText) ? $"/{balanceText}" : "")}", string.Join(", ", items)) : string.Format(CultureInfo.CurrentCulture, Strings.BotRedeem, key, $"{result}/{purchaseResultDetail}{(!string.IsNullOrEmpty(balanceText) ? $"/{balanceText}" : "")}"));
3533 bool rateLimited = false;
3534 bool redeemed = false;
3536 switch (purchaseResultDetail) {
3537 case EPurchaseResultDetail.AccountLocked:
3538 case EPurchaseResultDetail.AlreadyPurchased:
3539 case EPurchaseResultDetail.CannotRedeemCodeFromClient:
3540 case EPurchaseResultDetail.DoesNotOwnRequiredApp:
3541 case EPurchaseResultDetail.NoWallet:
3542 case EPurchaseResultDetail.RestrictedCountry:
3543 case EPurchaseResultDetail.Timeout:
3544 break;
3545 case EPurchaseResultDetail.BadActivationCode:
3546 case EPurchaseResultDetail.DuplicateActivationCode:
3547 case EPurchaseResultDetail.NoDetail: // OK
3548 redeemed = true;
3550 break;
3551 case EPurchaseResultDetail.RateLimited:
3552 rateLimited = true;
3554 break;
3555 default:
3556 ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(purchaseResultDetail), purchaseResultDetail));
3558 break;
3561 if (rateLimited) {
3562 break;
3565 BotDatabase.RemoveGameToRedeemInBackground(key);
3567 // If user omitted the name or intentionally provided the same name as key, replace it with the Steam result
3568 if (name.Equals(key, StringComparison.OrdinalIgnoreCase) && (items?.Count > 0)) {
3569 name = string.Join(", ", items.Values);
3572 string logEntry = $"{name}{DefaultBackgroundKeysRedeemerSeparator}[{purchaseResultDetail}]{(items?.Count > 0 ? $"{DefaultBackgroundKeysRedeemerSeparator}{string.Join(", ", items)}" : "")}{DefaultBackgroundKeysRedeemerSeparator}{key}";
3574 string filePath = GetFilePath(redeemed ? EFileType.KeysToRedeemUsed : EFileType.KeysToRedeemUnused);
3576 if (string.IsNullOrEmpty(filePath)) {
3577 ArchiLogger.LogNullError(filePath);
3579 return;
3582 try {
3583 await File.AppendAllTextAsync(filePath, $"{logEntry}{Environment.NewLine}").ConfigureAwait(false);
3584 } catch (Exception e) {
3585 ArchiLogger.LogGenericException(e);
3586 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.Content, logEntry));
3588 break;
3592 if (IsConnectedAndLoggedOn && BotDatabase.HasGamesToRedeemInBackground) {
3593 ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotRateLimitExceeded, TimeSpan.FromHours(RedeemCooldownInHours).ToHumanReadable()));
3595 GamesRedeemerInBackgroundTimer = new Timer(
3596 RedeemGamesInBackground,
3597 null,
3598 TimeSpan.FromHours(RedeemCooldownInHours), // Delay
3599 Timeout.InfiniteTimeSpan // Period
3603 ArchiLogger.LogGenericInfo(Strings.Done);
3604 } finally {
3605 GamesRedeemerInBackgroundSemaphore.Release();
3609 private async Task ResetGamesPlayed() {
3610 if (!IsConnectedAndLoggedOn || CardsFarmer.NowFarming) {
3611 return;
3614 if (BotConfig.GamesPlayedWhileIdle.Count > 0) {
3615 if (!IsPlayingPossible) {
3616 return;
3619 // This function might be executed before PlayingSessionStateCallback/SharedLibraryLockStatusCallback, ensure proper delay in this case
3620 await Task.Delay(2000).ConfigureAwait(false);
3622 if (!IsConnectedAndLoggedOn || CardsFarmer.NowFarming || !IsPlayingPossible) {
3623 return;
3626 if (PlayingWasBlocked) {
3627 byte minFarmingDelayAfterBlock = ASF.GlobalConfig?.MinFarmingDelayAfterBlock ?? GlobalConfig.DefaultMinFarmingDelayAfterBlock;
3629 if (minFarmingDelayAfterBlock > 0) {
3630 for (byte i = 0; (i < minFarmingDelayAfterBlock) && IsConnectedAndLoggedOn && !CardsFarmer.NowFarming && IsPlayingPossible && PlayingWasBlocked; i++) {
3631 await Task.Delay(1000).ConfigureAwait(false);
3634 if (!IsConnectedAndLoggedOn || CardsFarmer.NowFarming || !IsPlayingPossible) {
3635 return;
3640 ArchiLogger.LogGenericInfo(string.Format(CultureInfo.CurrentCulture, Strings.BotIdlingSelectedGames, nameof(BotConfig.GamesPlayedWhileIdle), string.Join(", ", BotConfig.GamesPlayedWhileIdle)));
3643 await ArchiHandler.PlayGames(BotConfig.GamesPlayedWhileIdle, BotConfig.CustomGamePlayedWhileIdle).ConfigureAwait(false);
3646 private void ResetPlayingWasBlockedWithTimer(object? state = null) {
3647 PlayingWasBlocked = false;
3648 StopPlayingWasBlockedTimer();
3651 private async Task SendCompletedSets() {
3652 // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that
3653 lock (SendCompleteTypesSemaphore) {
3654 if (SendCompleteTypesScheduled) {
3655 return;
3658 SendCompleteTypesScheduled = true;
3661 await SendCompleteTypesSemaphore.WaitAsync().ConfigureAwait(false);
3663 try {
3664 using (await Actions.GetTradingLock().ConfigureAwait(false)) {
3665 // ReSharper disable once SuspiciousLockOverSynchronizationPrimitive - this is not a mistake, we need extra synchronization, and we can re-use the semaphore object for that
3666 lock (SendCompleteTypesSemaphore) {
3667 SendCompleteTypesScheduled = false;
3670 HashSet<uint>? appIDs = await GetPossiblyCompletedBadgeAppIDs().ConfigureAwait(false);
3672 if ((appIDs == null) || (appIDs.Count == 0)) {
3673 return;
3676 HashSet<Asset> inventory;
3678 try {
3679 inventory = await ArchiHandler.GetMyInventoryAsync(tradableOnly: true)
3680 .Where(item => appIDs.Contains(item.RealAppID) && BotConfig.CompleteTypesToSend.Contains(item.Type))
3681 .ToHashSetAsync()
3682 .ConfigureAwait(false);
3683 } catch (TimeoutException e) {
3684 ArchiLogger.LogGenericWarningException(e);
3686 return;
3687 } catch (Exception e) {
3688 ArchiLogger.LogGenericException(e);
3690 return;
3693 if (inventory.Count == 0) {
3694 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(inventory)));
3696 return;
3699 Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), List<uint>> inventorySets = Trading.GetInventorySets(inventory);
3701 // Filter appIDs that can't possibly be completed due to having less cards than smallest badges possible
3702 appIDs.IntersectWith(inventorySets.Where(static kv => kv.Value.Count >= MinCardsPerBadge).Select(static kv => kv.Key.RealAppID));
3704 if (appIDs.Count == 0) {
3705 return;
3708 Dictionary<uint, byte>? cardsCountPerAppID = await LoadCardsPerSet(appIDs).ConfigureAwait(false);
3710 if (cardsCountPerAppID == null) {
3711 return;
3714 Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), (uint Sets, byte CardsPerSet)> itemsToTakePerInventorySet = new();
3716 foreach (((uint RealAppID, EAssetType Type, EAssetRarity Rarity) key, List<uint> amounts) in inventorySets.Where(set => appIDs.Contains(set.Key.RealAppID))) {
3717 if (!cardsCountPerAppID.TryGetValue(key.RealAppID, out byte cardsCount) || (cardsCount == 0)) {
3718 throw new InvalidOperationException(nameof(cardsCount));
3721 if (amounts.Count < cardsCount) {
3722 // Filter results that can't be completed due to not having enough cards available (now that we know how much exactly)
3723 continue;
3726 uint minimumOwnedAmount = amounts[0];
3728 if (minimumOwnedAmount == 0) {
3729 throw new InvalidOperationException(nameof(minimumOwnedAmount));
3732 itemsToTakePerInventorySet[key] = (minimumOwnedAmount, cardsCount);
3735 if (itemsToTakePerInventorySet.Count == 0) {
3736 return;
3739 HashSet<Asset> result = GetItemsForFullSets(inventory, itemsToTakePerInventorySet);
3741 if (result.Count > 0) {
3742 await Actions.SendInventory(result).ConfigureAwait(false);
3745 } finally {
3746 SendCompleteTypesSemaphore.Release();
3750 private async Task<bool> SendMessagePart(ulong steamID, string messagePart, ulong chatGroupID = 0) {
3751 if ((steamID == 0) || ((chatGroupID == 0) && !new SteamID(steamID).IsIndividualAccount)) {
3752 throw new ArgumentOutOfRangeException(nameof(steamID));
3755 ArgumentException.ThrowIfNullOrEmpty(messagePart);
3757 if (!IsConnectedAndLoggedOn) {
3758 return false;
3761 await MessagingSemaphore.WaitAsync().ConfigureAwait(false);
3763 try {
3764 for (byte i = 0; (i < WebBrowser.MaxTries) && IsConnectedAndLoggedOn; i++) {
3765 EResult result;
3767 if (chatGroupID == 0) {
3768 result = await ArchiHandler.SendMessage(steamID, messagePart).ConfigureAwait(false);
3769 } else {
3770 result = await ArchiHandler.SendMessage(chatGroupID, steamID, messagePart).ConfigureAwait(false);
3773 switch (result) {
3774 case EResult.Blocked:
3775 // No point in retrying, those failures are permanent
3776 ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.WarningFailedWithError, result));
3778 return false;
3779 case EResult.Busy:
3780 case EResult.Fail:
3781 case EResult.LimitExceeded:
3782 case EResult.RateLimitExceeded:
3783 case EResult.ServiceUnavailable:
3784 case EResult.Timeout:
3785 await Task.Delay(5000).ConfigureAwait(false);
3787 continue;
3788 case EResult.OK:
3789 return true;
3790 default:
3791 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(result), result));
3793 return false;
3797 return false;
3798 } finally {
3799 MessagingSemaphore.Release();
3803 private bool ShouldAckChatMessage(ulong steamID) {
3804 if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) {
3805 throw new ArgumentOutOfRangeException(nameof(steamID));
3808 if (Bots == null) {
3809 throw new InvalidOperationException(nameof(Bots));
3812 if (BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.MarkReceivedMessagesAsRead)) {
3813 return true;
3816 return BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.MarkBotMessagesAsRead) && Bots.Values.Any(bot => bot.SteamID == steamID);
3819 private void StopConnectionFailureTimer() {
3820 if (ConnectionFailureTimer == null) {
3821 return;
3824 ConnectionFailureTimer.Dispose();
3825 ConnectionFailureTimer = null;
3828 private void StopPlayingWasBlockedTimer() {
3829 if (PlayingWasBlockedTimer == null) {
3830 return;
3833 PlayingWasBlockedTimer.Dispose();
3834 PlayingWasBlockedTimer = null;
3837 private void StopRefreshTokensTimer() {
3838 if (RefreshTokensTimer == null) {
3839 return;
3842 RefreshTokensTimer.Dispose();
3843 RefreshTokensTimer = null;
3846 private void UpdateTokens(string accessToken, string? refreshToken = null) {
3847 ArgumentException.ThrowIfNullOrEmpty(accessToken);
3849 AccessToken = accessToken;
3851 if (!string.IsNullOrEmpty(refreshToken)) {
3852 RefreshToken = refreshToken;
3855 if (BotConfig.UseLoginKeys) {
3856 if (BotConfig.PasswordFormat.HasTransformation()) {
3857 BotDatabase.AccessToken = ArchiCryptoHelper.Encrypt(BotConfig.PasswordFormat, accessToken);
3859 if (!string.IsNullOrEmpty(refreshToken)) {
3860 BotDatabase.RefreshToken = ArchiCryptoHelper.Encrypt(BotConfig.PasswordFormat, refreshToken);
3862 } else {
3863 BotDatabase.AccessToken = accessToken;
3865 if (!string.IsNullOrEmpty(refreshToken)) {
3866 BotDatabase.RefreshToken = refreshToken;
3872 private (bool IsSteamParentalEnabled, string? SteamParentalCode) ValidateSteamParental(ParentalSettings settings, string? steamParentalCode = null, bool allowGeneration = true) {
3873 ArgumentNullException.ThrowIfNull(settings);
3875 if (!settings.is_enabled || (settings.passwordhash == null)) {
3876 return (false, null);
3879 if (settings.passwordhash.Length > byte.MaxValue) {
3880 throw new ArgumentOutOfRangeException(nameof(settings));
3883 ArchiCryptoHelper.EHashingMethod steamParentalHashingMethod;
3885 switch (settings.passwordhashtype) {
3886 case 4:
3887 steamParentalHashingMethod = ArchiCryptoHelper.EHashingMethod.Pbkdf2;
3889 break;
3890 case 6:
3891 steamParentalHashingMethod = ArchiCryptoHelper.EHashingMethod.SCrypt;
3893 break;
3894 default:
3895 ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(settings.passwordhashtype), settings.passwordhashtype));
3897 return (true, null);
3900 if (!string.IsNullOrEmpty(steamParentalCode)) {
3901 byte i = 0;
3903 byte[] password = new byte[steamParentalCode.Length];
3905 foreach (char character in steamParentalCode.TakeWhile(static character => character is >= '0' and <= '9')) {
3906 password[i++] = (byte) character;
3909 if (i >= steamParentalCode.Length) {
3910 byte[] passwordHash = ArchiCryptoHelper.Hash(password, settings.salt, (byte) settings.passwordhash.Length, steamParentalHashingMethod);
3912 if (passwordHash.SequenceEqual(settings.passwordhash)) {
3913 return (true, steamParentalCode);
3918 if (!allowGeneration) {
3919 return (true, null);
3922 ArchiLogger.LogGenericInfo(Strings.BotGeneratingSteamParentalCode);
3924 steamParentalCode = ArchiCryptoHelper.RecoverSteamParentalCode(settings.passwordhash, settings.salt, steamParentalHashingMethod);
3926 ArchiLogger.LogGenericInfo(Strings.Done);
3928 return (true, steamParentalCode);
3931 public enum EFileType : byte {
3932 Config,
3933 Database,
3934 KeysToRedeem,
3935 KeysToRedeemUnused,
3936 KeysToRedeemUsed,
3937 MobileAuthenticator