From 2aab56b7750429626b9bf529a2424ad5f92e2330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Domeradzki?= Date: Fri, 18 Jun 2021 19:50:14 +0200 Subject: [PATCH] Rewrite SendMessage() functions to account for new rate-limits (#2335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rewrite SendMessage() functions to account for new rate-limits * Refactor new message splitting logic into SteamChatMessage.cs This makes it ready for unit tests * Change the concept into UTF8-oriented logic * Misc * Add fix for another unit test * Update * Fix failing test I SPENT HOURS ON THIS * Misc * Misc * Add additional unit tests ensuring this works as designed * Misc * Misc * Add one more unit test * Rework the logic to account for new findings * Misc * Add unit test verifying exception on too long prefix * Address first @Abrynos concern Because we can * Throw also on too long prefix in regards to newlines * Correct wrong bytesRead calculation This worked previously only because we actually never had enough of room for escaped chars anyway and skipped over (2 + 2 missing bytes was smaller than 5 required to make a line) * Add unit test verifying if calculation was done properly * Address @Ryzhehvost concern * Handle empty newlines in the message properly * Misc No reason to even calculate utf8 bytes for empty lines * Misc * Add unit test verifying the reserved escape message bytes count * Correct calculation of lines by taking into account \r * Update ArchiSteamFarm/Steam/Bot.cs Co-authored-by: Sebastian Göls <6608231+Abrynos@users.noreply.github.com> * @Abrynos next time check if it compiles without warnings * Update SteamChatMessage.cs * Apply @Abrynos idea in a different way * Rewrite bot part to remove unnecessary delegate * Add @Ryzhehvost test * Add debug output * Extend @Ryzhehvost test for prefix * Misc * Misc refactor * Misc * Misc * Add logic for limited accounts, correct for unlimited Thanks @Ryzhehvost * Misc Co-authored-by: Sebastian Göls <6608231+Abrynos@users.noreply.github.com> --- ArchiSteamFarm.Tests/SteamChatMessage.cs | 332 ++++++++++++++++++ ArchiSteamFarm/Steam/Bot.cs | 222 ++++-------- .../Steam/Integration/SteamChatMessage.cs | 204 +++++++++++ ArchiSteamFarm/Storage/GlobalConfig.cs | 4 +- 4 files changed, 599 insertions(+), 163 deletions(-) create mode 100644 ArchiSteamFarm.Tests/SteamChatMessage.cs create mode 100644 ArchiSteamFarm/Steam/Integration/SteamChatMessage.cs diff --git a/ArchiSteamFarm.Tests/SteamChatMessage.cs b/ArchiSteamFarm.Tests/SteamChatMessage.cs new file mode 100644 index 000000000..3503cb978 --- /dev/null +++ b/ArchiSteamFarm.Tests/SteamChatMessage.cs @@ -0,0 +1,332 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2021 Łukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// | +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// | +// http://www.apache.org/licenses/LICENSE-2.0 +// | +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using static ArchiSteamFarm.Steam.Integration.SteamChatMessage; + +namespace ArchiSteamFarm.Tests { + [TestClass] + public sealed class SteamChatMessage { + [TestMethod] + public async Task CanSplitEvenWithStupidlyLongPrefix() { + string prefix = new('x', MaxMessagePrefixBytes); + + const string emoji = "😎"; + const string message = emoji + emoji + emoji + emoji; + + List output = await GetMessageParts(message, prefix, true).ToListAsync().ConfigureAwait(false); + + Assert.AreEqual(4, output.Count); + + Assert.AreEqual(prefix + emoji + ContinuationCharacter, output[0]); + Assert.AreEqual(prefix + ContinuationCharacter + emoji + ContinuationCharacter, output[1]); + Assert.AreEqual(prefix + ContinuationCharacter + emoji + ContinuationCharacter, output[2]); + Assert.AreEqual(prefix + ContinuationCharacter + emoji, output[3]); + } + + [TestMethod] + public void ContinuationCharacterSizeIsProperlyCalculated() => Assert.AreEqual(ContinuationCharacterBytes, Encoding.UTF8.GetByteCount(ContinuationCharacter.ToString())); + + [TestMethod] + public async Task DoesntSkipEmptyNewlines() { + string message = "asdf" + Environment.NewLine + Environment.NewLine + "asdf"; + + List output = await GetMessageParts(message).ToListAsync().ConfigureAwait(false); + + Assert.AreEqual(1, output.Count); + Assert.AreEqual(message, output.First()); + } + + [DataRow(false)] + [DataRow(true)] + [DataTestMethod] + public async Task DoesntSplitInTheMiddleOfMultiByteChar(bool isAccountLimited) { + int maxMessageBytes = isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts; + int longLineLength = maxMessageBytes - ReservedContinuationMessageBytes; + + const string emoji = "😎"; + + string longSequence = new('a', longLineLength - 1); + string message = longSequence + emoji; + + List output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false); + + Assert.AreEqual(2, output.Count); + + Assert.AreEqual(longSequence + ContinuationCharacter, output[0]); + Assert.AreEqual(ContinuationCharacter + emoji, output[1]); + } + + [TestMethod] + public async Task DoesntSplitJustBecauseOfLastEscapableCharacter() { + const string message = "abcdef["; + const string escapedMessage = @"abcdef\["; + + List output = await GetMessageParts(message).ToListAsync().ConfigureAwait(false); + + Assert.AreEqual(1, output.Count); + Assert.AreEqual(escapedMessage, output.First()); + } + + [DataRow(false)] + [DataRow(true)] + [DataTestMethod] + public async Task DoesntSplitOnBackslashNotUsedForEscaping(bool isAccountLimited) { + int maxMessageBytes = isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts; + int longLineLength = maxMessageBytes - ReservedContinuationMessageBytes; + + string longLine = new('a', longLineLength - 2); + string message = longLine + @"\"; + + List output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false); + + Assert.AreEqual(1, output.Count); + Assert.AreEqual(message + @"\", output.First()); + } + + [DataRow(false)] + [DataRow(true)] + [DataTestMethod] + public async Task DoesntSplitOnEscapeCharacter(bool isAccountLimited) { + int maxMessageBytes = isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts; + int longLineLength = maxMessageBytes - ReservedContinuationMessageBytes; + + string longLine = new('a', longLineLength - 1); + string message = longLine + "["; + + List output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false); + + Assert.AreEqual(2, output.Count); + + Assert.AreEqual(longLine + ContinuationCharacter, output[0]); + Assert.AreEqual(ContinuationCharacter + @"\[", output[1]); + } + + [TestMethod] + public async Task NoNeedForAnySplittingWithNewlines() { + string message = "abcdef" + Environment.NewLine + "ghijkl" + Environment.NewLine + "mnopqr"; + + List output = await GetMessageParts(message).ToListAsync().ConfigureAwait(false); + + Assert.AreEqual(1, output.Count); + Assert.AreEqual(message, output.First()); + } + + [TestMethod] + public async Task NoNeedForAnySplittingWithoutNewlines() { + const string message = "abcdef"; + + List output = await GetMessageParts(message).ToListAsync().ConfigureAwait(false); + + Assert.AreEqual(1, output.Count); + Assert.AreEqual(message, output.First()); + } + + [TestMethod] + public async Task ProperlyEscapesCharacters() { + const string message = @"[b]bold[/b] \n"; + const string escapedMessage = @"\[b]bold\[/b] \\n"; + + List output = await GetMessageParts(message).ToListAsync().ConfigureAwait(false); + + Assert.AreEqual(1, output.Count); + Assert.AreEqual(escapedMessage, output.First()); + } + + [TestMethod] + public async Task ProperlyEscapesSteamMessagePrefix() { + const string prefix = "/pre []"; + const string escapedPrefix = @"/pre \[]"; + + const string message = "asdf"; + + List output = await GetMessageParts(message, prefix).ToListAsync().ConfigureAwait(false); + + Assert.AreEqual(1, output.Count); + Assert.AreEqual(escapedPrefix + message, output.First()); + } + + [DataRow(false)] + [DataRow(true)] + [DataTestMethod] + public async Task ProperlySplitsLongSingleLine(bool isAccountLimited) { + int maxMessageBytes = isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts; + int longLineLength = maxMessageBytes - ReservedContinuationMessageBytes; + + string longLine = new('a', longLineLength); + string message = longLine + longLine + longLine + longLine; + + List output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false); + + Assert.AreEqual(4, output.Count); + + Assert.AreEqual(longLine + ContinuationCharacter, output[0]); + Assert.AreEqual(ContinuationCharacter + longLine + ContinuationCharacter, output[1]); + Assert.AreEqual(ContinuationCharacter + longLine + ContinuationCharacter, output[2]); + Assert.AreEqual(ContinuationCharacter + longLine, output[3]); + } + + [TestMethod] + public void ReservedSizeForEscapingIsProperlyCalculated() => Assert.AreEqual(ReservedEscapeMessageBytes, Encoding.UTF8.GetByteCount(@"\") + 4); // Maximum amount of bytes per single UTF-8 character is 4, not 6 as from Encoding.UTF8.GetMaxByteCount(1) + + [TestMethod] + public async Task RyzhehvostInitialTestForSplitting() { + const string prefix = "/me "; + + const string message = @" Уже имеет: app/1493800 | Aircraft Carrier Survival: Prolouge + Уже имеет: app/349520 | Armillo + Уже имеет: app/346330 | BrainBread 2 + Уже имеет: app/1086690 | C-War 2 + Уже имеет: app/730 | Counter-Strike: Global Offensive + Уже имеет: app/838380 | DEAD OR ALIVE 6 + Уже имеет: app/582890 | Estranged: The Departure + Уже имеет: app/331470 | Everlasting Summer + Уже имеет: app/1078000 | Gamecraft + Уже имеет: app/266310 | GameGuru + Уже имеет: app/275390 | Guacamelee! Super Turbo Championship Edition + Уже имеет: app/627690 | Idle Champions of the Forgotten Realms + Уже имеет: app/1048540 | Kao the Kangaroo: Round 2 + Уже имеет: app/370910 | Kathy Rain + Уже имеет: app/343710 | KHOLAT + Уже имеет: app/253900 | Knights and Merchants + Уже имеет: app/224260 | No More Room in Hell + Уже имеет: app/343360 | Particula + Уже имеет: app/237870 | Planet Explorers + Уже имеет: app/684680 | Polygoneer + Уже имеет: app/1089130 | Quake II RTX + Уже имеет: app/755790 | Ring of Elysium + Уже имеет: app/1258080 | Shop Titans + Уже имеет: app/759530 | Struckd - 3D Game Creator + Уже имеет: app/269710 | Tumblestone + Уже имеет: app/304930 | Unturned + Уже имеет: app/1019250 | WWII TCG - World War 2: The Card Game + + 1/1 ботов уже имеют игру app/1493800 | Aircraft Carrier Survival: Prolouge. + 1/1 ботов уже имеют игру app/349520 | Armillo. + 1/1 ботов уже имеют игру app/346330 | BrainBread 2. + 1/1 ботов уже имеют игру app/1086690 | C-War 2. + 1/1 ботов уже имеют игру app/730 | Counter-Strike: Global Offensive. + 1/1 ботов уже имеют игру app/838380 | DEAD OR ALIVE 6. + 1/1 ботов уже имеют игру app/582890 | Estranged: The Departure. + 1/1 ботов уже имеют игру app/331470 | Everlasting Summer. + 1/1 ботов уже имеют игру app/1078000 | Gamecraft. + 1/1 ботов уже имеют игру app/266310 | GameGuru. + 1/1 ботов уже имеют игру app/275390 | Guacamelee! Super Turbo Championship Edition. + 1/1 ботов уже имеют игру app/627690 | Idle Champions of the Forgotten Realms. + 1/1 ботов уже имеют игру app/1048540 | Kao the Kangaroo: Round 2. + 1/1 ботов уже имеют игру app/370910 | Kathy Rain. + 1/1 ботов уже имеют игру app/343710 | KHOLAT. + 1/1 ботов уже имеют игру app/253900 | Knights and Merchants. + 1/1 ботов уже имеют игру app/224260 | No More Room in Hell. + 1/1 ботов уже имеют игру app/343360 | Particula. + 1/1 ботов уже имеют игру app/237870 | Planet Explorers. + 1/1 ботов уже имеют игру app/684680 | Polygoneer. + 1/1 ботов уже имеют игру app/1089130 | Quake II RTX. + 1/1 ботов уже имеют игру app/755790 | Ring of Elysium. + 1/1 ботов уже имеют игру app/1258080 | Shop Titans. + 1/1 ботов уже имеют игру app/759530 | Struckd - 3D Game Creator. + 1/1 ботов уже имеют игру app/269710 | Tumblestone. + 1/1 ботов уже имеют игру app/304930 | Unturned."; + + List output = await GetMessageParts(message, prefix).ToListAsync().ConfigureAwait(false); + + Assert.AreEqual(2, output.Count); + + foreach (string messagePart in output) { + if ((messagePart.Length <= prefix.Length) || !messagePart.StartsWith(prefix, StringComparison.Ordinal)) { + Assert.Fail(); + + return; + } + + string[] lines = messagePart.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); + + int bytes = lines.Where(line => line.Length > 0).Sum(Encoding.UTF8.GetByteCount) + ((lines.Length - 1) * NewlineWeight); + + if (bytes > MaxMessageBytesForUnlimitedAccounts) { + Assert.Fail(); + + return; + } + } + } + + [DataRow(false)] + [DataRow(true)] + [DataTestMethod] + public async Task SplitsOnNewlinesWithoutContinuationCharacter(bool isAccountLimited) { + int maxMessageBytes = isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts; + + StringBuilder newlinePartBuilder = new(); + + for (ushort bytes = 0; bytes < maxMessageBytes - ReservedContinuationMessageBytes - NewlineWeight;) { + if (newlinePartBuilder.Length > 0) { + bytes += NewlineWeight; + newlinePartBuilder.Append(Environment.NewLine); + } + + bytes++; + newlinePartBuilder.Append('a'); + } + + string newlinePart = newlinePartBuilder.ToString(); + string message = newlinePart + Environment.NewLine + newlinePart + Environment.NewLine + newlinePart + Environment.NewLine + newlinePart; + + List output = await GetMessageParts(message, isAccountLimited: isAccountLimited).ToListAsync().ConfigureAwait(false); + + Assert.AreEqual(4, output.Count); + + Assert.AreEqual(newlinePart, output[0]); + Assert.AreEqual(newlinePart, output[1]); + Assert.AreEqual(newlinePart, output[2]); + Assert.AreEqual(newlinePart, output[3]); + } + + [ExpectedException(typeof(ArgumentOutOfRangeException))] + [TestMethod] + public async Task ThrowsOnTooLongNewlinesPrefix() { + string prefix = new('\n', (MaxMessagePrefixBytes / NewlineWeight) + 1); + + const string message = "asdf"; + + await GetMessageParts(message, prefix).ToListAsync().ConfigureAwait(false); + + Assert.Fail(); + } + + [ExpectedException(typeof(ArgumentOutOfRangeException))] + [TestMethod] + public async Task ThrowsOnTooLongPrefix() { + string prefix = new('x', MaxMessagePrefixBytes + 1); + + const string message = "asdf"; + + await GetMessageParts(message, prefix).ToListAsync().ConfigureAwait(false); + + Assert.Fail(); + } + } +} diff --git a/ArchiSteamFarm/Steam/Bot.cs b/ArchiSteamFarm/Steam/Bot.cs index 1a404be0d..4b94e2b8a 100644 --- a/ArchiSteamFarm/Steam/Bot.cs +++ b/ArchiSteamFarm/Steam/Bot.cs @@ -64,7 +64,6 @@ using SteamKit2.Internal; namespace ArchiSteamFarm.Steam { public sealed class Bot : IAsyncDisposable { internal const ushort CallbackSleep = 500; // In milliseconds - internal const ushort MaxMessagePrefixLength = MaxMessageLength - ReservedMessageLength - 2; // 2 for a minimum of 2 characters (escape one and real one) internal const byte MinCardsPerBadge = 5; internal const byte MinPlayingBlockedTTL = 60; // Delay in seconds added when account was occupied during our disconnect, to not disconnect other Steam client session too soon @@ -72,10 +71,8 @@ namespace ArchiSteamFarm.Steam { private const byte LoginCooldownInMinutes = 25; // Captcha disappears after around 20 minutes, so we make it 25 private const uint LoginID = 1242; // This must be the same for all ASF bots and all ASF processes private const byte MaxInvalidPasswordFailures = WebBrowser.MaxTries; // Max InvalidPassword failures in a row before we determine that our password is invalid (because Steam wrongly returns those, of course) - private const ushort MaxMessageLength = 5000; // This is a limitation enforced by Steam private const byte MaxTwoFactorCodeFailures = WebBrowser.MaxTries; // Max TwoFactorCodeMismatch failures in a row before we determine that our 2FA credentials are invalid (because Steam wrongly returns those, of course) private const byte RedeemCooldownInHours = 1; // 1 hour since first redeem attempt, this is a limitation enforced by Steam - private const byte ReservedMessageLength = 2; // 2 for 2x optional … [PublicAPI] public static IReadOnlyDictionary? BotsReadOnly => Bots; @@ -773,82 +770,15 @@ namespace ArchiSteamFarm.Steam { ArchiLogger.LogChatMessage(true, message, steamID: steamID); string? steamMessagePrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.SteamMessagePrefix : GlobalConfig.DefaultSteamMessagePrefix; - ushort maxMessageLength = (ushort) (MaxMessageLength - ReservedMessageLength - (steamMessagePrefix?.Length ?? 0)); - // We must escape our message prior to sending it - message = Escape(message); + await foreach (string messagePart in SteamChatMessage.GetMessageParts(message, steamMessagePrefix, IsAccountLimited).ConfigureAwait(false)) { + ArchiLogger.LogGenericDebug(messagePart); - int i = 0; + if (!await SendMessagePart(steamID, messagePart).ConfigureAwait(false)) { + ArchiLogger.LogGenericWarning(Strings.WarningFailed); - while (i < message.Length) { - int partLength; - bool copyNewline = false; - - if (message.Length - i > maxMessageLength) { - int lastNewLine = message.LastIndexOf(Environment.NewLine, (i + maxMessageLength) - Environment.NewLine.Length, maxMessageLength - Environment.NewLine.Length, StringComparison.Ordinal); - - if (lastNewLine > i) { - partLength = (lastNewLine - i) + Environment.NewLine.Length; - copyNewline = true; - } else { - partLength = maxMessageLength; - } - } else { - partLength = message.Length - i; + return false; } - - // If our message is of max length and ends with a single '\' then we can't split it here, it escapes the next character - if ((partLength >= maxMessageLength) && (message[(i + partLength) - 1] == '\\') && (message[(i + partLength) - 2] != '\\')) { - // Instead, we'll cut this message one char short and include the rest in next iteration - partLength--; - } - - string messagePart = message.Substring(i, partLength); - - messagePart = steamMessagePrefix + (i > 0 ? "…" : "") + messagePart + (maxMessageLength < message.Length - i ? "…" : ""); - - await MessagingSemaphore.WaitAsync().ConfigureAwait(false); - - try { - bool sent = false; - - for (byte j = 0; (j < WebBrowser.MaxTries) && !sent && IsConnectedAndLoggedOn; j++) { - // We add a one-second delay here to avoid Steam screwup in form of a ghost notification - // The exact cause is unknown, but the theory is that Steam is confused when dealing with more than 1 message per second from the same user - await Task.Delay(1000).ConfigureAwait(false); - - EResult result = await ArchiHandler.SendMessage(steamID, messagePart).ConfigureAwait(false); - - switch (result) { - case EResult.Busy: - case EResult.Fail: - case EResult.RateLimitExceeded: - case EResult.ServiceUnavailable: - case EResult.Timeout: - await Task.Delay(5000).ConfigureAwait(false); - - continue; - case EResult.OK: - sent = true; - - break; - default: - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(result), result)); - - return false; - } - } - - if (!sent) { - ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return false; - } - } finally { - MessagingSemaphore.Release(); - } - - i += partLength - (copyNewline ? Environment.NewLine.Length : 0); } return true; @@ -875,78 +805,15 @@ namespace ArchiSteamFarm.Steam { ArchiLogger.LogChatMessage(true, message, chatGroupID, chatID); string? steamMessagePrefix = ASF.GlobalConfig != null ? ASF.GlobalConfig.SteamMessagePrefix : GlobalConfig.DefaultSteamMessagePrefix; - ushort maxMessageLength = (ushort) (MaxMessageLength - ReservedMessageLength - (steamMessagePrefix?.Length ?? 0)); - // We must escape our message prior to sending it - message = Escape(message); + await foreach (string messagePart in SteamChatMessage.GetMessageParts(message, steamMessagePrefix, IsAccountLimited).ConfigureAwait(false)) { + ArchiLogger.LogGenericDebug(messagePart); - int i = 0; + if (!await SendMessagePart(chatID, messagePart, chatGroupID).ConfigureAwait(false)) { + ArchiLogger.LogGenericWarning(Strings.WarningFailed); - while (i < message.Length) { - int partLength; - bool copyNewline = false; - - if (message.Length - i > maxMessageLength) { - int lastNewLine = message.LastIndexOf(Environment.NewLine, (i + maxMessageLength) - Environment.NewLine.Length, maxMessageLength - Environment.NewLine.Length, StringComparison.Ordinal); - - if (lastNewLine > i) { - partLength = (lastNewLine - i) + Environment.NewLine.Length; - copyNewline = true; - } else { - partLength = maxMessageLength; - } - } else { - partLength = message.Length - i; + return false; } - - // If our message is of max length and ends with a single '\' then we can't split it here, it escapes the next character - if ((partLength >= maxMessageLength) && (message[(i + partLength) - 1] == '\\') && (message[(i + partLength) - 2] != '\\')) { - // Instead, we'll cut this message one char short and include the rest in next iteration - partLength--; - } - - string messagePart = message.Substring(i, partLength); - - messagePart = steamMessagePrefix + (i > 0 ? "…" : "") + messagePart + (maxMessageLength < message.Length - i ? "…" : ""); - - await MessagingSemaphore.WaitAsync().ConfigureAwait(false); - - try { - bool sent = false; - - for (byte j = 0; (j < WebBrowser.MaxTries) && !sent && IsConnectedAndLoggedOn; j++) { - EResult result = await ArchiHandler.SendMessage(chatGroupID, chatID, messagePart).ConfigureAwait(false); - - switch (result) { - case EResult.Busy: - case EResult.Fail: - case EResult.RateLimitExceeded: - case EResult.ServiceUnavailable: - case EResult.Timeout: - await Task.Delay(5000).ConfigureAwait(false); - - continue; - case EResult.OK: - sent = true; - - break; - default: - ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(result), result)); - - return false; - } - } - - if (!sent) { - ArchiLogger.LogGenericWarning(Strings.WarningFailed); - - return false; - } - } finally { - MessagingSemaphore.Release(); - } - - i += partLength - (copyNewline ? Environment.NewLine.Length : 0); } return true; @@ -1874,14 +1741,6 @@ namespace ArchiSteamFarm.Steam { SteamClient.Disconnect(); } - private static string Escape(string message) { - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } - - return message.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("[", "\\[", StringComparison.Ordinal); - } - private async Task?> GetKeysFromFile(string filePath) { if (string.IsNullOrEmpty(filePath)) { throw new ArgumentNullException(nameof(filePath)); @@ -2608,7 +2467,7 @@ namespace ArchiSteamFarm.Steam { if (!string.IsNullOrEmpty(notification.message_no_bbcode)) { message = notification.message_no_bbcode; } else if (!string.IsNullOrEmpty(notification.message)) { - message = UnEscape(notification.message); + message = SteamChatMessage.Unescape(notification.message); } else { return; } @@ -2654,7 +2513,7 @@ namespace ArchiSteamFarm.Steam { if (!string.IsNullOrEmpty(notification.message_no_bbcode)) { message = notification.message_no_bbcode; } else if (!string.IsNullOrEmpty(notification.message)) { - message = UnEscape(notification.message); + message = SteamChatMessage.Unescape(notification.message); } else { return; } @@ -3471,6 +3330,55 @@ namespace ArchiSteamFarm.Steam { } } + private async Task SendMessagePart(ulong steamID, string messagePart, ulong chatGroupID = 0) { + if ((steamID == 0) || ((chatGroupID == 0) && !new SteamID(steamID).IsIndividualAccount)) { + throw new ArgumentOutOfRangeException(nameof(steamID)); + } + + if (string.IsNullOrEmpty(messagePart)) { + throw new ArgumentNullException(nameof(messagePart)); + } + + if (!IsConnectedAndLoggedOn) { + return false; + } + + await MessagingSemaphore.WaitAsync().ConfigureAwait(false); + + try { + for (byte i = 0; (i < WebBrowser.MaxTries) && IsConnectedAndLoggedOn; i++) { + EResult result; + + if (chatGroupID == 0) { + result = await ArchiHandler.SendMessage(steamID, messagePart).ConfigureAwait(false); + } else { + result = await ArchiHandler.SendMessage(chatGroupID, steamID, messagePart).ConfigureAwait(false); + } + + switch (result) { + case EResult.Busy: + case EResult.Fail: + case EResult.RateLimitExceeded: + case EResult.ServiceUnavailable: + case EResult.Timeout: + await Task.Delay(5000).ConfigureAwait(false); + + continue; + case EResult.OK: + return true; + default: + ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.WarningUnknownValuePleaseReport, nameof(result), result)); + + return false; + } + } + + return false; + } finally { + MessagingSemaphore.Release(); + } + } + private bool ShouldAckChatMessage(ulong steamID) { if ((steamID == 0) || !new SteamID(steamID).IsIndividualAccount) { throw new ArgumentOutOfRangeException(nameof(steamID)); @@ -3505,14 +3413,6 @@ namespace ArchiSteamFarm.Steam { PlayingWasBlockedTimer = null; } - private static string UnEscape(string message) { - if (string.IsNullOrEmpty(message)) { - throw new ArgumentNullException(nameof(message)); - } - - return message.Replace("\\[", "[", StringComparison.Ordinal).Replace("\\\\", "\\", StringComparison.Ordinal); - } - private (bool IsSteamParentalEnabled, string? SteamParentalCode) ValidateSteamParental(ParentalSettings settings, string? steamParentalCode = null) { if (settings == null) { throw new ArgumentNullException(nameof(settings)); diff --git a/ArchiSteamFarm/Steam/Integration/SteamChatMessage.cs b/ArchiSteamFarm/Steam/Integration/SteamChatMessage.cs new file mode 100644 index 000000000..97af2c88a --- /dev/null +++ b/ArchiSteamFarm/Steam/Integration/SteamChatMessage.cs @@ -0,0 +1,204 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2021 Łukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// | +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// | +// http://www.apache.org/licenses/LICENSE-2.0 +// | +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if NETFRAMEWORK +using ArchiSteamFarm.Compatibility; +#endif +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace ArchiSteamFarm.Steam.Integration { + internal static class SteamChatMessage { + internal const char ContinuationCharacter = '…'; // A character used for indicating that the next newline part is a continuation of the previous line + internal const byte ContinuationCharacterBytes = 3; // The continuation character specified above uses 3 bytes in UTF-8 + internal const ushort MaxMessageBytesForLimitedAccounts = 2400; // This is a limitation enforced by Steam + internal const ushort MaxMessageBytesForUnlimitedAccounts = 6340; // This is a limitation enforced by Steam + internal const ushort MaxMessagePrefixBytes = MaxMessageBytesForLimitedAccounts - ReservedContinuationMessageBytes - ReservedEscapeMessageBytes; // Simplified calculation, nobody should be using prefixes even close to that anyway + internal const byte NewlineWeight = 61; // This defines how much weight a newline character is adding to the output, limitation enforced by Steam + internal const byte ReservedContinuationMessageBytes = ContinuationCharacterBytes * 2; // Up to 2 optional continuation characters + internal const byte ReservedEscapeMessageBytes = 5; // 2 characters total, escape one '\' of 1 byte and real one of up to 4 bytes + + internal static async IAsyncEnumerable GetMessageParts(string message, string? steamMessagePrefix = null, bool isAccountLimited = false) { + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + int prefixBytes = 0; + int prefixLength = 0; + + if (!string.IsNullOrEmpty(steamMessagePrefix)) { + // We must escape our message prefix if needed + steamMessagePrefix = Escape(steamMessagePrefix!); + + prefixBytes = GetMessagePrefixBytes(steamMessagePrefix); + + if (prefixBytes > MaxMessagePrefixBytes) { + throw new ArgumentOutOfRangeException(nameof(steamMessagePrefix)); + } + + prefixLength = steamMessagePrefix.Length; + } + + int maxMessageBytes = (isAccountLimited ? MaxMessageBytesForLimitedAccounts : MaxMessageBytesForUnlimitedAccounts) - ReservedContinuationMessageBytes; + + // We must escape our message prior to sending it + message = Escape(message); + + int messagePartBytes = prefixBytes; + StringBuilder messagePart = new(steamMessagePrefix); + + Decoder decoder = Encoding.UTF8.GetDecoder(); + ArrayPool charPool = ArrayPool.Shared; + + using StringReader stringReader = new(message); + + string? line; + + while ((line = await stringReader.ReadLineAsync().ConfigureAwait(false)) != null) { + // Special case for empty newline + if (line.Length == 0) { + if (messagePart.Length > prefixLength) { + messagePartBytes += NewlineWeight; + messagePart.AppendLine(); + } + + // Check if we reached the limit for one message + if (messagePartBytes + NewlineWeight + ReservedEscapeMessageBytes > maxMessageBytes) { + yield return messagePart.ToString(); + + messagePartBytes = prefixBytes; + messagePart.Clear(); + messagePart.Append(steamMessagePrefix); + } + + // Move on to the next line + continue; + } + + byte[] lineBytes = Encoding.UTF8.GetBytes(line); + + for (int lineBytesRead = 0; lineBytesRead < lineBytes.Length;) { + if (messagePart.Length > prefixLength) { + messagePartBytes += NewlineWeight; + messagePart.AppendLine(); + } + + int bytesToTake = Math.Min(maxMessageBytes - messagePartBytes, lineBytes.Length - lineBytesRead); + + // We can never have more characters than bytes used, so this covers the worst case of 1-byte characters exclusively + char[] lineChunk = charPool.Rent(bytesToTake); + + try { + // We have to reset the decoder prior to using it, as we must discard any amount of bytes read from previous incomplete character + decoder.Reset(); + + int charsUsed = decoder.GetChars(lineBytes, lineBytesRead, bytesToTake, lineChunk, 0, false); + + switch (charsUsed) { + case <= 0: + throw new InvalidOperationException(nameof(charsUsed)); + case >= 2 when (lineChunk[charsUsed - 1] == '\\') && (lineChunk[charsUsed - 2] != '\\'): + // If our message is of max length and ends with a single '\' then we can't split it here, because it escapes the next character + // Instead, we'll cut this message one char short and include the rest in the next iteration + charsUsed--; + + break; + } + + int bytesUsed = Encoding.UTF8.GetByteCount(lineChunk, 0, charsUsed); + + if (lineBytesRead > 0) { + messagePartBytes += ContinuationCharacterBytes; + messagePart.Append(ContinuationCharacter); + } + + lineBytesRead += bytesUsed; + + messagePartBytes += bytesUsed; + messagePart.Append(lineChunk, 0, charsUsed); + } finally { + charPool.Return(lineChunk); + } + + if (lineBytesRead < lineBytes.Length) { + messagePartBytes += ContinuationCharacterBytes; + messagePart.Append(ContinuationCharacter); + } + + // Check if we still have room for one more line + if (messagePartBytes + NewlineWeight + ReservedEscapeMessageBytes <= maxMessageBytes) { + continue; + } + + yield return messagePart.ToString(); + + messagePartBytes = prefixBytes; + messagePart.Clear(); + messagePart.Append(steamMessagePrefix); + } + } + + if (messagePart.Length <= prefixLength) { + yield break; + } + + yield return messagePart.ToString(); + } + + internal static bool IsValidPrefix(string steamMessagePrefix) { + if (string.IsNullOrEmpty(steamMessagePrefix)) { + throw new ArgumentNullException(nameof(steamMessagePrefix)); + } + + return GetMessagePrefixBytes(Escape(steamMessagePrefix)) <= MaxMessagePrefixBytes; + } + + internal static string Unescape(string message) { + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + return message.Replace("\\[", "[", StringComparison.Ordinal).Replace("\\\\", "\\", StringComparison.Ordinal); + } + + private static string Escape(string message) { + if (string.IsNullOrEmpty(message)) { + throw new ArgumentNullException(nameof(message)); + } + + return message.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("[", "\\[", StringComparison.Ordinal); + } + + private static int GetMessagePrefixBytes(string escapedSteamMessagePrefix) { + if (string.IsNullOrEmpty(escapedSteamMessagePrefix)) { + throw new ArgumentNullException(nameof(escapedSteamMessagePrefix)); + } + + string[] prefixLines = escapedSteamMessagePrefix.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); + + return prefixLines.Where(prefixLine => prefixLine.Length > 0).Sum(Encoding.UTF8.GetByteCount) + ((prefixLines.Length - 1) * NewlineWeight); + } + } +} diff --git a/ArchiSteamFarm/Storage/GlobalConfig.cs b/ArchiSteamFarm/Storage/GlobalConfig.cs index 5282d10d0..b6cd88976 100644 --- a/ArchiSteamFarm/Storage/GlobalConfig.cs +++ b/ArchiSteamFarm/Storage/GlobalConfig.cs @@ -30,7 +30,7 @@ using System.Threading.Tasks; using ArchiSteamFarm.Core; using ArchiSteamFarm.Helpers; using ArchiSteamFarm.Localization; -using ArchiSteamFarm.Steam; +using ArchiSteamFarm.Steam.Integration; using JetBrains.Annotations; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -319,7 +319,7 @@ namespace ArchiSteamFarm.Storage { return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(OptimizationMode), OptimizationMode)); } - if (!string.IsNullOrEmpty(SteamMessagePrefix) && (SteamMessagePrefix!.Length > Bot.MaxMessagePrefixLength)) { + if (!string.IsNullOrEmpty(SteamMessagePrefix) && !SteamChatMessage.IsValidPrefix(SteamMessagePrefix!)) { return (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(SteamMessagePrefix), SteamMessagePrefix)); }