1 // ----------------------------------------------------------------------------------------------
3 // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___
4 // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \
5 // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | |
6 // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_|
7 // ----------------------------------------------------------------------------------------------
9 // Copyright 2015-2024 Ćukasz "JustArchi" Domeradzki
10 // Contact: JustArchi@JustArchi.net
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
16 // http://www.apache.org/licenses/LICENSE-2.0
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.
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
;
36 using System
.Text
.Json
.Serialization
;
37 using System
.Text
.RegularExpressions
;
38 using System
.Threading
;
39 using System
.Threading
.Tasks
;
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
;
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;
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);
89 public Actions Actions { get; }
93 public ArchiHandler ArchiHandler { get; }
97 public ArchiLogger ArchiLogger { get; }
101 public ArchiWebHandler ArchiWebHandler { get; }
105 public BotDatabase BotDatabase { get; }
110 public string BotName { get; }
115 public CardsFarmer CardsFarmer { get; }
119 public Commands Commands { get; }
124 public uint GamesToRedeemInBackgroundCount
=> BotDatabase
.GamesToRedeemInBackgroundCount
;
129 public bool HasMobileAuthenticator
=> BotDatabase
.MobileAuthenticator
!= null;
133 public bool IsAccountLimited
=> AccountFlags
.HasFlag(EAccountFlags
.LimitedUser
) || AccountFlags
.HasFlag(EAccountFlags
.LimitedUserForce
);
137 public bool IsAccountLocked
=> AccountFlags
.HasFlag(EAccountFlags
.Lockdown
);
142 public bool IsConnectedAndLoggedOn
=> SteamClient
.SteamID
!= null;
147 public bool IsPlayingPossible
=> !PlayingBlocked
&& !LibraryLocked
;
151 public string? PublicIP
=> SteamClient
.PublicIP
?.ToString();
154 [JsonPropertyName($"{SharedInfo.UlongCompatibilityStringPrefix}{nameof(SteamID)}")]
157 public string SSteamID
=> SteamID
.ToString(CultureInfo
.InvariantCulture
);
161 public SteamApps SteamApps { get; }
165 public SteamConfiguration SteamConfiguration { get; }
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
{
190 foreach (EFileType fileType
in Enum
.GetValues(typeof(EFileType
))) {
191 string filePath
= GetFilePath(fileType
);
193 if (string.IsNullOrEmpty(filePath
)) {
194 ArchiLogger
.LogNullError(filePath
);
199 yield return (filePath
, fileType
);
206 public string? AccessToken
{
207 get => BackingAccessToken
;
210 AccessTokenValidUntil
= null;
212 if (string.IsNullOrEmpty(value)) {
213 BackingAccessToken
= null;
218 if (!Utilities
.TryReadJsonWebToken(value, out JsonWebToken
? accessToken
)) {
219 ArchiLogger
.LogGenericError(string.Format(CultureInfo
.CurrentCulture
, Strings
.ErrorIsInvalid
, nameof(accessToken
)));
224 BackingAccessToken
= value;
226 if (accessToken
.ValidTo
> DateTime
.MinValue
) {
227 AccessTokenValidUntil
= accessToken
.ValidTo
;
236 public EAccountFlags AccountFlags { get; private set; }
240 public string? AvatarHash { get; private set; }
246 public BotConfig BotConfig { get; private set; }
252 public bool KeepRunning { get; private set; }
256 public string? Nickname { get; private set; }
260 public FrozenDictionary
<uint, (EPaymentMethod PaymentMethod
, DateTime TimeCreated
)> OwnedPackageIDs { get; private set; }
= FrozenDictionary
<uint, (EPaymentMethod PaymentMethod
, DateTime TimeCreated
)>.Empty
;
266 public ASF
.EUserInputType RequiredInput { get; private set; }
272 public ulong SteamID { get; private set; }
278 public long WalletBalance { get; private set; }
284 public long WalletBalanceDelayed { get; private set; }
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
));
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(
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
);
355 SteamClient
= new SteamClient(SteamConfiguration
, botName
);
357 if (Debugging
.IsDebugConfigured
&& Directory
.Exists(ASF
.DebugDirectory
)) {
358 string debugListenerPath
= Path
.Combine(ASF
.DebugDirectory
, botName
);
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(
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();
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();
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);
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
)) {
487 File
.Delete(filePath
);
488 } catch (Exception e
) {
489 ArchiLogger
.LogGenericException(e
);
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
:
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
;
524 ASF
.ArchiLogger
.LogGenericError(string.Format(CultureInfo
.CurrentCulture
, Strings
.WarningUnknownValuePleaseReport
, nameof(permission
), permission
));
526 return familySharingAccess
;
531 public static Bot
? GetBot(string botName
) {
532 ArgumentException
.ThrowIfNullOrEmpty(botName
);
535 throw new InvalidOperationException(nameof(Bots
));
538 if (Bots
.TryGetValue(botName
, out Bot
? targetBot
)) {
542 if (!ulong.TryParse(botName
, out ulong steamID
) || (steamID
== 0) || !new SteamID(steamID
).IsIndividualAccount
) {
546 return Bots
.Values
.FirstOrDefault(bot
=> bot
.SteamID
== steamID
);
550 public static HashSet
<Bot
>? GetBots(string args
) {
551 ArgumentException
.ThrowIfNullOrEmpty(args
);
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
);
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
) {
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
) {
590 result
.Add(firstBot
);
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
)) {
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
;
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
);
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
);
646 Bot
? singleBot
= GetBot(botName
);
648 if (singleBot
== null) {
652 result
.Add(singleBot
);
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
))
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
);
689 public T
? GetHandler
<T
>() where T
: ClientMsgHandler
=> SteamClient
.GetHandler
<T
>();
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
)) {
711 if (itemsPerSet
< itemsPerClassID
.Count
) {
712 throw new InvalidOperationException($"{nameof(itemsPerSet)} < {nameof(itemsPerClassID)}");
715 if (itemsPerSet
> itemsPerClassID
.Count
) {
719 ushort maxSetsAllowed
= (ushort) ((maxItems
- result
.Count
) / itemsPerSet
);
720 ushort realSetsToExtract
= (ushort) Math
.Min(setsToExtract
, maxSetsAllowed
);
722 if (realSetsToExtract
== 0) {
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
) {
733 classRemaining
-= (ushort) item
.Amount
;
735 Asset itemToSend
= item
.DeepClone();
736 itemToSend
.Amount
= classRemaining
;
737 result
.Add(itemToSend
);
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
);
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
);
770 if (!byte.TryParse(lastPage
, out maxPages
) || (maxPages
== 0)) {
771 ArchiLogger
.LogNullError(maxPages
);
777 HashSet
<uint>? firstPageResult
= GetPossiblyCompletedBadgeAppIDs(badgePage
);
779 if (firstPageResult
== null) {
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) {
796 firstPageResult
.UnionWith(pageIDs
);
799 return firstPageResult
;
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) {
816 firstPageResult
.UnionWith(result
);
819 return firstPageResult
;
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
);
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);
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) {
879 result
.Add(appID
, cardCount
);
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;
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
) {
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
);
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
) {
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
);
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
953 case ASF
.EUserInputType
.DeviceConfirmation
:
954 // Nothing to do for us
956 case ASF
.EUserInputType
.Login
:
957 BotConfig
.SteamLogin
= inputValue
;
959 // Do not allow saving this account credential
960 BotConfig
.IsSteamLoginSet
= false;
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
;
973 case ASF
.EUserInputType
.SteamGuard
:
974 if (inputValue
.Length
!= 5) {
978 AuthCode
= inputValue
;
981 case ASF
.EUserInputType
.SteamParentalCode
:
982 if ((inputValue
.Length
!= BotConfig
.SteamParentalCodeLength
) || inputValue
.Any(static character
=> character
is < '0' or
> '9')) {
986 BotConfig
.SteamParentalCode
= inputValue
;
988 // Do not allow saving this account credential
989 BotConfig
.IsSteamParentalCodeSet
= false;
992 case ASF
.EUserInputType
.TwoFactorAuthentication
:
993 switch (inputValue
.Length
) {
994 case MobileAuthenticator
.BackupCodeDigits
:
995 case MobileAuthenticator
.CodeDigits
:
1001 inputValue
= inputValue
.ToUpperInvariant();
1003 if (inputValue
.Any(static character
=> !MobileAuthenticator
.CodeCharacters
.Contains(character
))) {
1007 TwoFactorCode
= inputValue
;
1011 throw new InvalidOperationException(nameof(inputType
));
1014 if (RequiredInput
== inputType
) {
1015 RequiredInput
= ASF
.EUserInputType
.None
;
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
);
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
);
1063 if (File
.Exists(unusedKeysFilePath
)) {
1065 File
.Delete(unusedKeysFilePath
);
1066 } catch (Exception e
) {
1067 ArchiLogger
.LogGenericException(e
);
1073 string usedKeysFilePath
= GetFilePath(EFileType
.KeysToRedeemUsed
);
1075 if (string.IsNullOrEmpty(usedKeysFilePath
)) {
1076 ASF
.ArchiLogger
.LogNullError(usedKeysFilePath
);
1081 if (File
.Exists(usedKeysFilePath
)) {
1083 File
.Delete(usedKeysFilePath
);
1084 } catch (Exception e
) {
1085 ArchiLogger
.LogGenericException(e
);
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
)) {
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
1145 if (ownedPackageData
.TimeCreated
< safePlayableBefore
) {
1146 // Our package is older than required, this is playable
1147 regionRestrictedUntil
= null;
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;
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
++) {
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
++) {
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
)) {
1218 KeyValue productInfo
= productInfoApp
.KeyValues
;
1220 if (productInfo
== KeyValue
.Invalid
) {
1221 ArchiLogger
.LogNullError(productInfo
);
1226 KeyValue commonProductInfo
= productInfo
["common"];
1228 if (commonProductInfo
== KeyValue
.Invalid
) {
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()) {
1239 case "PRELOADONLY" or
"PRERELEASE":
1240 return (0, DateTime
.MaxValue
, true);
1242 ArchiLogger
.LogGenericError(string.Format(CultureInfo
.CurrentCulture
, Strings
.WarningUnknownValuePleaseReport
, nameof(releaseState
), releaseState
));
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
1263 ArchiLogger
.LogGenericError(string.Format(CultureInfo
.CurrentCulture
, Strings
.WarningUnknownValuePleaseReport
, nameof(type
), type
));
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
);
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
) {
1305 if (!string.IsNullOrEmpty(ASF
.GlobalConfig
?.DefaultBot
) && Bots
.TryGetValue(ASF
.GlobalConfig
.DefaultBot
, out Bot
? 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
)) {
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
++) {
1341 productInfoResultSet
= await SteamApps
.PICSGetProductInfo([], packageRequests
).ToLongRunningTask().ConfigureAwait(false);
1342 } catch (Exception e
) {
1343 ArchiLogger
.LogGenericWarningException(e
);
1347 if (productInfoResultSet
?.Results
== 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
);
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
);
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
));
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
) {
1409 CPrivacySettings
? privacySettings
= await ArchiHandler
.GetPrivacySettings().ConfigureAwait(false);
1411 if (privacySettings
== null) {
1412 ArchiLogger
.LogGenericWarning(Strings
.WarningFailed
);
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
);
1454 OrderedDictionary gamesToRedeemInBackground
= new();
1456 using (StreamReader reader
= new(filePath
)) {
1457 while (await reader
.ReadLineAsync().ConfigureAwait(false) is { } line
) {
1458 if (line
.Length
== 0) {
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
));
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
);
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
) {
1528 await Destroy().ConfigureAwait(false);
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
1546 if (botConfig
== BotConfig
) {
1550 await InitializationSemaphore
.WaitAsync().ConfigureAwait(false);
1553 if (botConfig
== BotConfig
) {
1557 // Skip shutdown event as we're actually reinitializing the bot, not fully stopping it
1560 BotConfig
= botConfig
;
1562 await InitModules().ConfigureAwait(false);
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
)) {
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
) {
1595 await RefreshWebSessionSemaphore
.WaitAsync().ConfigureAwait(false);
1598 if (!IsConnectedAndLoggedOn
) {
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
);
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);
1623 AccessTokenGenerateResult response
;
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);
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);
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
);
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);
1664 RefreshWebSessionSemaphore
.Release();
1668 internal static async Task
RegisterBot(string botName
) {
1669 ArgumentException
.ThrowIfNullOrEmpty(botName
);
1672 throw new InvalidOperationException(nameof(Bots
));
1675 if (Bots
.ContainsKey(botName
)) {
1679 string configFilePath
= GetFilePath(botName
, EFileType
.Config
);
1681 if (string.IsNullOrEmpty(configFilePath
)) {
1682 ASF
.ArchiLogger
.LogNullError(configFilePath
);
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
));
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
);
1715 BotDatabase
? botDatabase
= await BotDatabase
.CreateOrLoad(databaseFilePath
).ConfigureAwait(false);
1717 if (botDatabase
== null) {
1718 ASF
.ArchiLogger
.LogGenericError(string.Format(CultureInfo
.CurrentCulture
, Strings
.ErrorDatabaseInvalid
, databaseFilePath
));
1723 if (Debugging
.IsDebugConfigured
) {
1724 ASF
.ArchiLogger
.LogGenericDebug($"{databaseFilePath}: {botDatabase.ToJsonText(true)}");
1727 botDatabase
.PerformMaintenance();
1731 await BotsSemaphore
.WaitAsync().ConfigureAwait(false);
1734 if (Bots
.ContainsKey(botName
)) {
1738 bot
= new Bot(botName
, botConfig
, botDatabase
);
1740 if (!Bots
.TryAdd(botName
, bot
)) {
1741 ASF
.ArchiLogger
.LogNullError(bot
);
1743 await bot
.DisposeAsync().ConfigureAwait(false);
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);
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
);
1785 throw new InvalidOperationException(nameof(Bots
));
1788 if (!ASF
.IsValidBotName(newBotName
) || Bots
.ContainsKey(newBotName
)) {
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
);
1809 File
.Move(filePath
, newFilePath
);
1810 } catch (Exception e
) {
1811 ArchiLogger
.LogGenericException(e
);
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
;
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
;
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
)));
1866 // We keep user input set in case we need to use it again due to disconnection, OnLoggedOn() will reset it for us
1870 internal void RequestPersonaStateUpdate() {
1871 if (!IsConnectedAndLoggedOn
) {
1875 SteamFriends
.RequestFriendInfo(SteamID
, EClientPersonaStateFlag
.PlayerName
| EClientPersonaStateFlag
.Presence
);
1878 internal void ResetPersonaState() {
1879 if (BotConfig
.OnlineStatus
== EPersonaState
.Offline
) {
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
) {
1899 return await ArchiHandler
.SendTypingStatus(steamID
).ConfigureAwait(false) == EResult
.OK
;
1902 internal async Task
Start() {
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
);
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
);
1934 if (File
.Exists(keysToRedeemFilePath
)) {
1935 await ImportKeysToRedeem(keysToRedeemFilePath
).ConfigureAwait(false);
1938 await Connect().ConfigureAwait(false);
1941 internal void Stop(bool skipShutdownEvent
= false) {
1946 KeepRunning
= false;
1947 ArchiLogger
.LogGenericInfo(Strings
.BotStopping
);
1949 if (SteamClient
.IsConnected
) {
1953 if (!skipShutdownEvent
) {
1954 Utilities
.InBackground(Events
.OnBotShutdown
);
1958 internal bool TryImportAuthenticator(MobileAuthenticator authenticator
) {
1959 ArgumentNullException
.ThrowIfNull(authenticator
);
1961 if (HasMobileAuthenticator
) {
1965 authenticator
.Init(this);
1966 BotDatabase
.MobileAuthenticator
= authenticator
;
1968 ArchiLogger
.LogGenericInfo(Strings
.BotAuthenticatorImportFinished
);
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
)) {
1992 await LimitLoginRequestsAsync().ConfigureAwait(false);
1994 if (!force
&& (!KeepRunning
|| SteamClient
.IsConnected
)) {
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) {
2008 throw new InvalidOperationException(nameof(Bots
));
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
);
2043 using StreamReader reader
= new(filePath
);
2045 while (await reader
.ReadLineAsync().ConfigureAwait(false) is { } line
) {
2046 if (line
.Length
== 0) {
2050 string[] parsedArgs
= line
.Split(DefaultBackgroundKeysRedeemerSeparator
, StringSplitOptions
.RemoveEmptyEntries
);
2052 if (parsedArgs
.Length
< 3) {
2053 ArchiLogger
.LogGenericWarning(string.Format(CultureInfo
.CurrentCulture
, Strings
.ErrorIsInvalid
, line
));
2058 string key
= parsedArgs
[^
1];
2060 if (!Utilities
.IsValidCdKey(key
)) {
2061 ArchiLogger
.LogGenericWarning(string.Format(CultureInfo
.CurrentCulture
, Strings
.ErrorIsInvalid
, key
));
2066 string name
= parsedArgs
[0];
2069 } catch (Exception e
) {
2070 ArchiLogger
.LogGenericException(e
);
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
);
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
);
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
);
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
)));
2133 TimeSpan timeSpan
= TimeSpan
.FromMilliseconds(CallbackSleep
);
2135 while (KeepRunning
|| SteamClient
.IsConnected
) {
2136 CallbackManager
.RunWaitAllCallbacks(timeSpan
);
2138 } catch (Exception e
) {
2139 ArchiLogger
.LogGenericException(e
);
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();
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
));
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
2173 ArchiLogger
.LogGenericError(string.Format(CultureInfo
.CurrentCulture
, Strings
.BotInvalidPasswordDuringLogin
, MaxLoginFailures
));
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
2183 ArchiLogger
.LogGenericError(string.Format(CultureInfo
.CurrentCulture
, Strings
.BotInvalidAuthenticatorDuringLogin
, MaxLoginFailures
));
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);
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
)));
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
)));
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
));
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
));
2257 private async void HeartBeat(object? state
= null) {
2258 if (!KeepRunning
|| !IsConnectedAndLoggedOn
|| (HeartBeatFailures
== byte.MaxValue
)) {
2262 byte connectionTimeout
= ASF
.GlobalConfig
?.ConnectionTimeout
?? GlobalConfig
.DefaultConnectionTimeout
;
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
)) {
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
)) {
2290 ArchiLogger
.LogGenericInfo(Strings
.BotAuthenticatorConverting
);
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
)));
2301 MobileAuthenticator
? authenticator
= json
.ToJsonObject
<MobileAuthenticator
>();
2303 if (authenticator
== null) {
2304 ArchiLogger
.LogNullError(authenticator
);
2309 if (!TryImportAuthenticator(authenticator
)) {
2313 File
.Delete(maFilePath
);
2314 } catch (Exception e
) {
2315 ArchiLogger
.LogGenericException(e
);
2319 private void InitConnectionFailureTimer() {
2320 if (ConnectionFailureTimer
!= null) {
2324 byte connectionTimeout
= ASF
.GlobalConfig
?.ConnectionTimeout
?? GlobalConfig
.DefaultConnectionTimeout
;
2326 ConnectionFailureTimer
= new Timer(
2327 InitPermanentConnectionFailure
,
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)) {
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
)));
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
)));
2388 private async Task
InitModules() {
2390 throw new InvalidOperationException(nameof(Bots
));
2393 AccountFlags
= EAccountFlags
.NormalUser
;
2394 AvatarHash
= IPCountryCode
= Nickname
= null;
2395 MasterChatGroupID
= 0;
2396 RequiredInput
= ASF
.EUserInputType
.None
;
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
;
2419 if (!string.IsNullOrEmpty(refreshTokenText
) && Utilities
.TryReadJsonWebToken(refreshTokenText
, out JsonWebToken
? refreshToken
) && ((refreshToken
.ValidTo
== DateTime
.MinValue
) || (refreshToken
.ValidTo
>= DateTime
.UtcNow
))) {
2420 RefreshToken
= refreshTokenText
;
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(
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(
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) {
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) {
2487 byte minFarmingDelayAfterBlock
= ASF
.GlobalConfig
?.MinFarmingDelayAfterBlock
?? GlobalConfig
.DefaultMinFarmingDelayAfterBlock
;
2489 PlayingWasBlockedTimer
= new Timer(
2490 ResetPlayingWasBlockedWithTimer
,
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();
2507 TimeSpan delay
= validUntil
- DateTime
.UtcNow
;
2509 // Start refreshing token before it's invalid
2510 if (delay
.TotalMinutes
> MinimumAccessTokenValidityMinutes
) {
2511 delay
-= TimeSpan
.FromMinutes(MinimumAccessTokenValidityMinutes
);
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
,
2523 TimeSpan
.FromMilliseconds(dueTime
), // Delay
2524 TimeSpan
.FromMinutes(1) // Period
2527 RefreshTokensTimer
.Change(TimeSpan
.FromMilliseconds(dueTime
), TimeSpan
.FromMinutes(1));
2531 private void InitStart() {
2532 if (!BotConfig
.Enabled
) {
2533 ArchiLogger
.LogGenericWarning(Strings
.BotInstanceNotStartingBecauseDisabled
);
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
) {
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)) {
2577 MasterChatGroupID
= clanChatRoomInfo
.chat_group_summary
.chat_group_id
;
2580 HashSet
<ulong>? chatGroupIDs
= await ArchiHandler
.GetMyChatGroupIDs().ConfigureAwait(false);
2582 if (chatGroupIDs
?.Contains(MasterChatGroupID
) != false) {
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
);
2598 if (ASF
.LoginRateLimitingSemaphore
== null) {
2599 ASF
.ArchiLogger
.LogNullError(ASF
.LoginRateLimitingSemaphore
);
2604 byte loginLimiterDelay
= ASF
.GlobalConfig
?.LoginLimiterDelay
?? GlobalConfig
.DefaultLoginLimiterDelay
;
2606 if (loginLimiterDelay
== 0) {
2607 await ASF
.LoginRateLimitingSemaphore
.WaitAsync().ConfigureAwait(false);
2608 ASF
.LoginRateLimitingSemaphore
.Release();
2613 await ASF
.LoginSemaphore
.WaitAsync().ConfigureAwait(false);
2616 await ASF
.LoginRateLimitingSemaphore
.WaitAsync().ConfigureAwait(false);
2617 ASF
.LoginRateLimitingSemaphore
.Release();
2619 Utilities
.InBackground(
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
);
2638 ArchiLogger
.LogGenericInfo(Strings
.BotDisconnecting
);
2644 if (!await InitLoginAndPassword(string.IsNullOrEmpty(RefreshToken
)).ConfigureAwait(false)) {
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
)));
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
)));
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
2689 ArchiLogger
.LogGenericInfo(Strings
.BotLoggingIn
);
2691 InitConnectionFailureTimer();
2693 if (string.IsNullOrEmpty(RefreshToken
)) {
2694 AuthPollResult pollResult
;
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
,
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();
2720 } catch (OperationCanceledException
) {
2721 // This is okay, we already took care of that and can ignore it here
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();
2739 if (string.IsNullOrEmpty(pollResult
.RefreshToken
)) {
2740 // The fuck is that?
2741 ArchiLogger
.LogNullError(pollResult
.RefreshToken
);
2743 ReconnectOnUserInitiated
= true;
2744 SteamClient
.Disconnect();
2749 UpdateTokens(pollResult
.AccessToken
, pollResult
.RefreshToken
);
2752 SteamUser
.LogOnDetails logOnDetails
= new() {
2753 AccessToken
= RefreshToken
,
2754 CellID
= ASF
.GlobalDatabase
?.CellID
,
2755 ClientLanguage
= CultureInfo
.CurrentCulture
.ToSteamClientLanguage(),
2757 ShouldRememberPassword
= BotConfig
.UseLoginKeys
,
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
) {
2809 switch (lastLogOnResult
) {
2810 case EResult
.AccountDisabled
:
2811 // Do not attempt to reconnect, those failures are permanent
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
);
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)) {
2831 await Task
.Delay(LoginCooldownInMinutes
* 60 * 1000).ConfigureAwait(false);
2833 ASF
.LoginRateLimitingSemaphore
.Release();
2838 // Generic delay before retrying
2839 await Task
.Delay(5000).ConfigureAwait(false);
2844 if (!KeepRunning
|| SteamClient
.IsConnected
) {
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
) {
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);
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);
2886 if (BotConfig
.BotBehaviour
.HasFlag(BotConfig
.EBotBehaviour
.RejectInvalidGroupInvites
)) {
2887 ArchiLogger
.LogInvite(friend
.SteamID
, false);
2889 ArchiHandler
.AcknowledgeClanInvite(friend
.SteamID
, false);
2894 ArchiLogger
.LogInvite(friend
.SteamID
);
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
)));
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
)));
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
)));
2930 ArchiLogger
.LogInvite(friend
.SteamID
);
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
) {
2945 HashSet
<ulong> guestPassIDs
= callback
.GuestPasses
.Select(static guestPass
=> guestPass
["gid"].AsUnsignedLong()).Where(static gid
=> gid
!= 0).ToHashSet();
2947 if (guestPassIDs
.Count
== 0) {
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
);
2963 if (notification
.chat_id
== 0) {
2964 ArchiLogger
.LogNullError(notification
.chat_id
);
2969 if (notification
.steamid_sender
== 0) {
2970 ArchiLogger
.LogNullError(notification
.steamid_sender
);
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
));
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
);
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
)) {
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
);
3015 if ((EChatEntryType
) notification
.chat_entry_type
!= EChatEntryType
.ChatMsg
) {
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
));
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
);
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
)) {
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
)));
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
);
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;
3163 case EResult
.LogonSessionReplaced
:
3164 DateTime now
= DateTime
.UtcNow
;
3166 if (now
.Subtract(LastLogonSessionReplaced
).TotalHours
< 1) {
3167 ArchiLogger
.LogGenericError(Strings
.BotLogonSessionReplaced
);
3173 LastLogonSessionReplaced
= now
;
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
) {
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
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)));
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)));
3261 // Steam parental disabled
3262 SteamParentalActive = false;
3265 ArchiWebHandler.OnVanityURLChanged(callback.VanityURL);
3267 // Establish web session
3268 if (!await RefreshWebSession().ConfigureAwait(false)) {
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(
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) {
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')) {
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);
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);
3373 case "FriendMessagesClient.IncomingMessage#1":
3374 await OnIncomingMessage((CFriendMessages_IncomingMessage_Notification) notification.Body).ConfigureAwait(false);
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)) {
3389 LibraryLocked = false;
3391 if ((callback.LibraryLockedBySteamID == 0) || (callback.LibraryLockedBySteamID == SteamID)) {
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) {
3415 HashSet<UserNotificationsCallback.EUserNotification> newPluginNotifications = [];
3417 foreach ((UserNotificationsCallback.EUserNotification notification, uint count) in callback.Notifications) {
3418 bool newNotification;
3421 newNotification = !PastNotifications.TryGetValue(notification, out uint previousCount) || (count > previousCount);
3422 PastNotifications[notification] = count;
3424 if (newNotification) {
3425 newPluginNotifications.Add(notification);
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);
3439 case UserNotificationsCallback.EUserNotification.Items when newNotification:
3440 OnInventoryChanged();
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);
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)) {
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);
3498 if (string.IsNullOrEmpty(name)) {
3499 ArchiLogger.LogNullError(name);
3504 CStore_RegisterCDKey_Response? response = await Actions.RedeemKey(key).ConfigureAwait(false);
3506 if (response == null) {
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;
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:
3545 case EPurchaseResultDetail.BadActivationCode:
3546 case EPurchaseResultDetail.DuplicateActivationCode:
3547 case EPurchaseResultDetail.NoDetail: // OK
3551 case EPurchaseResultDetail.RateLimited:
3556 ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(purchaseResultDetail), purchaseResultDetail));
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
);
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
));
3592 if (IsConnectedAndLoggedOn
&& BotDatabase
.HasGamesToRedeemInBackground
) {
3593 ArchiLogger
.LogGenericInfo(string.Format(CultureInfo
.CurrentCulture
, Strings
.BotRateLimitExceeded
, TimeSpan
.FromHours(RedeemCooldownInHours
).ToHumanReadable()));
3595 GamesRedeemerInBackgroundTimer
= new Timer(
3596 RedeemGamesInBackground
,
3598 TimeSpan
.FromHours(RedeemCooldownInHours
), // Delay
3599 Timeout
.InfiniteTimeSpan
// Period
3603 ArchiLogger
.LogGenericInfo(Strings
.Done
);
3605 GamesRedeemerInBackgroundSemaphore
.Release();
3609 private async Task
ResetGamesPlayed() {
3610 if (!IsConnectedAndLoggedOn
|| CardsFarmer
.NowFarming
) {
3614 if (BotConfig
.GamesPlayedWhileIdle
.Count
> 0) {
3615 if (!IsPlayingPossible
) {
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
) {
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
) {
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
) {
3658 SendCompleteTypesScheduled
= true;
3661 await SendCompleteTypesSemaphore
.WaitAsync().ConfigureAwait(false);
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)) {
3676 HashSet
<Asset
> inventory
;
3679 inventory
= await ArchiHandler
.GetMyInventoryAsync(tradableOnly
: true)
3680 .Where(item
=> appIDs
.Contains(item
.RealAppID
) && BotConfig
.CompleteTypesToSend
.Contains(item
.Type
))
3682 .ConfigureAwait(false);
3683 } catch (TimeoutException e
) {
3684 ArchiLogger
.LogGenericWarningException(e
);
3687 } catch (Exception e
) {
3688 ArchiLogger
.LogGenericException(e
);
3693 if (inventory
.Count
== 0) {
3694 ArchiLogger
.LogGenericWarning(string.Format(CultureInfo
.CurrentCulture
, Strings
.ErrorIsEmpty
, nameof(inventory
)));
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) {
3708 Dictionary
<uint, byte>? cardsCountPerAppID
= await LoadCardsPerSet(appIDs
).ConfigureAwait(false);
3710 if (cardsCountPerAppID
== null) {
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)
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) {
3739 HashSet
<Asset
> result
= GetItemsForFullSets(inventory
, itemsToTakePerInventorySet
);
3741 if (result
.Count
> 0) {
3742 await Actions
.SendInventory(result
).ConfigureAwait(false);
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
) {
3761 await MessagingSemaphore
.WaitAsync().ConfigureAwait(false);
3764 for (byte i
= 0; (i
< WebBrowser
.MaxTries
) && IsConnectedAndLoggedOn
; i
++) {
3767 if (chatGroupID
== 0) {
3768 result
= await ArchiHandler
.SendMessage(steamID
, messagePart
).ConfigureAwait(false);
3770 result
= await ArchiHandler
.SendMessage(chatGroupID
, steamID
, messagePart
).ConfigureAwait(false);
3774 case EResult
.Blocked
:
3775 // No point in retrying, those failures are permanent
3776 ArchiLogger
.LogGenericWarning(string.Format(CultureInfo
.CurrentCulture
, Strings
.WarningFailedWithError
, result
));
3781 case EResult
.LimitExceeded
:
3782 case EResult
.RateLimitExceeded
:
3783 case EResult
.ServiceUnavailable
:
3784 case EResult
.Timeout
:
3785 await Task
.Delay(5000).ConfigureAwait(false);
3791 ArchiLogger
.LogGenericError(string.Format(CultureInfo
.CurrentCulture
, Strings
.WarningUnknownValuePleaseReport
, nameof(result
), result
));
3799 MessagingSemaphore
.Release();
3803 private bool ShouldAckChatMessage(ulong steamID
) {
3804 if ((steamID
== 0) || !new SteamID(steamID
).IsIndividualAccount
) {
3805 throw new ArgumentOutOfRangeException(nameof(steamID
));
3809 throw new InvalidOperationException(nameof(Bots
));
3812 if (BotConfig
.BotBehaviour
.HasFlag(BotConfig
.EBotBehaviour
.MarkReceivedMessagesAsRead
)) {
3816 return BotConfig
.BotBehaviour
.HasFlag(BotConfig
.EBotBehaviour
.MarkBotMessagesAsRead
) && Bots
.Values
.Any(bot
=> bot
.SteamID
== steamID
);
3819 private void StopConnectionFailureTimer() {
3820 if (ConnectionFailureTimer
== null) {
3824 ConnectionFailureTimer
.Dispose();
3825 ConnectionFailureTimer
= null;
3828 private void StopPlayingWasBlockedTimer() {
3829 if (PlayingWasBlockedTimer
== null) {
3833 PlayingWasBlockedTimer
.Dispose();
3834 PlayingWasBlockedTimer
= null;
3837 private void StopRefreshTokensTimer() {
3838 if (RefreshTokensTimer
== null) {
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
);
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
) {
3887 steamParentalHashingMethod
= ArchiCryptoHelper
.EHashingMethod
.Pbkdf2
;
3891 steamParentalHashingMethod
= ArchiCryptoHelper
.EHashingMethod
.SCrypt
;
3895 ArchiLogger
.LogGenericError(string.Format(CultureInfo
.CurrentCulture
, Strings
.WarningUnknownValuePleaseReport
, nameof(settings
.passwordhashtype
), settings
.passwordhashtype
));
3897 return (true, null);
3900 if (!string.IsNullOrEmpty(steamParentalCode
)) {
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 {