Rewrite SendMessage() functions to account for new rate-limits (#2335)

* 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>
This commit is contained in:
Łukasz Domeradzki 2021-06-18 19:50:14 +02:00 committed by GitHub
parent 4367134380
commit 2aab56b775
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 599 additions and 163 deletions

View file

@ -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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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 = @"<XLimited5> Уже имеет: app/1493800 | Aircraft Carrier Survival: Prolouge
<XLimited5> Уже имеет: app/349520 | Armillo
<XLimited5> Уже имеет: app/346330 | BrainBread 2
<XLimited5> Уже имеет: app/1086690 | C-War 2
<XLimited5> Уже имеет: app/730 | Counter-Strike: Global Offensive
<XLimited5> Уже имеет: app/838380 | DEAD OR ALIVE 6
<XLimited5> Уже имеет: app/582890 | Estranged: The Departure
<XLimited5> Уже имеет: app/331470 | Everlasting Summer
<XLimited5> Уже имеет: app/1078000 | Gamecraft
<XLimited5> Уже имеет: app/266310 | GameGuru
<XLimited5> Уже имеет: app/275390 | Guacamelee! Super Turbo Championship Edition
<XLimited5> Уже имеет: app/627690 | Idle Champions of the Forgotten Realms
<XLimited5> Уже имеет: app/1048540 | Kao the Kangaroo: Round 2
<XLimited5> Уже имеет: app/370910 | Kathy Rain
<XLimited5> Уже имеет: app/343710 | KHOLAT
<XLimited5> Уже имеет: app/253900 | Knights and Merchants
<XLimited5> Уже имеет: app/224260 | No More Room in Hell
<XLimited5> Уже имеет: app/343360 | Particula
<XLimited5> Уже имеет: app/237870 | Planet Explorers
<XLimited5> Уже имеет: app/684680 | Polygoneer
<XLimited5> Уже имеет: app/1089130 | Quake II RTX
<XLimited5> Уже имеет: app/755790 | Ring of Elysium
<XLimited5> Уже имеет: app/1258080 | Shop Titans
<XLimited5> Уже имеет: app/759530 | Struckd - 3D Game Creator
<XLimited5> Уже имеет: app/269710 | Tumblestone
<XLimited5> Уже имеет: app/304930 | Unturned
<XLimited5> Уже имеет: app/1019250 | WWII TCG - World War 2: The Card Game
<ASF> 1/1 ботов уже имеют игру app/1493800 | Aircraft Carrier Survival: Prolouge.
<ASF> 1/1 ботов уже имеют игру app/349520 | Armillo.
<ASF> 1/1 ботов уже имеют игру app/346330 | BrainBread 2.
<ASF> 1/1 ботов уже имеют игру app/1086690 | C-War 2.
<ASF> 1/1 ботов уже имеют игру app/730 | Counter-Strike: Global Offensive.
<ASF> 1/1 ботов уже имеют игру app/838380 | DEAD OR ALIVE 6.
<ASF> 1/1 ботов уже имеют игру app/582890 | Estranged: The Departure.
<ASF> 1/1 ботов уже имеют игру app/331470 | Everlasting Summer.
<ASF> 1/1 ботов уже имеют игру app/1078000 | Gamecraft.
<ASF> 1/1 ботов уже имеют игру app/266310 | GameGuru.
<ASF> 1/1 ботов уже имеют игру app/275390 | Guacamelee! Super Turbo Championship Edition.
<ASF> 1/1 ботов уже имеют игру app/627690 | Idle Champions of the Forgotten Realms.
<ASF> 1/1 ботов уже имеют игру app/1048540 | Kao the Kangaroo: Round 2.
<ASF> 1/1 ботов уже имеют игру app/370910 | Kathy Rain.
<ASF> 1/1 ботов уже имеют игру app/343710 | KHOLAT.
<ASF> 1/1 ботов уже имеют игру app/253900 | Knights and Merchants.
<ASF> 1/1 ботов уже имеют игру app/224260 | No More Room in Hell.
<ASF> 1/1 ботов уже имеют игру app/343360 | Particula.
<ASF> 1/1 ботов уже имеют игру app/237870 | Planet Explorers.
<ASF> 1/1 ботов уже имеют игру app/684680 | Polygoneer.
<ASF> 1/1 ботов уже имеют игру app/1089130 | Quake II RTX.
<ASF> 1/1 ботов уже имеют игру app/755790 | Ring of Elysium.
<ASF> 1/1 ботов уже имеют игру app/1258080 | Shop Titans.
<ASF> 1/1 ботов уже имеют игру app/759530 | Struckd - 3D Game Creator.
<ASF> 1/1 ботов уже имеют игру app/269710 | Tumblestone.
<ASF> 1/1 ботов уже имеют игру app/304930 | Unturned.";
List<string> 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<string> 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();
}
}
}

View file

@ -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<string, Bot>? 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<Dictionary<string, string>?> 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<bool> 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));

View file

@ -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<string> 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<char> charPool = ArrayPool<char>.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);
}
}
}

View file

@ -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));
}