diff --git a/.github/workflows/check-buildstatus.yml b/.github/workflows/check-buildstatus.yml index 917a7c0..c1cfaaf 100644 --- a/.github/workflows/check-buildstatus.yml +++ b/.github/workflows/check-buildstatus.yml @@ -8,14 +8,19 @@ jobs: check-buildstatus: runs-on: ubuntu-latest + strategy: + matrix: + dotnet-version: [ '7.0.x' ] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup .NET - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v2 with: - dotnet-version: 6.0.x + dotnet-version: ${{ matrix.dotnet-version }} - name: Restore dependencies run: dotnet restore - name: Build TwitchLib.Communication - run: dotnet build --no-restore + run: dotnet build --no-restore --configuration Release + - name: Test + run: dotnet test --no-restore --verbosity normal \ No newline at end of file diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml index 3b39c12..5e66190 100644 --- a/.github/workflows/preview-release.yml +++ b/.github/workflows/preview-release.yml @@ -10,11 +10,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup .NET - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v2 with: - dotnet-version: 6.0.x + dotnet-version: 7.0.x - name: Restore dependencies run: dotnet restore - name: Build TwitchLib.Communication diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ae58ce..bbeead9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,11 +10,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup .NET - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v2 with: - dotnet-version: 6.0.x + dotnet-version: 7.0.x - name: Restore dependencies run: dotnet restore - name: Build TwitchLib.Communication diff --git a/.github/workflows/tests-linux.yml b/.github/workflows/tests-linux.yml new file mode 100644 index 0000000..0266b95 --- /dev/null +++ b/.github/workflows/tests-linux.yml @@ -0,0 +1,25 @@ +name: Test TwitchLib.Communication Linux + +on: + [push] + +jobs: + tests: + + runs-on: ubuntu-latest + strategy: + matrix: + dotnet-version: [ '7.0.x' ] + + steps: + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: ${{ matrix.dotnet-version }} + - name: Restore dependencies + run: dotnet restore + - name: Build TwitchLib.Communication + run: dotnet build --no-restore --configuration Release + - name: Test + run: dotnet test --no-restore --verbosity normal diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml new file mode 100644 index 0000000..4aee907 --- /dev/null +++ b/.github/workflows/tests-windows.yml @@ -0,0 +1,25 @@ +name: Test TwitchLib.Communication Windows + +on: + [push] + +jobs: + tests: + + runs-on: windows-latest + strategy: + matrix: + dotnet-version: [ '7.0.x' ] + + steps: + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: ${{ matrix.dotnet-version }} + - name: Restore dependencies + run: dotnet restore + - name: Build TwitchLib.Communication + run: dotnet build --no-restore --configuration Release + - name: Test + run: dotnet test --no-restore --verbosity normal diff --git a/.gitignore b/.gitignore index 89ad0e0..cc7fed1 100644 --- a/.gitignore +++ b/.gitignore @@ -233,4 +233,7 @@ $RECYCLE.BIN/ *.msp # Windows shortcuts -*.lnk \ No newline at end of file +*.lnk + +# Rider files +.idea/* \ No newline at end of file diff --git a/ChangeLog.md b/ChangeLog.md new file mode 100644 index 0000000..dcef01d --- /dev/null +++ b/ChangeLog.md @@ -0,0 +1,101 @@ +# Changelog + +## Version 2.0.0 +### Addresses +##### Issues +- https://github.com/TwitchLib/TwitchLib/issues/1093 +- https://github.com/TwitchLib/TwitchLib.Client/issues/206 +- https://github.com/TwitchLib/TwitchLib/issues/1104 +- https://github.com/TwitchLib/TwitchLib.Communication/issues/13 +- https://github.com/TwitchLib/TwitchLib.Communication/issues/7 + +##### Pull Requests +- none + +--- + +### Changes + +--- + +#### IClient +##### Changed +- now extends `IDisposable` +- `event EventHandler OnReconnected;` + - to `event EventHandler OnReconnected;` + - now the `event`handlers argument is `OnConnectedEventArgs` instead of `OnReconnectedEventArgs` + - the specific `event`handler itself, determines wether the args are in context of connect or reconnect +- `IClient.Send(string message)` is now synchronized because + - `ThrottlerService` got removed + - https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream?view=netstandard-2.0#remarks +##### Added +- none +##### Removed +- see also: https://discuss.dev.twitch.tv/t/deprecation-of-chat-commands-through-irc/40486 + - `bool SendWhisper(string message);` + - `void WhisperThrottled(OnWhisperThrottledEventArgs eventArgs);` +- `event EventHandler OnData;` + - as far as i got it right, + - binary data is not received + - it has never ever been used/raised +- `event EventHandler OnMessageThrottled;` + - because `ThrottlerService` is now part of `TwitchLib.Client` +- `event EventHandler OnStateChanged;` + - neither used by `TwitchLib.Client` nor by `TwitchLib.PubSub` +--- + +#### ClientOptions +##### Changed +- `value`s for properties can only be passed by `ctor` +- `ctor` also takes an argument for `ReconnectionPolicy` + - by leaving it `null`, a `default` `ReconnectionPolicy` is created, that attempts to reconnect every 3_000 milliseconds for ten times +- `DisconnectWait` became an unsigned integer (`uint`), to ensure only positive values are used for it +##### Removed +- see also: https://discuss.dev.twitch.tv/t/deprecation-of-chat-commands-through-irc/40486 + - `TimeSpan WhisperThrottlingPeriod { get; set; }` + - `int WhispersAllowedInPeriod { get; set; }` + - `int WhisperQueueCapacity { get; set; }` +##### Moved +- the following properties went to `TwitchLib.Client.Models.SendOptions` + - `int SendQueueCapacity { get; set; }` + - `TimeSpan SendCacheItemTimeout { get; set; }` + - `ushort SendDelay { get; set; }` + - `TimeSpan ThrottlingPeriod { get; set; }` + - `int MessagesAllowedInPeriod { get; set; }` + +--- + +#### ConnectionWatchDog +- now the `ConnectionWatchDog` enforces reconnect according to the `ReconnectionPolicy` +- `ConnectionWatchDog` does not send `PING :tmi.twitch.tv`-messages anymore + - `TwitchLib.Client` receives `PING :tmi.twitch.tv`-messages and has to reply with `PONG :tmi.twitch.tv` + - https://dev.twitch.tv/docs/irc/#keepalive-messages + - `TwitchLib.Client` does so + - it handles received PING-messages + - `TwitchLib.PubSub` has to send `PING :tmi.twitch.tv` within at least every five minutes + - https://dev.twitch.tv/docs/pubsub/#connection-management + - `TwitchLib.PubSub` does so + - it has its own PING- and PONG-Timer + +--- + +#### Throttling/ThrottlerService +- `TwitchLib.Communication.IClient` doesnt throttle messages anymore + - `TwitchLib.PubSub` does not need it + - only `TwitchLib.Client` needs it + - so, throttling went to `TwitchLib.Client.Services.ThrottlerService` in combination with `TwitchLib.Client.Services.Throttler` +- everything related to throttling got removed + - `TwitchLib.Communication.Events.OnMessageThrottledEventArgs` + - `TwitchLib.Communication.Interfaces.IClientOptions` + - see also [ClientOptions.Moved](#ClientOptions.Moved) + - `int SendQueueCapacity { get; set; }` + - `TimeSpan SendCacheItemTimeout { get; set; }` + - `ushort SendDelay { get; set; }` + - `TimeSpan ThrottlingPeriod { get; set; }` + - `int MessagesAllowedInPeriod { get; set; }` + +--- + +#### OnStateChangedEventArgs +- removed +- neither used by `TwitchLib.Client` nor by `TwitchLib.PubSub` \ No newline at end of file diff --git a/TwitchLib.Communication.sln b/TwitchLib.Communication.sln index 793ca03..a635a11 100644 --- a/TwitchLib.Communication.sln +++ b/TwitchLib.Communication.sln @@ -1,11 +1,11 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.27703.2035 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.33424.131 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TwitchLib.Communication", "src\TwitchLib.Communication\TwitchLib.Communication.csproj", "{5DBA3070-744D-45EF-84EA-D5ECF3C71CFE}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TwitchLib.Communication.Tests", "src\TwitchLib.Communication.Tests\TwitchLib.Communication.Tests.csproj", "{8945B40A-7E9A-423E-964F-E215C112DA56}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TwitchLib.Communication.Tests", "src\TwitchLib.Communication.Tests\TwitchLib.Communication.Tests.csproj", "{8945B40A-7E9A-423E-964F-E215C112DA56}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/TwitchLib.Communication.Tests/Clients/ClientTestsBase.cs b/src/TwitchLib.Communication.Tests/Clients/ClientTestsBase.cs new file mode 100644 index 0000000..7894e57 --- /dev/null +++ b/src/TwitchLib.Communication.Tests/Clients/ClientTestsBase.cs @@ -0,0 +1,819 @@ +using System; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using TwitchLib.Communication.Events; +using TwitchLib.Communication.Interfaces; +using TwitchLib.Communication.Models; +using TwitchLib.Communication.Tests.Helpers; +using Xunit; +using Xunit.Sdk; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] +namespace TwitchLib.Communication.Tests.Clients; + +/// +/// bundles -Tests in one container +/// +/// +/// +/// +/// +/// +/// +/// +/// +public abstract class ClientTestsBase where T : IClient +{ + private static TimeSpan WaitOneDuration => TimeSpan.FromSeconds(5); + private readonly IClientOptions? _options; + + protected ClientTestsBase(IClientOptions? options = null) + { + _options = options; + } + + [Fact] + public async Task Client_Raises_OnConnected_EventArgs() + { + // create one logger per test-method! - cause one file per test-method is generated + var logger = TestLogHelper.GetLogger(); + var client = GetClient(logger, _options); + Assert.NotNull(client); + try + { + var pauseConnected = new ManualResetEvent(false); + + await Assert.RaisesAsync( + h => client.OnConnected += h, + h => client.OnConnected -= h, + async () => + { + client.OnConnected += async(sender, e) => + { + pauseConnected.Set(); + }; + + await client.OpenAsync(); + Assert.True(pauseConnected.WaitOne(WaitOneDuration)); + }); + } + catch (Exception e) + { + logger.LogError(e.ToString()); + Assert.Fail(e.ToString()); + } + finally + { + client.Dispose(); + } + } + + [Fact] + public async Task Client_Raises_OnDisconnected_EventArgs() + { + // create one logger per test-method! - cause one file per test-method is generated + var logger = TestLogHelper.GetLogger(); + var client = GetClient(logger, _options); + Assert.NotNull(client); + try + { + var pauseDisconnected = new ManualResetEvent(false); + + await Assert.RaisesAsync( + h => client.OnDisconnected += h, + h => client.OnDisconnected -= h, + async () => + { + client.OnConnected += async (sender, e) => + { + await client.CloseAsync(); + }; + + client.OnDisconnected += async (sender, e) => + { + pauseDisconnected.Set(); + }; + await client.OpenAsync(); + + Assert.True(pauseDisconnected.WaitOne(WaitOneDuration)); + }); + } + catch (Exception e) + { + logger.LogError(e.ToString()); + Assert.Fail(e.ToString()); + } + finally + { + client.Dispose(); + } + } + + [Fact] + public async Task Client_Raises_OnReconnected_EventArgs() + { + // create one logger per test-method! - cause one file per test-method is generated + var logger = TestLogHelper.GetLogger(); + var client = GetClient(logger, _options); + Assert.NotNull(client); + try + { + var pauseReconnected = new ManualResetEvent(false); + + await Assert.RaisesAsync( + h => client.OnReconnected += h, + h => client.OnReconnected -= h, + async () => + { + client.OnConnected += async (s, e) => await client.ReconnectAsync(); + + client.OnReconnected += async (s, e) => + { + pauseReconnected.Set(); + }; + await client.OpenAsync(); + Assert.True(pauseReconnected.WaitOne(WaitOneDuration)); + }); + } + catch (Exception e) + { + logger.LogError(e.ToString()); + Assert.Fail(e.ToString()); + } + finally + { + client.Dispose(); + } + } + + [Fact] + public void Dispose_Client_Before_Connecting_IsOK() + { + // create one logger per test-method! - cause one file per test-method is generated + var logger = TestLogHelper.GetLogger(); + IClient? client = null; + try + { + client = GetClient(logger, _options); + Assert.NotNull(client); + client.Dispose(); + } + catch (Exception e) + { + logger.LogError(e.ToString()); + Assert.Fail(e.ToString()); + } + finally + { + client?.Dispose(); + } + } + + private static TClient? GetClient(ILogger logger, IClientOptions? options = null) + { + var constructorParameterTypes = new Type[] + { + typeof(IClientOptions), + typeof(ILogger) + }; + + var constructor = typeof(TClient).GetConstructor(constructorParameterTypes); + var constructorParameters = new object[] + { + options ?? new ClientOptions(), + logger + }; + + return (TClient?)constructor?.Invoke(constructorParameters); + } +} + + +#region Modified Assert +//TL;DR: Extracted version of XUNIT with +//modification to accept new event Handler + +public partial class Assert +{ + + /// + /// Verifies that a event with the exact event args (and not a derived type) is raised. + /// + /// The type of the event arguments to expect + /// Code to attach the event handler + /// Code to detach the event handler + /// A delegate to the code to be tested + /// The event sender and arguments wrapped in an object + /// Thrown when the expected event was not raised. + public static async Task> RaisesAsync(Action> attach, Action> detach, Func testCode) + { + var raisedEvent = await RaisesAsyncInternal(attach, detach, testCode); + + if (raisedEvent == null) + throw new RaisesException(typeof(T)); + + if (raisedEvent.Arguments != null && !raisedEvent.Arguments.GetType().Equals(typeof(T))) + throw new RaisesException(typeof(T), raisedEvent.Arguments.GetType()); + + return raisedEvent; + } + + /// + /// Verifies that an event with the exact or a derived event args is raised. + /// + /// The type of the event arguments to expect + /// Code to attach the event handler + /// Code to detach the event handler + /// A delegate to the code to be tested + /// The event sender and arguments wrapped in an object + /// Thrown when the expected event was not raised. + public static async Task> RaisesAnyAsync(Action> attach, Action> detach, Func testCode) + { + var raisedEvent = await RaisesAsyncInternal(attach, detach, testCode); + + if (raisedEvent == null) + throw new RaisesException(typeof(T)); + + return raisedEvent; + } + +#if XUNIT_NULLABLE + static async Task?> RaisesAsyncInternal(Action> attach, Action> detach, Func testCode) +#else + static async Task> RaisesAsyncInternal(Action> attach, Action> detach, Func testCode) +#endif + { + NotNull(attach); + NotNull(detach); + NotNull(testCode); + +#if XUNIT_NULLABLE + RaisedEvent? raisedEvent = null; + void handler(object? s, T args) => raisedEvent = new RaisedEvent(s, args); +#else + RaisedEvent? raisedEvent = null; + AsyncEventHandler value = (object? s, T args) => + { + raisedEvent = new RaisedEvent(s, args); + return Task.CompletedTask; + }; + AsyncEventHandler handler = value; +#endif + attach(handler); + await testCode(); + detach(handler); + return raisedEvent; + } + + /// + /// Represents a raised event after the fact. + /// + /// The type of the event arguments. + public class RaisedEvent + { + /// + /// The sender of the event. + /// +#if XUNIT_NULLABLE + public object? Sender { get; } +#else + public object Sender { get; } +#endif + + /// + /// The event arguments. + /// + public T Arguments { get; } + + /// + /// Creates a new instance of the class. + /// + /// The sender of the event. + /// The event arguments +#if XUNIT_NULLABLE + public RaisedEvent(object? sender, T args) +#else + public RaisedEvent(object sender, T args) +#endif + { + Sender = sender; + Arguments = args; + } + } + + +#if XUNIT_NULLABLE + public static void False([DoesNotReturnIf(parameterValue: true)] bool condition) +#else + public static void False(bool condition) +#endif + { + False((bool?)condition, null); + } + + /// + /// Verifies that the condition is false. + /// + /// The condition to be tested + /// Thrown if the condition is not false +#if XUNIT_NULLABLE + public static void False([DoesNotReturnIf(parameterValue: true)] bool? condition) +#else + public static void False(bool? condition) +#endif + { + False(condition, null); + } + + /// + /// Verifies that the condition is false. + /// + /// The condition to be tested + /// The message to show when the condition is not false + /// Thrown if the condition is not false +#if XUNIT_NULLABLE + public static void False([DoesNotReturnIf(parameterValue: true)] bool condition, string? userMessage) +#else + public static void False(bool condition, string userMessage) +#endif + { + False((bool?)condition, userMessage); + } + + /// + /// Verifies that the condition is false. + /// + /// The condition to be tested + /// The message to show when the condition is not false + /// Thrown if the condition is not false +#if XUNIT_NULLABLE + public static void False([DoesNotReturnIf(parameterValue: true)] bool? condition, string? userMessage) +#else + public static void False(bool? condition, string userMessage) +#endif + { + if (!condition.HasValue || condition.GetValueOrDefault()) + throw new FalseException(userMessage, condition); + } + + /// + /// Verifies that an expression is true. + /// + /// The condition to be inspected + /// Thrown when the condition is false +#if XUNIT_NULLABLE + public static void True([DoesNotReturnIf(parameterValue: false)] bool condition) +#else + public static void True(bool condition) +#endif + { + True((bool?)condition, null); + } + + /// + /// Verifies that an expression is true. + /// + /// The condition to be inspected + /// Thrown when the condition is false +#if XUNIT_NULLABLE + public static void True([DoesNotReturnIf(parameterValue: false)] bool? condition) +#else + public static void True(bool? condition) +#endif + { + True(condition, null); + } + + /// + /// Verifies that an expression is true. + /// + /// The condition to be inspected + /// The message to be shown when the condition is false + /// Thrown when the condition is false +#if XUNIT_NULLABLE + public static void True([DoesNotReturnIf(parameterValue: false)] bool condition, string? userMessage) +#else + public static void True(bool condition, string userMessage) +#endif + { + True((bool?)condition, userMessage); + } + + /// + /// Verifies that an expression is true. + /// + /// The condition to be inspected + /// The message to be shown when the condition is false + /// Thrown when the condition is false +#if XUNIT_NULLABLE + public static void True([DoesNotReturnIf(parameterValue: false)] bool? condition, string? userMessage) +#else + public static void True(bool? condition, string userMessage) +#endif + { + if (!condition.HasValue || !condition.GetValueOrDefault()) + throw new TrueException(userMessage, condition); + } + + /// + /// Verifies that a string contains a given sub-string, using the current culture. + /// + /// The sub-string expected to be in the string + /// The string to be inspected + /// Thrown when the sub-string is not present inside the string +#if XUNIT_NULLABLE + public static void Contains(string expectedSubstring, string? actualString) +#else + public static void Contains(string expectedSubstring, string actualString) +#endif + { + Contains(expectedSubstring, actualString, StringComparison.CurrentCulture); + } + + /// + /// Verifies that a string contains a given sub-string, using the given comparison type. + /// + /// The sub-string expected to be in the string + /// The string to be inspected + /// The type of string comparison to perform + /// Thrown when the sub-string is not present inside the string +#if XUNIT_NULLABLE + public static void Contains(string expectedSubstring, string? actualString, StringComparison comparisonType) +#else + public static void Contains(string expectedSubstring, string actualString, StringComparison comparisonType) +#endif + { + NotNull(expectedSubstring); + + if (actualString == null || actualString.IndexOf(expectedSubstring, comparisonType) < 0) + throw new ContainsException(expectedSubstring, actualString); + } + + /// + /// Verifies that a string does not contain a given sub-string, using the current culture. + /// + /// The sub-string which is expected not to be in the string + /// The string to be inspected + /// Thrown when the sub-string is present inside the string +#if XUNIT_NULLABLE + public static void DoesNotContain(string expectedSubstring, string? actualString) +#else + public static void DoesNotContain(string expectedSubstring, string actualString) +#endif + { + DoesNotContain(expectedSubstring, actualString, StringComparison.CurrentCulture); + } + + /// + /// Verifies that a string does not contain a given sub-string, using the current culture. + /// + /// The sub-string which is expected not to be in the string + /// The string to be inspected + /// The type of string comparison to perform + /// Thrown when the sub-string is present inside the given string +#if XUNIT_NULLABLE + public static void DoesNotContain(string expectedSubstring, string? actualString, StringComparison comparisonType) +#else + public static void DoesNotContain(string expectedSubstring, string actualString, StringComparison comparisonType) +#endif + { + NotNull(expectedSubstring); + + if (actualString != null && actualString.IndexOf(expectedSubstring, comparisonType) >= 0) + throw new DoesNotContainException(expectedSubstring, actualString); + } + + /// + /// Verifies that a string starts with a given string, using the current culture. + /// + /// The string expected to be at the start of the string + /// The string to be inspected + /// Thrown when the string does not start with the expected string +#if XUNIT_NULLABLE + public static void StartsWith(string? expectedStartString, string? actualString) +#else + public static void StartsWith(string expectedStartString, string actualString) +#endif + { + StartsWith(expectedStartString, actualString, StringComparison.CurrentCulture); + } + + /// + /// Verifies that a string starts with a given string, using the given comparison type. + /// + /// The string expected to be at the start of the string + /// The string to be inspected + /// The type of string comparison to perform + /// Thrown when the string does not start with the expected string +#if XUNIT_NULLABLE + public static void StartsWith(string? expectedStartString, string? actualString, StringComparison comparisonType) +#else + public static void StartsWith(string expectedStartString, string actualString, StringComparison comparisonType) +#endif + { + if (expectedStartString == null || actualString == null || !actualString.StartsWith(expectedStartString, comparisonType)) + throw new StartsWithException(expectedStartString, actualString); + } + + /// + /// Verifies that a string ends with a given string, using the current culture. + /// + /// The string expected to be at the end of the string + /// The string to be inspected + /// Thrown when the string does not end with the expected string +#if XUNIT_NULLABLE + public static void EndsWith(string? expectedEndString, string? actualString) +#else + public static void EndsWith(string expectedEndString, string actualString) +#endif + { + EndsWith(expectedEndString, actualString, StringComparison.CurrentCulture); + } + + /// + /// Verifies that a string ends with a given string, using the given comparison type. + /// + /// The string expected to be at the end of the string + /// The string to be inspected + /// The type of string comparison to perform + /// Thrown when the string does not end with the expected string +#if XUNIT_NULLABLE + public static void EndsWith(string? expectedEndString, string? actualString, StringComparison comparisonType) +#else + public static void EndsWith(string expectedEndString, string actualString, StringComparison comparisonType) +#endif + { + if (expectedEndString == null || actualString == null || !actualString.EndsWith(expectedEndString, comparisonType)) + throw new EndsWithException(expectedEndString, actualString); + } + + /// + /// Verifies that a string matches a regular expression. + /// + /// The regex pattern expected to match + /// The string to be inspected + /// Thrown when the string does not match the regex pattern +#if XUNIT_NULLABLE + public static void Matches(string expectedRegexPattern, string? actualString) +#else + public static void Matches(string expectedRegexPattern, string actualString) +#endif + { + NotNull(expectedRegexPattern); + + if (actualString == null || !Regex.IsMatch(actualString, expectedRegexPattern)) + throw new MatchesException(expectedRegexPattern, actualString); + } + + /// + /// Verifies that a string matches a regular expression. + /// + /// The regex expected to match + /// The string to be inspected + /// Thrown when the string does not match the regex +#if XUNIT_NULLABLE + public static void Matches(Regex expectedRegex, string? actualString) +#else + public static void Matches(Regex expectedRegex, string actualString) +#endif + { + NotNull(expectedRegex); + + if (actualString == null || !expectedRegex.IsMatch(actualString)) + throw new MatchesException(expectedRegex.ToString(), actualString); + } + + /// + /// Verifies that a string does not match a regular expression. + /// + /// The regex pattern expected not to match + /// The string to be inspected + /// Thrown when the string matches the regex pattern +#if XUNIT_NULLABLE + public static void DoesNotMatch(string expectedRegexPattern, string? actualString) +#else + public static void DoesNotMatch(string expectedRegexPattern, string actualString) +#endif + { + NotNull(expectedRegexPattern); + + if (actualString != null && Regex.IsMatch(actualString, expectedRegexPattern)) + throw new DoesNotMatchException(expectedRegexPattern, actualString); + } + + /// + /// Verifies that a string does not match a regular expression. + /// + /// The regex expected not to match + /// The string to be inspected + /// Thrown when the string matches the regex +#if XUNIT_NULLABLE + public static void DoesNotMatch(Regex expectedRegex, string? actualString) +#else + public static void DoesNotMatch(Regex expectedRegex, string actualString) +#endif + { + NotNull(expectedRegex); + + if (actualString != null && expectedRegex.IsMatch(actualString)) + throw new DoesNotMatchException(expectedRegex.ToString(), actualString); + } + + /// + /// Verifies that two strings are equivalent. + /// + /// The expected string value. + /// The actual string value. + /// Thrown when the strings are not equivalent. +#if XUNIT_NULLABLE + public static void Equal(string? expected, string? actual) +#else + public static void Equal(string expected, string actual) +#endif + { + Equal(expected, actual, false, false, false); + } + + /// + /// Verifies that two strings are equivalent. + /// + /// The expected string value. + /// The actual string value. + /// If set to true, ignores cases differences. The invariant culture is used. + /// If set to true, treats \r\n, \r, and \n as equivalent. + /// If set to true, treats spaces and tabs (in any non-zero quantity) as equivalent. + /// Thrown when the strings are not equivalent. +#if XUNIT_NULLABLE + public static void Equal( + string? expected, + string? actual, + bool ignoreCase = false, + bool ignoreLineEndingDifferences = false, + bool ignoreWhiteSpaceDifferences = false) +#else + public static void Equal( + string expected, + string actual, + bool ignoreCase = false, + bool ignoreLineEndingDifferences = false, + bool ignoreWhiteSpaceDifferences = false) +#endif + { +#if XUNIT_SPAN + if (expected == null && actual == null) + return; + if (expected == null || actual == null) + throw new EqualException(expected, actual, -1, -1); + + Equal(expected.AsSpan(), actual.AsSpan(), ignoreCase, ignoreLineEndingDifferences, ignoreWhiteSpaceDifferences); +#else + // Start out assuming the one of the values is null + int expectedIndex = -1; + int actualIndex = -1; + int expectedLength = 0; + int actualLength = 0; + + if (expected == null) + { + if (actual == null) + return; + } + else if (actual != null) + { + // Walk the string, keeping separate indices since we can skip variable amounts of + // data based on ignoreLineEndingDifferences and ignoreWhiteSpaceDifferences. + expectedIndex = 0; + actualIndex = 0; + expectedLength = expected.Length; + actualLength = actual.Length; + + while (expectedIndex < expectedLength && actualIndex < actualLength) + { + char expectedChar = expected[expectedIndex]; + char actualChar = actual[actualIndex]; + + if (ignoreLineEndingDifferences && IsLineEnding(expectedChar) && IsLineEnding(actualChar)) + { + expectedIndex = SkipLineEnding(expected, expectedIndex); + actualIndex = SkipLineEnding(actual, actualIndex); + } + else if (ignoreWhiteSpaceDifferences && IsWhiteSpace(expectedChar) && IsWhiteSpace(actualChar)) + { + expectedIndex = SkipWhitespace(expected, expectedIndex); + actualIndex = SkipWhitespace(actual, actualIndex); + } + else + { + if (ignoreCase) + { + expectedChar = Char.ToUpperInvariant(expectedChar); + actualChar = Char.ToUpperInvariant(actualChar); + } + + if (expectedChar != actualChar) + { + break; + } + + expectedIndex++; + actualIndex++; + } + } + } + + if (expectedIndex < expectedLength || actualIndex < actualLength) + { + throw new EqualException(expected, actual, expectedIndex, actualIndex); + } +#endif + } + static bool IsLineEnding(char c) + { + return c == '\r' || c == '\n'; + } + + static bool IsWhiteSpace(char c) + { + return c == ' ' || c == '\t'; + } + + static int SkipLineEnding(string value, int index) + { + if (value[index] == '\r') + { + ++index; + } + if (index < value.Length && value[index] == '\n') + { + ++index; + } + + return index; + } + + static int SkipWhitespace(string value, int index) + { + while (index < value.Length) + { + switch (value[index]) + { + case ' ': + case '\t': + index++; + break; + + default: + return index; + } + } + + return index; + } + + /// + /// Verifies that an object reference is not null. + /// + /// The object to be validated + /// Thrown when the object reference is null +#if XUNIT_NULLABLE + public static void NotNull([NotNull] object? @object) +#else + public static void NotNull(object @object) +#endif + { + if (@object == null) + throw new NotNullException(); + } + + /// + /// Verifies that an object reference is null. + /// + /// The object to be inspected + /// Thrown when the object reference is not null +#if XUNIT_NULLABLE + public static void Null([MaybeNull] object? @object) +#else + public static void Null(object @object) +#endif + { + if (@object != null) + throw new NullException(@object); + } + + /// + /// Indicates that the test should immediately fail. + /// + /// The failure message +#if XUNIT_NULLABLE + [DoesNotReturn] +#endif + public static void Fail(string message) + { + NotNull( message); + + throw new FailException(message); + } +} +#endregion diff --git a/src/TwitchLib.Communication.Tests/Clients/TcpClientTests.cs b/src/TwitchLib.Communication.Tests/Clients/TcpClientTests.cs new file mode 100644 index 0000000..439e5d9 --- /dev/null +++ b/src/TwitchLib.Communication.Tests/Clients/TcpClientTests.cs @@ -0,0 +1,9 @@ +using TwitchLib.Communication.Clients; +using TwitchLib.Communication.Models; + +namespace TwitchLib.Communication.Tests.Clients; + +public class TcpClientTests : ClientTestsBase +{ + public TcpClientTests() : base(new ClientOptions(useSsl: false)) { } +} \ No newline at end of file diff --git a/src/TwitchLib.Communication.Tests/Clients/WebSocketClientTests.cs b/src/TwitchLib.Communication.Tests/Clients/WebSocketClientTests.cs new file mode 100644 index 0000000..0009ded --- /dev/null +++ b/src/TwitchLib.Communication.Tests/Clients/WebSocketClientTests.cs @@ -0,0 +1,7 @@ +using TwitchLib.Communication.Clients; + +namespace TwitchLib.Communication.Tests.Clients; + +public class WebSocketClientTests : ClientTestsBase +{ +} diff --git a/src/TwitchLib.Communication.Tests/Helpers/TestLogHelper.cs b/src/TwitchLib.Communication.Tests/Helpers/TestLogHelper.cs new file mode 100644 index 0000000..900e9a9 --- /dev/null +++ b/src/TwitchLib.Communication.Tests/Helpers/TestLogHelper.cs @@ -0,0 +1,68 @@ +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Events; +using Serilog.Exceptions; + +namespace TwitchLib.Communication.Tests.Helpers; + +internal static class TestLogHelper +{ + private static readonly string OUTPUT_TEMPLATE = + "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u}] {Message:lj}{NewLine}{Exception}{NewLine}"; + + private static readonly string NEW_TEST_RUN_INDICATOR; + + static TestLogHelper() + { + var builder = new StringBuilder(); + builder.AppendLine(); + builder.Append('-', 80).AppendLine(); + builder.Append(' ', 34); + builder.AppendLine("new Test-Run"); + builder.Append('-', 80).AppendLine(); + NEW_TEST_RUN_INDICATOR = builder.ToString(); + } + + internal static Microsoft.Extensions.Logging.ILogger GetLogger( + LogEventLevel logEventLevel = LogEventLevel.Verbose, + [CallerMemberName] string callerMemberName = "TestMethod") + { + Serilog.ILogger logger = GetSerilogLogger(typeof(T).Name, + callerMemberName, + logEventLevel); + Microsoft.Extensions.Logging.ILoggerFactory loggerFactory = + new Serilog.Extensions.Logging.SerilogLoggerFactory(logger); + return loggerFactory.CreateLogger(); + } + + private static Serilog.ILogger GetSerilogLogger(string typeName, + string callerMemberName, + LogEventLevel logEventLevel) + { + Serilog.LoggerConfiguration loggerConfiguration = GetConfiguration(typeName, + callerMemberName, + logEventLevel); + Serilog.ILogger logger = loggerConfiguration.CreateLogger().ForContext(); + logger.Information(NEW_TEST_RUN_INDICATOR); + return logger; + } + + private static Serilog.LoggerConfiguration GetConfiguration(string typeName, + string callerMemberName, + LogEventLevel logEventLevel) + { + var loggerConfiguration = new Serilog.LoggerConfiguration(); + loggerConfiguration.MinimumLevel.Verbose(); + string path = $"../../../Logs/{typeName}/{callerMemberName}.log"; + loggerConfiguration.WriteTo.File( + path: path, + restrictedToMinimumLevel: logEventLevel, + outputTemplate: OUTPUT_TEMPLATE + ); + loggerConfiguration.Enrich.WithExceptionDetails(); + loggerConfiguration.Enrich.FromLogContext(); + return loggerConfiguration; + } +} diff --git a/src/TwitchLib.Communication.Tests/Logs/TcpClient/.gitkeep b/src/TwitchLib.Communication.Tests/Logs/TcpClient/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/TwitchLib.Communication.Tests/Logs/WebSocketClient/.gitkeep b/src/TwitchLib.Communication.Tests/Logs/WebSocketClient/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/TwitchLib.Communication.Tests/Models/ReconnectionPolicyTests.cs b/src/TwitchLib.Communication.Tests/Models/ReconnectionPolicyTests.cs new file mode 100644 index 0000000..24d1186 --- /dev/null +++ b/src/TwitchLib.Communication.Tests/Models/ReconnectionPolicyTests.cs @@ -0,0 +1,39 @@ +using System; +using TwitchLib.Communication.Models; +using Xunit; + +namespace TwitchLib.Communication.Tests.Models; + +public class ReconnectionPolicyTests +{ + /// + /// Checks + ///

+ /// + ///

+ /// + ///
+ [Fact] + public void ReconnectionPolicy_OmitReconnect() + { + try + { + ReconnectionPolicy reconnectionPolicy = new NoReconnectionPolicy(); + Assert.False(reconnectionPolicy.AreAttemptsComplete()); + reconnectionPolicy.ProcessValues(); + Assert.True(reconnectionPolicy.AreAttemptsComplete()); + // in case of a normal connect, we expect the ReconnectionPolicy to be reset + reconnectionPolicy.Reset(false); + Assert.False(reconnectionPolicy.AreAttemptsComplete()); + reconnectionPolicy.ProcessValues(); + Assert.True(reconnectionPolicy.AreAttemptsComplete()); + // in case of a reconnect, we expect the ReconnectionPolicy not to be reset + reconnectionPolicy.Reset(true); + Assert.True(reconnectionPolicy.AreAttemptsComplete()); + } + catch (Exception e) + { + Assert.Fail(e.ToString()); + } + } +} diff --git a/src/TwitchLib.Communication.Tests/TcpClientTests.cs b/src/TwitchLib.Communication.Tests/TcpClientTests.cs deleted file mode 100644 index dce736d..0000000 --- a/src/TwitchLib.Communication.Tests/TcpClientTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using TwitchLib.Communication.Clients; -using TwitchLib.Communication.Events; -using TwitchLib.Communication.Models; -using Xunit; - -namespace TwitchLib.Communication.Tests -{ - public class TcpClientTests - { - [Fact] - public void Client_Raises_OnConnected_EventArgs() - { - - var client = new TcpClient(); - var pauseConnected = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnConnected += h, - h => client.OnConnected -= h, - () => - { - client.OnConnected += (sender, e) => { pauseConnected.Set(); }; - client.Open(); - Assert.True(pauseConnected.WaitOne(5000)); - }); - } - - [Fact] - public void Client_Raises_OnDisconnected_EventArgs() - { - var client = new TcpClient(new ClientOptions() {DisconnectWait = 100}); - var pauseDisconnected = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnDisconnected += h, - h => client.OnDisconnected -= h, - () => - { - client.OnConnected += async (sender, e) => - { - await Task.Delay(2000); - client.Close(); - }; - client.OnDisconnected += (sender, e) => - { - pauseDisconnected.Set(); - }; - client.Open(); - Assert.True(pauseDisconnected.WaitOne(20000)); - }); - } - - [Fact] - public void Client_Raises_OnReconnected_EventArgs() - { - var client = new TcpClient(new ClientOptions(){ReconnectionPolicy = null}); - var pauseReconnected = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnReconnected += h, - h => client.OnReconnected -= h, - () => - { - client.OnConnected += async (s, e) => - { - await Task.Delay(2000); - client.Reconnect(); - }; - - client.OnReconnected += (s, e) => { pauseReconnected.Set(); }; - client.Open(); - - Assert.True(pauseReconnected.WaitOne(20000)); - }); - } - - [Fact] - public void Dispose_Client_Before_Connecting_IsOK() - { - var tcpClient = new TcpClient(); - tcpClient.Dispose(); - } - - [Fact] - public void Client_Can_SendAndReceive_Messages() - { - var client = new TcpClient(); - var pauseConnected = new ManualResetEvent(false); - var pauseReadMessage = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnMessage += h, - h => client.OnMessage -= h, - () => - { - client.OnConnected += (sender, e) => { pauseConnected.Set(); }; - - client.OnMessage += (sender, e) => - { - pauseReadMessage.Set(); - Assert.Equal("PONG :tmi.twitch.tv", e.Message); - }; - - client.Open(); - client.Send("PING"); - Assert.True(pauseConnected.WaitOne(5000)); - Assert.True(pauseReadMessage.WaitOne(5000)); - }); - } - } -} diff --git a/src/TwitchLib.Communication.Tests/TwitchLib.Communication.Tests.csproj b/src/TwitchLib.Communication.Tests/TwitchLib.Communication.Tests.csproj index 859c2cc..c245c36 100644 --- a/src/TwitchLib.Communication.Tests/TwitchLib.Communication.Tests.csproj +++ b/src/TwitchLib.Communication.Tests/TwitchLib.Communication.Tests.csproj @@ -1,15 +1,24 @@  - net6.0 + net7.0 false + enable + disable + latest + + + + + + - all + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -19,4 +28,9 @@ + + + + + diff --git a/src/TwitchLib.Communication.Tests/WebSocketClientTests.cs b/src/TwitchLib.Communication.Tests/WebSocketClientTests.cs deleted file mode 100644 index b4736ba..0000000 --- a/src/TwitchLib.Communication.Tests/WebSocketClientTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using TwitchLib.Communication.Clients; -using TwitchLib.Communication.Events; -using TwitchLib.Communication.Models; -using Xunit; - -namespace TwitchLib.Communication.Tests -{ - public class WebSocketClientTests - { - [Fact] - public void Client_Raises_OnConnected_EventArgs() - { - var client = new WebSocketClient(); - var pauseConnected = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnConnected += h, - h => client.OnConnected -= h, - () => - { - client.OnConnected += (sender, e) => { pauseConnected.Set(); }; - client.Open(); - Assert.True(pauseConnected.WaitOne(5000)); - }); - } - - [Fact] - public void Client_Raises_OnDisconnected_EventArgs() - { - var client = new WebSocketClient(new ClientOptions() {DisconnectWait = 5000}); - var pauseDisconnected = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnDisconnected += h, - h => client.OnDisconnected -= h, - () => - { - client.OnConnected += async (sender, e) => - { - await Task.Delay(2000); - client.Close(); - }; - client.OnDisconnected += (sender, e) => - { - pauseDisconnected.Set(); - }; - client.Open(); - Assert.True(pauseDisconnected.WaitOne(200000)); - }); - } - - [Fact] - public void Client_Raises_OnReconnected_EventArgs() - { - var client = new WebSocketClient(new ClientOptions(){ReconnectionPolicy = null}); - var pauseReconnected = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnReconnected += h, - h => client.OnReconnected -= h, - () => - { - client.OnConnected += async (s, e) => - { - await Task.Delay(2000); - client.Reconnect(); - }; - - client.OnReconnected += (s, e) => { pauseReconnected.Set(); }; - client.Open(); - - Assert.True(pauseReconnected.WaitOne(20000)); - }); - } - - [Fact] - public void Dispose_Client_Before_Connecting_IsOK() - { - var client = new WebSocketClient(); - client.Dispose(); - } - - - [Fact] - public void Client_Can_SendAndReceive_Messages() - { - var client = new WebSocketClient(); - var pauseConnected = new ManualResetEvent(false); - var pauseReadMessage = new ManualResetEvent(false); - - Assert.Raises( - h => client.OnMessage += h, - h => client.OnMessage -= h, - () => - { - client.OnConnected += (sender, e) => { pauseConnected.Set(); }; - - client.OnMessage += (sender, e) => - { - pauseReadMessage.Set(); - Assert.Equal("PONG :tmi.twitch.tv", e.Message); - }; - - client.Open(); - client.Send("PING"); - Assert.True(pauseConnected.WaitOne(5000)); - Assert.True(pauseReadMessage.WaitOne(5000)); - }); - } - } -} diff --git a/src/TwitchLib.Communication/Clients/ClientBase.cs b/src/TwitchLib.Communication/Clients/ClientBase.cs new file mode 100644 index 0000000..0dfc91f --- /dev/null +++ b/src/TwitchLib.Communication/Clients/ClientBase.cs @@ -0,0 +1,384 @@ +using Microsoft.Extensions.Logging; +using TwitchLib.Communication.Events; +using TwitchLib.Communication.Extensions; +using TwitchLib.Communication.Interfaces; +using TwitchLib.Communication.Models; +using TwitchLib.Communication.Services; + +namespace TwitchLib.Communication.Clients; + +/// +/// This bundles almost everything that and have in common +/// to be able to +/// +/// +/// pass instances of this to +/// +/// +/// and to access Methods of this instance within +/// +/// +/// +public abstract class ClientBase : IClient + where T : IDisposable +{ + private readonly SemaphoreSlim _semaphore = new(1, 1); + private readonly NetworkServices _networkServices; + private CancellationTokenSource _cancellationTokenSource; + + /// + /// This is used for + /// whenever a call to is made + /// + internal CancellationToken Token => _cancellationTokenSource.Token; + + internal static TimeSpan TimeOutEstablishConnection => TimeSpan.FromSeconds(15); + + protected readonly ILogger>? Logger; + + protected abstract string Url { get; } + + /// + /// The underlying client. + /// + public T? Client { get; private set; } + + /// + public abstract bool IsConnected { get; } + + /// + public IClientOptions Options { get; } + + /// + public event AsyncEventHandler? OnConnected; + + /// + public event AsyncEventHandler? OnDisconnected; + + /// + public event AsyncEventHandler? OnError; + + /// + public event AsyncEventHandler? OnFatality; + + /// + public event AsyncEventHandler? OnMessage; + + /// + public event AsyncEventHandler? OnSendFailed; + + /// + public event AsyncEventHandler? OnReconnected; + + internal ClientBase( + IClientOptions? options, + ILogger>? logger) + { + Logger = logger; + _cancellationTokenSource = new CancellationTokenSource(); + Options = options ?? new ClientOptions(); + _networkServices = new NetworkServices(this, logger); + } + + /// + /// Wont raise the given if .IsCancellationRequested + /// + private async Task RaiseSendFailed(OnSendFailedEventArgs eventArgs) + { + Logger?.TraceMethodCall(GetType()); + if (Token.IsCancellationRequested) + { + return; + } + + if (OnSendFailed != null) await OnSendFailed.Invoke(this, eventArgs); + } + + /// + /// Wont raise the given if .IsCancellationRequested + /// + internal async Task RaiseError(OnErrorEventArgs eventArgs) + { + Logger?.TraceMethodCall(GetType()); + if (Token.IsCancellationRequested) + { + return; + } + + if (OnError != null) await OnError.Invoke(this, eventArgs); + } + + /// + /// Wont raise the given if .IsCancellationRequested + /// + private async Task RaiseReconnected() + { + Logger?.TraceMethodCall(GetType()); + if (Token.IsCancellationRequested) + { + return; + } + + if (OnReconnected != null) await OnReconnected.Invoke(this, new OnConnectedEventArgs()); + } + + /// + /// Wont raise the given if .IsCancellationRequested + /// + internal async Task RaiseMessage(OnMessageEventArgs eventArgs) + { + Logger?.TraceMethodCall(GetType()); + if (Token.IsCancellationRequested) + { + return; + } + + if (OnMessage != null) await OnMessage.Invoke(this, eventArgs); + } + + /// + /// Wont raise the given if .IsCancellationRequested + /// + internal async Task RaiseFatal(Exception? ex = null) + { + Logger?.TraceMethodCall(GetType()); + if (Token.IsCancellationRequested) + { + return; + } + + var onFatalErrorEventArgs = ex != null + ? new OnFatalErrorEventArgs(ex) + : new OnFatalErrorEventArgs("Fatal network error."); + + if (OnFatality != null) await OnFatality.Invoke(this, onFatalErrorEventArgs); + } + + private async Task RaiseDisconnected() + { + Logger?.TraceMethodCall(GetType()); + if (OnDisconnected != null) await OnDisconnected.Invoke(this, new OnDisconnectedEventArgs()); + } + + private async Task RaiseConnected() + { + Logger?.TraceMethodCall(GetType()); + if (OnConnected != null) await OnConnected.Invoke(this, new OnConnectedEventArgs()); + } + + /// + public async Task SendAsync(string message) + { + Logger?.TraceMethodCall(GetType()); + + await _semaphore.WaitAsync(Token); + try + { + await ClientSendAsync(message); + return true; + } + catch (Exception e) + { + await RaiseSendFailed(new OnSendFailedEventArgs(e, message)); + return false; + } + finally + { + _semaphore.Release(); + } + } + + /// + public Task OpenAsync() + { + Logger?.TraceMethodCall(GetType()); + return OpenPrivateAsync(false); + } + + /// + public async Task CloseAsync() + { + Logger?.TraceMethodCall(GetType()); + + // ClosePrivate() also handles IClientOptions.DisconnectWait + await ClosePrivateAsync(); + } + + /// + /// + /// + public void Dispose() + { + Logger?.TraceMethodCall(GetType()); + CloseAsync().GetAwaiter().GetResult(); + GC.SuppressFinalize(this); + } + + /// + public async Task ReconnectAsync() + { + Logger?.TraceMethodCall(GetType()); + + return await ReconnectInternalAsync(); + } + + private async Task OpenPrivateAsync(bool isReconnect) + { + Logger?.TraceMethodCall(GetType()); + try + { + if (Token.IsCancellationRequested) + { + return false; + } + + if (IsConnected) + { + return true; + } + + // Always create new client when opening new connection + Client = CreateClient(); + + var first = true; + Options.ReconnectionPolicy.Reset(isReconnect); + + while (!IsConnected && + !Options.ReconnectionPolicy.AreAttemptsComplete()) + { + Logger?.TraceAction(GetType(), "try to connect"); + if (!first) + { + await Task.Delay(Options.ReconnectionPolicy.GetReconnectInterval(), CancellationToken.None); + } + + await ConnectClientAsync(); + Options.ReconnectionPolicy.ProcessValues(); + first = false; + } + + if (!IsConnected) + { + Logger?.TraceAction(GetType(), "Client couldn't establish a connection"); + await RaiseFatal(); + return false; + } + + Logger?.TraceAction(GetType(), "Client established a connection"); + _networkServices.Start(); + + if (!isReconnect) + { + await RaiseConnected(); + } + + return true; + } + catch (Exception ex) + { + Logger?.LogExceptionAsError(GetType(), ex); + await RaiseError(new OnErrorEventArgs(ex)); + await RaiseFatal(); + return false; + } + } + + /// + /// Stops + /// by calling + ///

+ /// and enforces the + ///

+ /// afterwards it waits for the via given amount of milliseconds + ///

+ ///

+ /// will keep running, + /// because itself issued this call by calling + ///
+ private async Task ClosePrivateAsync() + { + Logger?.TraceMethodCall(GetType()); + + await _networkServices.StopAsync(); + + // This cancellation traverse up to NetworkServices.ListenTask + _cancellationTokenSource.Cancel(); + Logger?.TraceAction(GetType(), + $"{nameof(_cancellationTokenSource)}.{nameof(_cancellationTokenSource.Cancel)} is called"); + + CloseClient(); + await RaiseDisconnected(); + _cancellationTokenSource = new CancellationTokenSource(); + + await Task.Delay(TimeSpan.FromMilliseconds(Options.DisconnectWait), CancellationToken.None); + } + + /// + /// Send method for the client. + /// + /// + /// Message to be send + /// + protected abstract Task ClientSendAsync(string message); + + /// + /// Instantiate the underlying client. + /// + protected abstract T CreateClient(); + + /// + /// one of the following specific methods + /// + /// + /// + /// + /// + /// + /// + /// + /// calls to one of the methods mentioned above, + /// also Dispose() the respective client, + /// so no additional Dispose() is needed + /// + protected abstract void CloseClient(); + + /// + /// Connect the client. + /// + protected abstract Task ConnectClientAsync(); + + /// + /// To issue a reconnect + ///

+ /// especially for the + ///

+ /// it stops all but ! + ///

+ ///

+ /// see also : + ///

+ /// + ///
+ /// + /// if a connection could be established, otherwise + /// + internal async Task ReconnectInternalAsync() + { + Logger?.TraceMethodCall(GetType()); + + await ClosePrivateAsync(); + var reconnected = await OpenPrivateAsync(true); + if (reconnected) + { + await RaiseReconnected(); + } + + return reconnected; + } + + /// + /// just the Action that listens for new Messages + /// the corresponding is held by + /// + internal abstract Task ListenTaskActionAsync(); +} diff --git a/src/TwitchLib.Communication/Clients/TcpClient.cs b/src/TwitchLib.Communication/Clients/TcpClient.cs index 3f39532..1bfc475 100644 --- a/src/TwitchLib.Communication/Clients/TcpClient.cs +++ b/src/TwitchLib.Communication/Clients/TcpClient.cs @@ -1,393 +1,176 @@ -using System; -using System.IO; -using System.Linq; -using System.Net.Security; -using System.Threading; -using System.Threading.Tasks; +using System.Net.Security; +using Microsoft.Extensions.Logging; using TwitchLib.Communication.Events; +using TwitchLib.Communication.Extensions; using TwitchLib.Communication.Interfaces; -using TwitchLib.Communication.Models; -using TwitchLib.Communication.Services; -namespace TwitchLib.Communication.Clients -{ - public class TcpClient : IClient - { - private int NotConnectedCounter; - public TimeSpan DefaultKeepAliveInterval { get; set; } - public int SendQueueLength => _throttlers.SendQueue.Count; - public int WhisperQueueLength => _throttlers.WhisperQueue.Count; - public bool IsConnected => Client?.Connected ?? false; - public IClientOptions Options { get; } - - public event EventHandler OnConnected; - public event EventHandler OnData; - public event EventHandler OnDisconnected; - public event EventHandler OnError; - public event EventHandler OnFatality; - public event EventHandler OnMessage; - public event EventHandler OnMessageThrottled; - public event EventHandler OnWhisperThrottled; - public event EventHandler OnSendFailed; - public event EventHandler OnStateChanged; - public event EventHandler OnReconnected; - - private readonly string _server = "irc.chat.twitch.tv"; - private int Port => Options != null ? Options.UseSsl ? 443 : 80 : 0; - public System.Net.Sockets.TcpClient Client { get; private set; } - private StreamReader _reader; - private StreamWriter _writer; - private readonly Throttlers _throttlers; - private CancellationTokenSource _tokenSource = new CancellationTokenSource(); - private bool _stopServices; - private bool _networkServicesRunning; - private Task[] _networkTasks; - private Task _monitorTask; - - public TcpClient(IClientOptions options = null) - { - Options = options ?? new ClientOptions(); - _throttlers = - new Throttlers(this, Options.ThrottlingPeriod, Options.WhisperThrottlingPeriod) - { - TokenSource = _tokenSource - }; - InitializeClient(); - } - - private void InitializeClient() - { - // check if services should stop - if (_stopServices) { return; } - - Client = new System.Net.Sockets.TcpClient(); - - if (_monitorTask == null) - { - _monitorTask = StartMonitorTask(); - return; - } - - if (_monitorTask.IsCompleted) _monitorTask = StartMonitorTask(); - } - - public bool Open() - { - // reset some boolean values - // especially _stopServices - Reset(); - // now using private _Open() - return _Open(); - } - - /// - /// for private use only, - /// to be able to check at the beginning - /// - private bool _Open() - { - // check if services should stop - if (_stopServices) { return false; } - - try - { - if (IsConnected) return true; - - Task.Run(() => { - InitializeClient(); - Client.Connect(_server, Port); - if (Options.UseSsl) - { - var ssl = new SslStream(Client.GetStream(), false); - ssl.AuthenticateAsClient(_server); - _reader = new StreamReader(ssl); - _writer = new StreamWriter(ssl); - } - else - { - _reader = new StreamReader(Client.GetStream()); - _writer = new StreamWriter(Client.GetStream()); - } - }).Wait(10000); - - if (!IsConnected) return _Open(); +namespace TwitchLib.Communication.Clients; - StartNetworkServices(); - return true; +public class TcpClient : ClientBase +{ + private StreamReader? _reader; + private StreamWriter? _writer; - } - catch (Exception) - { - InitializeClient(); - return false; - } - } + /// + protected override string Url => "irc.chat.twitch.tv"; - public void Close(bool callDisconnect = true) - { - _reader?.Dispose(); - _writer?.Dispose(); - Client?.Close(); + private int Port => Options.UseSsl ? 6697 : 6667; - _stopServices = callDisconnect; - CleanupServices(); - InitializeClient(); - OnDisconnected?.Invoke(this, new OnDisconnectedEventArgs()); - } + /// + public override bool IsConnected => Client?.Connected ?? false; - public void Reconnect() - { - // reset some boolean values - // especially _stopServices - Reset(); - // now using private _Reconnect() - _Reconnect(); - } + public TcpClient( + IClientOptions? options = null, + ILogger? logger = null) + : base(options, logger) + { + } - /// - /// for private use only, - /// to be able to check at the beginning - /// - private void _Reconnect() + internal override async Task ListenTaskActionAsync() + { + Logger?.TraceMethodCall(GetType()); + if (_reader == null) { - // check if services should stop - if (_stopServices) { return; } - - Task.Run(() => - { - Task.Delay(20).Wait(); - Close(); - if(Open()) - { - OnReconnected?.Invoke(this, new OnReconnectedEventArgs()); - } - }); + var ex = new InvalidOperationException($"{nameof(_reader)} was null!"); + Logger?.LogExceptionAsError(GetType(), ex); + await RaiseFatal(ex); + throw ex; } - public bool Send(string message) + while (IsConnected) { try { - if (!IsConnected || SendQueueLength >= Options.SendQueueCapacity) + var input = await _reader.ReadLineAsync(); + if (input is null) { - return false; + continue; } - _throttlers.SendQueue.Add(new Tuple(DateTime.UtcNow, message)); - - return true; + await RaiseMessage(new OnMessageEventArgs(input)); } - catch (Exception ex) + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) { - OnError?.Invoke(this, new OnErrorEventArgs {Exception = ex}); - throw; - } - } - - public bool SendWhisper(string message) - { - try - { - if (!IsConnected || WhisperQueueLength >= Options.WhisperQueueCapacity) - { - return false; - } - - _throttlers.WhisperQueue.Add(new Tuple(DateTime.UtcNow, message)); - - return true; + // occurs if the Tasks are canceled by the CancellationTokenSource.Token + Logger?.LogExceptionAsInformation(GetType(), ex); } catch (Exception ex) { - OnError?.Invoke(this, new OnErrorEventArgs {Exception = ex}); - throw; + Logger?.LogExceptionAsError(GetType(), ex); + await RaiseError(new OnErrorEventArgs(ex)); + break; } } + } - private void StartNetworkServices() - { - _networkServicesRunning = true; - _networkTasks = new[] - { - StartListenerTask(), - _throttlers.StartSenderTask(), - _throttlers.StartWhisperSenderTask() - }.ToArray(); - - if (!_networkTasks.Any(c => c.IsFaulted)) return; - _networkServicesRunning = false; - CleanupServices(); - } + /// + protected override async Task ClientSendAsync(string message) + { + Logger?.TraceMethodCall(GetType()); - public Task SendAsync(string message) + // this is not thread safe + // this method should only be called from 'ClientBase.Send()' + // where its call gets synchronized/locked + // https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream?view=netstandard-2.0#remarks + if (_writer == null) { - return Task.Run(async () => - { - await _writer.WriteLineAsync(message); - await _writer.FlushAsync(); - }); + var ex = new InvalidOperationException($"{nameof(_writer)} was null!"); + Logger?.LogExceptionAsError(GetType(), ex); + await RaiseFatal(ex); + throw ex; } - private Task StartListenerTask() - { - return Task.Run(async () => - { - while (IsConnected && _networkServicesRunning) - { - try - { - var input = await _reader.ReadLineAsync(); - - if (input is null && IsConnected) - { - Send("PING"); - Task.Delay(500).Wait(); - } - - OnMessage?.Invoke(this, new OnMessageEventArgs {Message = input}); - } - catch (Exception ex) - { - OnError?.Invoke(this, new OnErrorEventArgs {Exception = ex}); - } - } - }); - } + await _writer.WriteLineAsync(message); + await _writer.FlushAsync(); + } - private Task StartMonitorTask() - { - return Task.Run(() => - { - var needsReconnect = false; - var checkConnectedCounter = 0; - try + /// + protected override async Task ConnectClientAsync() + { + Logger?.TraceMethodCall(GetType()); + if (Client == null) + { + Exception ex = new InvalidOperationException($"{nameof(Client)} was null!"); + Logger?.LogExceptionAsError(GetType(), ex); + throw ex; + } + + try + { + // https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-scenarios +#if NET6_0_OR_GREATER + // within the following thread: + // https://stackoverflow.com/questions/4238345/asynchronously-wait-for-taskt-to-complete-with-timeout + // the following answer + // NET6_0_OR_GREATER: https://stackoverflow.com/a/68998339 + + var connectTask = Client.ConnectAsync(Url, Port); + var waitTask = connectTask.WaitAsync(TimeOutEstablishConnection, Token); + await Task.WhenAny(connectTask, waitTask); +#else + // within the following thread: + // https://stackoverflow.com/questions/4238345/asynchronously-wait-for-taskt-to-complete-with-timeout + // the following two answers: + // https://stackoverflow.com/a/11191070 + // https://stackoverflow.com/a/22078975 + + using (var delayTaskCancellationTokenSource = new CancellationTokenSource()) { - var lastState = IsConnected; - while (!_tokenSource.IsCancellationRequested) - { - if (lastState == IsConnected) - { - Thread.Sleep(200); - - if (!IsConnected) - NotConnectedCounter++; - else - checkConnectedCounter++; - - if (checkConnectedCounter >= 300) //Check every 60s for Response - { - Send("PING"); - checkConnectedCounter = 0; - } + var connectTask = Client.ConnectAsync(Url, Port); + var delayTask = Task.Delay( + (int)TimeOutEstablishConnection.TotalMilliseconds, + delayTaskCancellationTokenSource.Token); - switch (NotConnectedCounter) - { - case 25: //Try Reconnect after 5s - case 75: //Try Reconnect after extra 10s - case 150: //Try Reconnect after extra 15s - case 300: //Try Reconnect after extra 30s - case 600: //Try Reconnect after extra 60s - _Reconnect(); - break; - default: - { - if (NotConnectedCounter >= 1200 && NotConnectedCounter % 600 == 0) //Try Reconnect after every 120s from this point - _Reconnect(); - break; - } - } - - if (NotConnectedCounter != 0 && IsConnected) - NotConnectedCounter = 0; - - continue; - } - OnStateChanged?.Invoke(this, new OnStateChangedEventArgs { IsConnected = IsConnected, WasConnected = lastState }); - - if (IsConnected) - OnConnected?.Invoke(this, new OnConnectedEventArgs()); - - if (!IsConnected && !_stopServices) - { - if (lastState && Options.ReconnectionPolicy != null && !Options.ReconnectionPolicy.AreAttemptsComplete()) - { - needsReconnect = true; - break; - } - OnDisconnected?.Invoke(this, new OnDisconnectedEventArgs()); - } - - lastState = IsConnected; - } - } - catch (Exception ex) - { - OnError?.Invoke(this, new OnErrorEventArgs {Exception = ex}); + await Task.WhenAny(connectTask, delayTask); + delayTaskCancellationTokenSource.Cancel(); } +#endif + if (!Client.Connected) + { + Logger?.TraceAction(GetType(), "Client couldn't establish connection"); + return; + } - if (needsReconnect && !_stopServices) - _Reconnect(); - }, _tokenSource.Token); - } - - private void CleanupServices() - { - _tokenSource.Cancel(); - _tokenSource = new CancellationTokenSource(); - _throttlers.TokenSource = _tokenSource; - - if (!_stopServices) return; - if (!(_networkTasks?.Length > 0)) return; - if (Task.WaitAll(_networkTasks, 15000)) return; - - OnFatality?.Invoke(this, - new OnFatalErrorEventArgs - { - Reason = "Fatal network error. Network services fail to shut down." - }); - - // moved to Reset() - //_stopServices = false; - //_throttlers.Reconnecting = false; - //_networkServicesRunning = false; - } - - private void Reset() - { - _stopServices = false; - _throttlers.Reconnecting = false; - _networkServicesRunning = false; + Logger?.TraceAction(GetType(), "Client established connection successfully"); + Stream stream = Client.GetStream(); + if (Options.UseSsl) + { + var ssl = new SslStream(stream, false); + await ssl.AuthenticateAsClientAsync(Url); + stream = ssl; + } + _reader = new StreamReader(stream); + _writer = new StreamWriter(stream); } - - public void WhisperThrottled(OnWhisperThrottledEventArgs eventArgs) + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) { - OnWhisperThrottled?.Invoke(this, eventArgs); + // occurs if the Tasks are canceled by the CancellationTokenSource.Token + Logger?.LogExceptionAsInformation(GetType(), ex); } - - public void MessageThrottled(OnMessageThrottledEventArgs eventArgs) + catch (Exception ex) { - OnMessageThrottled?.Invoke(this, eventArgs); + Logger?.LogExceptionAsError(GetType(), ex); } + } - public void SendFailed(OnSendFailedEventArgs eventArgs) - { - OnSendFailed?.Invoke(this, eventArgs); - } + /// + protected override System.Net.Sockets.TcpClient CreateClient() + { + Logger?.TraceMethodCall(GetType()); - public void Error(OnErrorEventArgs eventArgs) + return new System.Net.Sockets.TcpClient { - OnError?.Invoke(this, eventArgs); - } + // https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.tcpclient.lingerstate?view=netstandard-2.0#remarks + LingerState = new System.Net.Sockets.LingerOption(true, 0) + }; + } - public void Dispose() - { - Close(); - _throttlers.ShouldDispose = true; - _tokenSource.Cancel(); - Thread.Sleep(500); - _tokenSource.Dispose(); - Client?.Dispose(); - GC.Collect(); - } + /// + protected override void CloseClient() + { + Logger?.TraceMethodCall(GetType()); + _reader?.Dispose(); + _writer?.Dispose(); + Client?.Dispose(); } -} +} \ No newline at end of file diff --git a/src/TwitchLib.Communication/Clients/WebsocketClient.cs b/src/TwitchLib.Communication/Clients/WebsocketClient.cs index 93c5777..516175e 100644 --- a/src/TwitchLib.Communication/Clients/WebsocketClient.cs +++ b/src/TwitchLib.Communication/Clients/WebsocketClient.cs @@ -1,402 +1,212 @@ -using System; -using System.Linq; -using System.Net.WebSockets; +using System.Net.WebSockets; using System.Text; -using System.Threading; -using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using TwitchLib.Communication.Enums; using TwitchLib.Communication.Events; +using TwitchLib.Communication.Extensions; using TwitchLib.Communication.Interfaces; -using TwitchLib.Communication.Models; -using TwitchLib.Communication.Services; -namespace TwitchLib.Communication.Clients -{ - public class WebSocketClient : IClient - { - private int NotConnectedCounter; - public TimeSpan DefaultKeepAliveInterval { get; set; } - public int SendQueueLength => _throttlers.SendQueue.Count; - public int WhisperQueueLength => _throttlers.WhisperQueue.Count; - public bool IsConnected => Client?.State == WebSocketState.Open; - public IClientOptions Options { get; } - public ClientWebSocket Client { get; private set; } - - public event EventHandler OnConnected; - public event EventHandler OnData; - public event EventHandler OnDisconnected; - public event EventHandler OnError; - public event EventHandler OnFatality; - public event EventHandler OnMessage; - public event EventHandler OnMessageThrottled; - public event EventHandler OnWhisperThrottled; - public event EventHandler OnSendFailed; - public event EventHandler OnStateChanged; - public event EventHandler OnReconnected; - - private string Url { get; } - private readonly Throttlers _throttlers; - private CancellationTokenSource _tokenSource = new CancellationTokenSource(); - private bool _stopServices; - private bool _networkServicesRunning; - private Task[] _networkTasks; - private Task _monitorTask; - - public WebSocketClient(IClientOptions options = null) - { - Options = options ?? new ClientOptions(); +namespace TwitchLib.Communication.Clients; - switch (Options.ClientType) - { - case ClientType.Chat: - Url = Options.UseSsl ? "wss://irc-ws.chat.twitch.tv:443" : "ws://irc-ws.chat.twitch.tv:80"; - break; - case ClientType.PubSub: - Url = Options.UseSsl ? "wss://pubsub-edge.twitch.tv:443" : "ws://pubsub-edge.twitch.tv:80"; - break; - default: - throw new ArgumentOutOfRangeException(); - } - - _throttlers = new Throttlers(this, Options.ThrottlingPeriod, Options.WhisperThrottlingPeriod) { TokenSource = _tokenSource }; - } - - private void InitializeClient() - { - // check if services should stop - if (_stopServices) { return; } +public class WebSocketClient : ClientBase +{ + protected override string Url { get; } - Client?.Abort(); - Client = new ClientWebSocket(); - - if (_monitorTask == null) - { - _monitorTask = StartMonitorTask(); - return; - } + public override bool IsConnected => Client?.State == WebSocketState.Open; - if (_monitorTask.IsCompleted) _monitorTask = StartMonitorTask(); - } - public bool Open() - { - // reset some boolean values - // especially _stopServices - Reset(); - // now using private _Open() - return _Open(); + public WebSocketClient( + IClientOptions? options = null, + ILogger? logger = null) + : base(options, logger) + { + switch (Options.ClientType) + { + case ClientType.Chat: + Url = Options.UseSsl ? "wss://irc-ws.chat.twitch.tv:443" : "ws://irc-ws.chat.twitch.tv:80"; + break; + case ClientType.PubSub: + Url = Options.UseSsl ? "wss://pubsub-edge.twitch.tv:443" : "ws://pubsub-edge.twitch.tv:80"; + break; + default: + var ex = new ArgumentOutOfRangeException(nameof(Options.ClientType)); + Logger?.LogExceptionAsError(GetType(), ex); + throw ex; } + } - /// - /// for private use only, - /// to be able to check at the beginning - /// - private bool _Open() + internal override async Task ListenTaskActionAsync() + { + Logger?.TraceMethodCall(GetType()); + if (Client == null) + { + var ex = new InvalidOperationException($"{nameof(Client)} was null!"); + Logger?.LogExceptionAsError(GetType(), ex); + await RaiseFatal(ex); + throw ex; + } + + var memoryStream = new MemoryStream(); + var bytes = new byte[1024]; +#if NET + var buffer = new Memory(bytes); + ValueWebSocketReceiveResult result; +#else + var buffer = new ArraySegment(bytes); + WebSocketReceiveResult result; +#endif + while (IsConnected) { - // check if services should stop - if (_stopServices) { return false; } - try { - if (IsConnected) return true; - - InitializeClient(); - Client.ConnectAsync(new Uri(Url), _tokenSource.Token).Wait(10000); - if (!IsConnected) return _Open(); - - StartNetworkServices(); - return true; + result = await Client.ReceiveAsync(buffer, Token); } - catch (WebSocketException) + catch (TaskCanceledException) { - InitializeClient(); - return false; + // Swallow any cancellation exceptions + break; } - } - - public void Close(bool callDisconnect = true) - { - Client?.Abort(); - _stopServices = callDisconnect; - CleanupServices(); - - if (!callDisconnect) - InitializeClient(); - - OnDisconnected?.Invoke(this, new OnDisconnectedEventArgs()); - } - - public void Reconnect() - { - // reset some boolean values - // especially _stopServices - Reset(); - // now using private _Reconnect() - _Reconnect(); - } - - /// - /// for private use only, - /// to be able to check at the beginning - /// - private void _Reconnect() - { - // check if services should stop - if (_stopServices) { return; } - - Task.Run(() => + catch (OperationCanceledException ex) { - Task.Delay(20).Wait(); - Close(); - if(Open()) - { - OnReconnected?.Invoke(this, new OnReconnectedEventArgs()); - } - }); - } - - public bool Send(string message) - { - try - { - if (!IsConnected || SendQueueLength >= Options.SendQueueCapacity) - { - return false; - } - - _throttlers.SendQueue.Add(new Tuple(DateTime.UtcNow, message)); - - return true; + Logger?.LogExceptionAsInformation(GetType(), ex); + break; } catch (Exception ex) { - OnError?.Invoke(this, new OnErrorEventArgs { Exception = ex }); - throw; + Logger?.LogExceptionAsError(GetType(), ex); + await RaiseError(new OnErrorEventArgs(ex)); + break; } - } - - public bool SendWhisper(string message) - { - try - { - if (!IsConnected || WhisperQueueLength >= Options.WhisperQueueCapacity) - { - return false; - } - - _throttlers.WhisperQueue.Add(new Tuple(DateTime.UtcNow, message)); - return true; - } - catch (Exception ex) + switch (result.MessageType) { - OnError?.Invoke(this, new OnErrorEventArgs { Exception = ex }); - throw; - } - } - - private void StartNetworkServices() - { - _networkServicesRunning = true; - _networkTasks = new[] - { - StartListenerTask(), - _throttlers.StartSenderTask(), - _throttlers.StartWhisperSenderTask() - }.ToArray(); - - if (!_networkTasks.Any(c => c.IsFaulted)) return; - _networkServicesRunning = false; - CleanupServices(); - } - - public Task SendAsync(byte[] message) - { - return Client.SendAsync(new ArraySegment(message), WebSocketMessageType.Text, true, _tokenSource.Token); - } - - private Task StartListenerTask() - { - return Task.Run(async () => - { - var message = ""; - - while (IsConnected && _networkServicesRunning) - { - WebSocketReceiveResult result; - var buffer = new byte[1024]; - - try - { - result = await Client.ReceiveAsync(new ArraySegment(buffer), _tokenSource.Token); - } - catch + case WebSocketMessageType.Close: + await CloseAsync(); + break; + case WebSocketMessageType.Text: + if (result.EndOfMessage && memoryStream.Position == 0) { - InitializeClient(); + //optimization when we can read the whole message at once + var message = Encoding.UTF8.GetString(bytes, 0, result.Count); + await RaiseMessage(new OnMessageEventArgs(message)); break; } - - if (result == null) continue; - - switch (result.MessageType) + memoryStream.Write(bytes, 0, result.Count); + if (result.EndOfMessage) { - case WebSocketMessageType.Close: - Close(); - break; - case WebSocketMessageType.Text when !result.EndOfMessage: - message += Encoding.UTF8.GetString(buffer).TrimEnd('\0'); - continue; - case WebSocketMessageType.Text: - message += Encoding.UTF8.GetString(buffer).TrimEnd('\0'); - OnMessage?.Invoke(this, new OnMessageEventArgs(){Message = message}); - break; - case WebSocketMessageType.Binary: - break; - default: - throw new ArgumentOutOfRangeException(); + var message = Encoding.UTF8.GetString(memoryStream.GetBuffer(), 0, (int)memoryStream.Position); + await RaiseMessage(new OnMessageEventArgs(message)); + memoryStream.Position = 0; } - - message = ""; - } - }); + break; + case WebSocketMessageType.Binary: + //todo + break; + default: + Exception ex = new ArgumentOutOfRangeException(); + Logger?.LogExceptionAsError(GetType(), ex); + throw ex; + } } + } - private Task StartMonitorTask() - { - return Task.Run(() => - { - var needsReconnect = false; - var checkConnectedCounter = 0; - try - { - var lastState = IsConnected; - while (!_tokenSource.IsCancellationRequested) - { - if (lastState == IsConnected) - { - Thread.Sleep(200); - - if (!IsConnected) - NotConnectedCounter++; - else - checkConnectedCounter++; - - if (checkConnectedCounter >= 300) //Check every 60s for Response - { - Send("PING"); - checkConnectedCounter = 0; - } - - switch (NotConnectedCounter) - { - case 25: //Try Reconnect after 5s - case 75: //Try Reconnect after extra 10s - case 150: //Try Reconnect after extra 15s - case 300: //Try Reconnect after extra 30s - case 600: //Try Reconnect after extra 60s - _Reconnect(); - break; - default: - { - if (NotConnectedCounter >= 1200 && NotConnectedCounter % 600 == 0) //Try Reconnect after every 120s from this point - _Reconnect(); - break; - } - } - - if (NotConnectedCounter != 0 && IsConnected) - NotConnectedCounter = 0; - - continue; - } - OnStateChanged?.Invoke(this, new OnStateChangedEventArgs { IsConnected = Client.State == WebSocketState.Open, WasConnected = lastState}); - - if (IsConnected) - OnConnected?.Invoke(this, new OnConnectedEventArgs()); - - if (!IsConnected && !_stopServices) - { - if (lastState && Options.ReconnectionPolicy != null && !Options.ReconnectionPolicy.AreAttemptsComplete()) - { - needsReconnect = true; - break; - } - - OnDisconnected?.Invoke(this, new OnDisconnectedEventArgs()); - if (Client.CloseStatus != null && Client.CloseStatus != WebSocketCloseStatus.NormalClosure) - OnError?.Invoke(this, new OnErrorEventArgs { Exception = new Exception(Client.CloseStatus + " " + Client.CloseStatusDescription) }); - } - - lastState = IsConnected; - } - } - catch (Exception ex) - { - OnError?.Invoke(this, new OnErrorEventArgs { Exception = ex }); - } - - if (needsReconnect && !_stopServices) - _Reconnect(); - }, _tokenSource.Token); - } + /// + protected override async Task ClientSendAsync(string message) + { + Logger?.TraceMethodCall(GetType()); + + // this is not thread safe + // this method should only be called from 'ClientBase.Send()' + // where its call gets synchronized/locked + // https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream?view=netstandard-2.0#remarks + + // https://stackoverflow.com/a/59619916 + // links from within this thread: + // the 4th point: https://www.codetinkerer.com/2018/06/05/aspnet-core-websockets.html + // https://github.com/dotnet/corefx/blob/d6b11250b5113664dd3701c25bdf9addfacae9cc/src/Common/src/System/Net/WebSockets/ManagedWebSocket.cs#L22-L28 + if (Client == null) + { + var ex = new InvalidOperationException($"{nameof(Client)} was null!"); + Logger?.LogExceptionAsError(GetType(), ex); + await RaiseFatal(ex); + throw ex; + } + + var bytes = Encoding.UTF8.GetBytes(message); + await Client.SendAsync(new ArraySegment(bytes), + WebSocketMessageType.Text, + true, + Token); + } - private void CleanupServices() - { - _tokenSource.Cancel(); - _tokenSource = new CancellationTokenSource(); - _throttlers.TokenSource = _tokenSource; - - if (!_stopServices) return; - if (!(_networkTasks?.Length > 0)) return; - if (Task.WaitAll(_networkTasks, 15000)) return; - - OnFatality?.Invoke(this, - new OnFatalErrorEventArgs + /// + protected override async Task ConnectClientAsync() + { + Logger?.TraceMethodCall(GetType()); + if (Client == null) + { + var ex = new InvalidOperationException($"{nameof(Client)} was null!"); + Logger?.LogExceptionAsError(GetType(), ex); + await RaiseFatal(ex); + throw ex; + } + + try + { + // https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-scenarios +#if NET6_0_OR_GREATER + // within the following thread: + // https://stackoverflow.com/questions/4238345/asynchronously-wait-for-taskt-to-complete-with-timeout + // the following answer + // NET6_0_OR_GREATER: https://stackoverflow.com/a/68998339 + var connectTask = Client.ConnectAsync(new Uri(Url), Token); + var waitTask = connectTask.WaitAsync(TimeOutEstablishConnection, Token); + await Task.WhenAny(connectTask, waitTask); +#else + // within the following thread: + // https://stackoverflow.com/questions/4238345/asynchronously-wait-for-taskt-to-complete-with-timeout + // the following two answers: + // https://stackoverflow.com/a/11191070 + // https://stackoverflow.com/a/22078975 + + using (var delayTaskCancellationTokenSource = new CancellationTokenSource()) { - Reason = "Fatal network error. Network services fail to shut down." - }); - - // moved to Reset() - //_stopServices = false; - //_throttlers.Reconnecting = false; - //_networkServicesRunning = false; - } - - private void Reset() - { - this._stopServices = false; - this._throttlers.Reconnecting = false; - this._networkServicesRunning = false; - } + var connectTask = Client.ConnectAsync(new Uri(Url), Token); + var delayTask = Task.Delay( + (int)TimeOutEstablishConnection.TotalMilliseconds, + delayTaskCancellationTokenSource.Token); - public void WhisperThrottled(OnWhisperThrottledEventArgs eventArgs) - { - OnWhisperThrottled?.Invoke(this, eventArgs); + await Task.WhenAny(connectTask, delayTask); + delayTaskCancellationTokenSource.Cancel(); + } +#endif + if (!IsConnected) + { + Logger?.TraceAction(GetType(), "Client couldn't establish connection"); + } } - - public void MessageThrottled(OnMessageThrottledEventArgs eventArgs) + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) { - OnMessageThrottled?.Invoke(this, eventArgs); + // occurs if the Tasks are canceled by the CancellationTokenSource.Token + Logger?.LogExceptionAsInformation(GetType(), ex); } - - public void SendFailed(OnSendFailedEventArgs eventArgs) + catch (Exception ex) { - OnSendFailed?.Invoke(this, eventArgs); + Logger?.LogExceptionAsError(GetType(), ex); } + } - public void Error(OnErrorEventArgs eventArgs) - { - OnError?.Invoke(this, eventArgs); - } + /// + protected override ClientWebSocket CreateClient() + { + Logger?.TraceMethodCall(GetType()); + return new ClientWebSocket(); + } - public void Dispose() - { - Close(); - _throttlers.ShouldDispose = true; - _tokenSource.Cancel(); - Thread.Sleep(500); - _tokenSource.Dispose(); - Client?.Dispose(); - GC.Collect(); - } + /// + protected override void CloseClient() + { + Logger?.TraceMethodCall(GetType()); + Client?.Abort(); + Client?.Dispose(); } -} +} \ No newline at end of file diff --git a/src/TwitchLib.Communication/Enums/ClientType.cs b/src/TwitchLib.Communication/Enums/ClientType.cs index 706cdcc..7e8bea7 100644 --- a/src/TwitchLib.Communication/Enums/ClientType.cs +++ b/src/TwitchLib.Communication/Enums/ClientType.cs @@ -1,8 +1,7 @@ -namespace TwitchLib.Communication.Enums +namespace TwitchLib.Communication.Enums; + +public enum ClientType { - public enum ClientType - { - Chat, - PubSub - } + Chat, + PubSub, } diff --git a/src/TwitchLib.Communication/Events/CoreEvents.cs b/src/TwitchLib.Communication/Events/CoreEvents.cs new file mode 100644 index 0000000..3a31924 --- /dev/null +++ b/src/TwitchLib.Communication/Events/CoreEvents.cs @@ -0,0 +1,8 @@ +namespace TwitchLib.Communication.Events; + +/* +* Custom implementation of asynchronous event handler +* This is useful to properly and safely handle async Tasks +* Reference: https://medium.com/@a.lyskawa/the-hitchhiker-guide-to-asynchronous-events-in-c-e9840109fb53 +*/ +public delegate Task AsyncEventHandler(object? sender, TEventArgs e); diff --git a/src/TwitchLib.Communication/Events/OnConnectedEventArgs.cs b/src/TwitchLib.Communication/Events/OnConnectedEventArgs.cs index dc1e053..50ddb21 100644 --- a/src/TwitchLib.Communication/Events/OnConnectedEventArgs.cs +++ b/src/TwitchLib.Communication/Events/OnConnectedEventArgs.cs @@ -1,7 +1,3 @@ -using System; +namespace TwitchLib.Communication.Events; -namespace TwitchLib.Communication.Events -{ - public class OnConnectedEventArgs : EventArgs - { } -} +public class OnConnectedEventArgs : EventArgs { } diff --git a/src/TwitchLib.Communication/Events/OnDataEventArgs.cs b/src/TwitchLib.Communication/Events/OnDataEventArgs.cs deleted file mode 100644 index a470734..0000000 --- a/src/TwitchLib.Communication/Events/OnDataEventArgs.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; - -namespace TwitchLib.Communication.Events -{ - public class OnDataEventArgs : EventArgs - { - public byte[] Data; - } -} diff --git a/src/TwitchLib.Communication/Events/OnDisconnectedEventArgs.cs b/src/TwitchLib.Communication/Events/OnDisconnectedEventArgs.cs index 9beb085..bec279e 100644 --- a/src/TwitchLib.Communication/Events/OnDisconnectedEventArgs.cs +++ b/src/TwitchLib.Communication/Events/OnDisconnectedEventArgs.cs @@ -1,7 +1,3 @@ -using System; +namespace TwitchLib.Communication.Events; -namespace TwitchLib.Communication.Events -{ - public class OnDisconnectedEventArgs : EventArgs - { } -} +public class OnDisconnectedEventArgs : EventArgs { } diff --git a/src/TwitchLib.Communication/Events/OnErrorEventArgs.cs b/src/TwitchLib.Communication/Events/OnErrorEventArgs.cs index 3d630dc..49a3217 100644 --- a/src/TwitchLib.Communication/Events/OnErrorEventArgs.cs +++ b/src/TwitchLib.Communication/Events/OnErrorEventArgs.cs @@ -1,9 +1,11 @@ -using System; +namespace TwitchLib.Communication.Events; -namespace TwitchLib.Communication.Events +public class OnErrorEventArgs : EventArgs { - public class OnErrorEventArgs : EventArgs + public Exception Exception { get; } + + public OnErrorEventArgs(Exception exception) { - public Exception Exception { get; set; } + Exception = exception; } } diff --git a/src/TwitchLib.Communication/Events/OnFatalErrorEventArgs.cs b/src/TwitchLib.Communication/Events/OnFatalErrorEventArgs.cs index f473d1d..47cf586 100644 --- a/src/TwitchLib.Communication/Events/OnFatalErrorEventArgs.cs +++ b/src/TwitchLib.Communication/Events/OnFatalErrorEventArgs.cs @@ -1,9 +1,16 @@ -using System; +namespace TwitchLib.Communication.Events; -namespace TwitchLib.Communication.Events +public class OnFatalErrorEventArgs : EventArgs { - public class OnFatalErrorEventArgs : EventArgs + public string Reason { get; } + + public OnFatalErrorEventArgs(string reason) + { + Reason = reason; + } + + public OnFatalErrorEventArgs(Exception e) { - public string Reason; + Reason = e.ToString(); } -} +} \ No newline at end of file diff --git a/src/TwitchLib.Communication/Events/OnMessageEventArgs.cs b/src/TwitchLib.Communication/Events/OnMessageEventArgs.cs index ab56142..934b896 100644 --- a/src/TwitchLib.Communication/Events/OnMessageEventArgs.cs +++ b/src/TwitchLib.Communication/Events/OnMessageEventArgs.cs @@ -1,9 +1,11 @@ -using System; +namespace TwitchLib.Communication.Events; -namespace TwitchLib.Communication.Events +public class OnMessageEventArgs : EventArgs { - public class OnMessageEventArgs : EventArgs + public string Message { get; } + + public OnMessageEventArgs(string message) { - public string Message; + Message = message; } } diff --git a/src/TwitchLib.Communication/Events/OnMessageThrottledEventArgs.cs b/src/TwitchLib.Communication/Events/OnMessageThrottledEventArgs.cs deleted file mode 100644 index ce7e692..0000000 --- a/src/TwitchLib.Communication/Events/OnMessageThrottledEventArgs.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace TwitchLib.Communication.Events -{ - public class OnMessageThrottledEventArgs : EventArgs - { - public string Message { get; set; } - public int SentMessageCount { get; set; } - public TimeSpan Period { get; set; } - public int AllowedInPeriod { get; set; } - } -} diff --git a/src/TwitchLib.Communication/Events/OnReconnectedEventArgs.cs b/src/TwitchLib.Communication/Events/OnReconnectedEventArgs.cs deleted file mode 100644 index 89a496c..0000000 --- a/src/TwitchLib.Communication/Events/OnReconnectedEventArgs.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System; - -namespace TwitchLib.Communication.Events -{ - public class OnReconnectedEventArgs : EventArgs - { } -} diff --git a/src/TwitchLib.Communication/Events/OnSendFailedEventArgs.cs b/src/TwitchLib.Communication/Events/OnSendFailedEventArgs.cs index 79812e7..4b0c4c9 100644 --- a/src/TwitchLib.Communication/Events/OnSendFailedEventArgs.cs +++ b/src/TwitchLib.Communication/Events/OnSendFailedEventArgs.cs @@ -1,10 +1,14 @@ -using System; +namespace TwitchLib.Communication.Events; -namespace TwitchLib.Communication.Events +public class OnSendFailedEventArgs : EventArgs { - public class OnSendFailedEventArgs : EventArgs + public string Message { get; } + + public Exception Exception { get; } + + public OnSendFailedEventArgs(Exception exception, string message) { - public string Data; - public Exception Exception; + Exception = exception; + Message = message; } } diff --git a/src/TwitchLib.Communication/Events/OnStateChangedEventArgs.cs b/src/TwitchLib.Communication/Events/OnStateChangedEventArgs.cs deleted file mode 100644 index f3bf3f1..0000000 --- a/src/TwitchLib.Communication/Events/OnStateChangedEventArgs.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; - -namespace TwitchLib.Communication.Events -{ - public class OnStateChangedEventArgs : EventArgs - { - public bool IsConnected; - public bool WasConnected; - } -} diff --git a/src/TwitchLib.Communication/Events/OnWhisperThrottledEventArgs.cs b/src/TwitchLib.Communication/Events/OnWhisperThrottledEventArgs.cs deleted file mode 100644 index 4c985de..0000000 --- a/src/TwitchLib.Communication/Events/OnWhisperThrottledEventArgs.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace TwitchLib.Communication.Events -{ - public class OnWhisperThrottledEventArgs : EventArgs - { - public string Message { get; set; } - public int SentWhisperCount { get; set; } - public TimeSpan Period { get; set; } - public int AllowedInPeriod { get; set; } - } -} diff --git a/src/TwitchLib.Communication/Extensions/LogExtensions.cs b/src/TwitchLib.Communication/Extensions/LogExtensions.cs new file mode 100644 index 0000000..8be0a16 --- /dev/null +++ b/src/TwitchLib.Communication/Extensions/LogExtensions.cs @@ -0,0 +1,32 @@ +#pragma warning disable SYSLIB1006 // Multiple logging methods cannot use the same event id within a class +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; + +namespace TwitchLib.Communication.Extensions +{ + /// + /// expensive Extensions of the + /// + internal static partial class LogExtensions + { + public static void TraceMethodCall(this ILogger logger, Type type, [CallerMemberName] string callerMemberName = "", [CallerLineNumber] int callerLineNumber = 0) + { + // because of the code-formatting, 2 line is subtracted from the callerLineNumber + // cant be done inline! + callerLineNumber -= 2; + TraceMethodCallCore(logger, type, callerMemberName, callerLineNumber); + } + + [LoggerMessage(0, LogLevel.Trace, "{type}.{callerMemberName} at line {callerLineNumber} is called")] + static partial void TraceMethodCallCore(this ILogger logger, Type type, string callerMemberName, int callerLineNumber); + + [LoggerMessage(0, LogLevel.Error, "Exception in {type}.{callerMemberName} at line {callerLineNumber}")] + public static partial void LogExceptionAsError(this ILogger logger, Type type, Exception exception, [CallerMemberName] string callerMemberName = "", [CallerLineNumber] int callerLineNumber = 0); + + [LoggerMessage(0, LogLevel.Information, "Exception in {type}.{callerMemberName} at line {callerLineNumber}")] + public static partial void LogExceptionAsInformation(this ILogger logger, Type type, Exception exception, [CallerMemberName] string callerMemberName = "", [CallerLineNumber] int callerLineNumber = 0); + + [LoggerMessage(0, LogLevel.Trace, "{type}.{callerMemberName} at line {callerLineNumber}: {action}")] + public static partial void TraceAction(this ILogger logger, Type type, string action, [CallerMemberName] string callerMemberName = "", [CallerLineNumber] int callerLineNumber = 0); + } +} diff --git a/src/TwitchLib.Communication/Interfaces/IClient.cs b/src/TwitchLib.Communication/Interfaces/IClient.cs index 691be31..1edd023 100644 --- a/src/TwitchLib.Communication/Interfaces/IClient.cs +++ b/src/TwitchLib.Communication/Interfaces/IClient.cs @@ -1,129 +1,103 @@ -using System; -using TwitchLib.Communication.Events; +using TwitchLib.Communication.Events; -namespace TwitchLib.Communication.Interfaces -{ - public interface IClient - { - /// - /// Keep alive period for the Connection. Not needed in TCP. - /// - TimeSpan DefaultKeepAliveInterval { get; set; } - - /// - /// The current number of items waiting to be sent. - /// - int SendQueueLength { get; } - - /// - /// The current number of Whispers waiting to be sent. - /// - int WhisperQueueLength { get; } - - /// - /// The current state of the connection. - /// - bool IsConnected { get; } - - /// - /// Client Configuration Options - /// - IClientOptions Options {get;} - - /// - /// Fires when the Client has connected - /// - event EventHandler OnConnected; - - /// - /// Fires when Data (ByteArray) is received. - /// - event EventHandler OnData; - - /// - /// Fires when the Client disconnects - /// - event EventHandler OnDisconnected; - - /// - /// Fires when An Exception Occurs in the client - /// - event EventHandler OnError; - - /// - /// Fires when a Fatal Error Occurs. - /// - event EventHandler OnFatality; - - /// - /// Fires when a Message/ group of messages is received. - /// - event EventHandler OnMessage; - - /// - /// Fires when a Message has been throttled. - /// - event EventHandler OnMessageThrottled; +namespace TwitchLib.Communication.Interfaces; - /// - /// Fires when a Whisper has been throttled. - /// - event EventHandler OnWhisperThrottled; - - /// - /// Fires when a message Send event failed. - /// - event EventHandler OnSendFailed; - - /// - /// Fires when the connection state changes - /// - event EventHandler OnStateChanged; - - /// - /// Fires when the client reconnects automatically - /// - event EventHandler OnReconnected; - - /// - /// Disconnect the Client from the Server - /// Set disconnect called in the client. Used in test cases. (default true) - /// - void Close(bool callDisconnect = true); - - /// - /// Dispose the Client. Forces the Send Queue to be destroyed, resulting in Message Loss. - /// - void Dispose(); - - /// - /// Connect the Client to the requested Url. - /// - /// Returns True if Connected, False if Failed to Connect. - bool Open(); - - /// - /// Queue a Message to Send to the server as a String. - /// - /// The Message To Queue - /// Returns True if was successfully queued. False if it fails. - bool Send(string message); - - /// - /// Queue a Whisper to Send to the server as a String. - /// - /// The Whisper To Queue - /// Returns True if was successfully queued. False if it fails. - bool SendWhisper(string message); - - /// - /// Manually reconnects the client. - /// - void Reconnect(); - - void MessageThrottled(OnMessageThrottledEventArgs eventArgs); - void SendFailed(OnSendFailedEventArgs eventArgs); - void Error(OnErrorEventArgs eventArgs); - void WhisperThrottled(OnWhisperThrottledEventArgs eventArgs); - } -} \ No newline at end of file +public interface IClient : IDisposable +{ + /// + /// The current state of the connection. + /// + bool IsConnected { get; } + + /// + /// Client Configuration Options + /// + IClientOptions Options { get; } + + /// + /// Fires when the Client has connected + /// + event AsyncEventHandler? OnConnected; + + /// + /// Fires when the Client disconnects + /// + event AsyncEventHandler? OnDisconnected; + + /// + /// Fires when An Exception Occurs in the client + /// + event AsyncEventHandler? OnError; + + /// + /// Fires when a Fatal Error Occurs. + /// + event AsyncEventHandler? OnFatality; + + /// + /// Fires when a Message/ group of messages is received. + /// + event AsyncEventHandler? OnMessage; + + /// + /// Fires when a message Send event failed. + /// + event AsyncEventHandler? OnSendFailed; + + /// + /// Fires when the client reconnects automatically + /// + event AsyncEventHandler? OnReconnected; + + /// + /// tries to connect to twitch according to ! + /// + /// + /// if a connection could be established, otherwise + /// + Task OpenAsync(); + + /// + /// if the underlying Client is connected, + ///

+ /// is invoked + ///

+ /// before it makes a call to and + ///

+ ///

+ /// this Method is also used by 'TwitchLib.Client.TwitchClient' + ///

+ /// whenever it receives a Reconnect-Message + ///

+ ///

+ /// so, if the twitch-servers want us to reconnect, + ///

+ /// we have to close the connection and establish a new ones + ///

+ ///

+ /// can also be used for a manual reconnect + ///
+ /// + /// , if the client reconnected; otherwise + /// + Task ReconnectAsync(); + + /// + /// stops everything + /// and waits for the via given amount of milliseconds + /// + Task CloseAsync(); + + /// + /// Sends the given irc- + /// + /// + /// irc-message to send + /// + /// + /// , if the message was sent + ///

+ /// otherwise + ///
+ Task SendAsync(string message); +} diff --git a/src/TwitchLib.Communication/Interfaces/IClientOptions.cs b/src/TwitchLib.Communication/Interfaces/IClientOptions.cs index 20d373f..e845484 100644 --- a/src/TwitchLib.Communication/Interfaces/IClientOptions.cs +++ b/src/TwitchLib.Communication/Interfaces/IClientOptions.cs @@ -1,71 +1,28 @@ -using System; -using TwitchLib.Communication.Enums; +using TwitchLib.Communication.Enums; using TwitchLib.Communication.Models; -namespace TwitchLib.Communication.Interfaces -{ - public interface IClientOptions - { - /// - /// Type of the Client to Create. Possible Types Chat or PubSub. - /// - ClientType ClientType { get; set; } - - /// - /// How long to wait on a clean disconnect [in ms] (default 20000ms). - /// - int DisconnectWait { get; set; } - - /// - /// Number of Messages Allowed Per Instance of the Throttling Period. (default 100) - /// - int MessagesAllowedInPeriod { get; set; } - - /// - /// Reconnection Policy Settings. Reconnect without Losing data etc. - /// The Default Policy applied is 10 reconnection attempts with 3 seconds between each attempt. - /// - ReconnectionPolicy ReconnectionPolicy { get; set; } - - /// - /// The amount of time an object can wait to be sent before it is considered dead, and should be skipped (default 30 minutes). - /// A dead item will be ignored and removed from the send queue when it is hit. - /// - TimeSpan SendCacheItemTimeout { get; set; } - - /// - /// Minimum time between sending items from the queue [in ms] (default 50ms). - /// - ushort SendDelay { get; set; } +namespace TwitchLib.Communication.Interfaces; - /// - /// Maximum number of Queued outgoing messages (default 10000). - /// - int SendQueueCapacity { get; set; } - - /// - /// Period Between each reset of the throttling instance window. (default 30s) - /// - TimeSpan ThrottlingPeriod { get; set; } - - /// - /// Use Secure Connection [SSL] (default: true) - /// - bool UseSsl { get; set; } - - /// - /// Period Between each reset of the whisper throttling instance window. (default 60s) - /// - TimeSpan WhisperThrottlingPeriod { get; set; } - - /// - /// Number of Whispers Allowed to be sent Per Instance of the Throttling Period. (default 100) - /// - int WhispersAllowedInPeriod { get; set; } - - /// - /// Maximum number of Queued outgoing Whispers (default 10000). - /// - int WhisperQueueCapacity { get; set; } - } -} \ No newline at end of file +public interface IClientOptions +{ + /// + /// Type of the Client to Create. Possible Types Chat or PubSub. + /// + ClientType ClientType { get; } + + /// + /// How long to wait on a clean disconnect [in ms] (default 1_500ms). + /// + uint DisconnectWait { get; } + + /// + /// Reconnection Policy Settings. Reconnect without Losing data etc. + /// The Default Policy applied is 10 reconnection attempts with 3 seconds between each attempt. + /// + ReconnectionPolicy ReconnectionPolicy { get; } + + /// + /// Use Secure Connection [SSL] (default: true) + /// + bool UseSsl { get; } +} diff --git a/src/TwitchLib.Communication/Models/ClientOptions.cs b/src/TwitchLib.Communication/Models/ClientOptions.cs index 6ad91f7..0741bf3 100644 --- a/src/TwitchLib.Communication/Models/ClientOptions.cs +++ b/src/TwitchLib.Communication/Models/ClientOptions.cs @@ -1,22 +1,47 @@ -using System; -using TwitchLib.Communication.Enums; +using TwitchLib.Communication.Enums; using TwitchLib.Communication.Interfaces; -namespace TwitchLib.Communication.Models +namespace TwitchLib.Communication.Models; + +public class ClientOptions : IClientOptions { - public class ClientOptions : IClientOptions + /// + public ReconnectionPolicy ReconnectionPolicy { get; } + + /// + public bool UseSsl { get; } + + /// + public uint DisconnectWait { get; } + + /// + public ClientType ClientType { get; } + + /// + /// + /// + /// your own + ///

+ /// by leaving it , a , that makes every 3_000ms one attempt to connect for ten times, is going to be applied + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public ClientOptions( + ReconnectionPolicy? reconnectionPolicy = null, + bool useSsl = true, + uint disconnectWait = 1_500, + ClientType clientType = ClientType.Chat) { - public int SendQueueCapacity { get; set; } = 10000; - public TimeSpan SendCacheItemTimeout { get; set; } = TimeSpan.FromMinutes(30); - public ushort SendDelay { get; set; } = 50; - public ReconnectionPolicy ReconnectionPolicy { get; set; } = new ReconnectionPolicy(3000, maxAttempts: 10); - public bool UseSsl { get; set; } = true; - public int DisconnectWait { get; set; } = 20000; - public ClientType ClientType { get; set; } = ClientType.Chat; - public TimeSpan ThrottlingPeriod { get; set; } = TimeSpan.FromSeconds(30); - public int MessagesAllowedInPeriod { get; set; } = 100; - public TimeSpan WhisperThrottlingPeriod { get; set; } = TimeSpan.FromSeconds(60); - public int WhispersAllowedInPeriod { get; set; } = 100; - public int WhisperQueueCapacity { get; set; } = 10000; + ReconnectionPolicy = reconnectionPolicy ?? new ReconnectionPolicy(3_000, maxAttempts: 10); + UseSsl = useSsl; + DisconnectWait = disconnectWait; + ClientType = clientType; } -} +} \ No newline at end of file diff --git a/src/TwitchLib.Communication/Models/NoReconnectionPolicy.cs b/src/TwitchLib.Communication/Models/NoReconnectionPolicy.cs new file mode 100644 index 0000000..70b4df1 --- /dev/null +++ b/src/TwitchLib.Communication/Models/NoReconnectionPolicy.cs @@ -0,0 +1,14 @@ +namespace TwitchLib.Communication.Models; + +/// +/// This policy should be used to omit reconnect-attempts. +/// +public class NoReconnectionPolicy : ReconnectionPolicy +{ + public NoReconnectionPolicy() + : base( + reconnectInterval: 0, + maxAttempts: 1) + { + } +} diff --git a/src/TwitchLib.Communication/Models/ReconnectionPolicy.cs b/src/TwitchLib.Communication/Models/ReconnectionPolicy.cs index 1041670..3bb1a2d 100644 --- a/src/TwitchLib.Communication/Models/ReconnectionPolicy.cs +++ b/src/TwitchLib.Communication/Models/ReconnectionPolicy.cs @@ -1,93 +1,197 @@ -namespace TwitchLib.Communication.Models -{ - public class ReconnectionPolicy - { - private readonly int _reconnectStepInterval; - private readonly int? _initMaxAttempts; - private int _minReconnectInterval; - private readonly int _maxReconnectInterval; - private int? _maxAttempts; - private int _attemptsMade; +namespace TwitchLib.Communication.Models; - public ReconnectionPolicy() - { - _reconnectStepInterval = 3000; - _minReconnectInterval = 3000; - _maxReconnectInterval = 30000; - _maxAttempts = null; - _initMaxAttempts = null; - _attemptsMade = 0; - } +/// +/// Connection/Reconnection-Policy +///

+///

+/// controls the attempts to make to connect and to reconnect to twitch +///

+///

+/// to omit reconnects and to only make one attempt to connect to twitch, please use +///
+public class ReconnectionPolicy +{ + private readonly int _reconnectStepInterval; + private readonly int? _initMaxAttempts; + private int _currentReconnectInterval; + private readonly int _maxReconnectInterval; + private int? _maxAttempts; + private int _attemptsMade; - public void SetMaxAttempts(int attempts) - { - _maxAttempts = attempts; - } + /// + /// the or + /// infinitely + /// attempts to reconnect + ///

+ ///

+ /// with each attempt, the reconnect interval increases by 3_000 milliseconds + /// until it reaches 30_000 milliseconds + ///

+ /// + ///

+ ///

+ /// Example: + ///

+ /// try to connect -> couldn't connect -> wait 3_000 milliseconds -> try to connect -> couldn't connect -> wait 6_000 milliseconds -> and so on + ///
+ public ReconnectionPolicy() + { + _reconnectStepInterval = 3_000; + _currentReconnectInterval = _reconnectStepInterval; + _maxReconnectInterval = 30_000; + _maxAttempts = null; + _initMaxAttempts = null; + _attemptsMade = 0; + } - public void Reset() - { - _attemptsMade = 0; - _minReconnectInterval = _reconnectStepInterval; - _maxAttempts = _initMaxAttempts; - } + /// + /// the or + /// attempts to reconnect for times + ///

+ ///

+ /// with each attempt, the reconnect interval increases by the amount of + /// until it reaches + ///

+ ///

+ /// Example: + ///

+ /// = 3_000 + ///

+ /// = 30_000 + ///

+ /// try to connect -> couldnt connect -> wait 3_000 milliseconds -> try to connect -> couldnt connect -> wait 6_000 milliseconds -> and so on + ///
+ /// + /// minimum interval in milliseconds + /// + /// + /// maximum interval in milliseconds + /// + /// + /// means infinite; it never stops to try to reconnect + /// + public ReconnectionPolicy( + int minReconnectInterval, + int maxReconnectInterval, + int maxAttempts) + { + _reconnectStepInterval = minReconnectInterval; + _currentReconnectInterval = minReconnectInterval > maxReconnectInterval + ? maxReconnectInterval + : minReconnectInterval; + _maxReconnectInterval = maxReconnectInterval; + _maxAttempts = maxAttempts; + _initMaxAttempts = maxAttempts; + _attemptsMade = 0; + } - public void SetAttemptsMade(int count) => _attemptsMade = count; + /// + /// the or + /// infinitely + /// attempts to reconnect + ///

+ ///

+ /// with each attempt, the reconnect interval increases by the amount of + /// until it reaches + ///

+ ///

+ /// Example: + ///

+ /// = 3_000 + ///

+ /// = 30_000 + ///

+ /// try to connect -> couldn't connect -> wait 3_000 milliseconds -> try to connect -> couldn't connect -> wait 6_000 milliseconds -> and so on + ///
+ /// + /// minimum interval in milliseconds + /// + /// + /// maximum interval in milliseconds + /// + public ReconnectionPolicy( + int minReconnectInterval, + int maxReconnectInterval) + { + _reconnectStepInterval = minReconnectInterval; + _currentReconnectInterval = minReconnectInterval > maxReconnectInterval + ? maxReconnectInterval + : minReconnectInterval; + _maxReconnectInterval = maxReconnectInterval; + _maxAttempts = null; + _initMaxAttempts = null; + _attemptsMade = 0; + } - public ReconnectionPolicy(int minReconnectInterval, int maxReconnectInterval, int? maxAttempts) - { - _reconnectStepInterval = minReconnectInterval; - _minReconnectInterval = minReconnectInterval > maxReconnectInterval - ? maxReconnectInterval - : minReconnectInterval; - _maxReconnectInterval = maxReconnectInterval; - _maxAttempts = maxAttempts; - _initMaxAttempts = maxAttempts; - _attemptsMade = 0; - } + /// + /// the or + /// infinitely + /// attempts to reconnect every -milliseconds + /// + /// + /// Interval in milliseconds between trying to reconnect + /// + public ReconnectionPolicy(int reconnectInterval) + { + _reconnectStepInterval = reconnectInterval; + _currentReconnectInterval = reconnectInterval; + _maxReconnectInterval = reconnectInterval; + _maxAttempts = null; + _initMaxAttempts = null; + _attemptsMade = 0; + } - public ReconnectionPolicy(int minReconnectInterval, int maxReconnectInterval) - { - _reconnectStepInterval = minReconnectInterval; - _minReconnectInterval = minReconnectInterval > maxReconnectInterval - ? maxReconnectInterval - : minReconnectInterval; - _maxReconnectInterval = maxReconnectInterval; - _maxAttempts = null; - _initMaxAttempts = null; - _attemptsMade = 0; - } + /// + /// the or + /// attempts to reconnect every -milliseconds for times + /// + /// + /// Interval in milliseconds between trying to reconnect + /// + /// + /// means infinite; it never stops to try to reconnect + /// + public ReconnectionPolicy( + int reconnectInterval, + int? maxAttempts) + { + _reconnectStepInterval = reconnectInterval; + _currentReconnectInterval = reconnectInterval; + _maxReconnectInterval = reconnectInterval; + _maxAttempts = maxAttempts; + _initMaxAttempts = maxAttempts; + _attemptsMade = 0; + } - public ReconnectionPolicy(int reconnectInterval) - { - _reconnectStepInterval = reconnectInterval; - _minReconnectInterval = reconnectInterval; - _maxReconnectInterval = reconnectInterval; - _maxAttempts = null; - _initMaxAttempts = null; - _attemptsMade = 0; - } + internal void Reset(bool isReconnect) + { + if (isReconnect) return; + _attemptsMade = 0; + _currentReconnectInterval = _reconnectStepInterval; + _maxAttempts = _initMaxAttempts; + } - public ReconnectionPolicy(int reconnectInterval, int? maxAttempts) + internal void ProcessValues() + { + _attemptsMade++; + if (_currentReconnectInterval < _maxReconnectInterval) { - _reconnectStepInterval = reconnectInterval; - _minReconnectInterval = reconnectInterval; - _maxReconnectInterval = reconnectInterval; - _maxAttempts = maxAttempts; - _initMaxAttempts = maxAttempts; - _attemptsMade = 0; + _currentReconnectInterval += _reconnectStepInterval; } - internal void ProcessValues() + if (_currentReconnectInterval > _maxReconnectInterval) { - _attemptsMade++; - if (_minReconnectInterval < _maxReconnectInterval) - _minReconnectInterval += _reconnectStepInterval; - if (_minReconnectInterval > _maxReconnectInterval) - _minReconnectInterval = _maxReconnectInterval; + _currentReconnectInterval = _maxReconnectInterval; } + } - public int GetReconnectInterval() => _minReconnectInterval; + public int GetReconnectInterval() + { + return _currentReconnectInterval; + } - public bool AreAttemptsComplete() => _attemptsMade == _maxAttempts; + public bool AreAttemptsComplete() + { + return _attemptsMade == _maxAttempts; } -} \ No newline at end of file +} diff --git a/src/TwitchLib.Communication/Services/ConnectionWatchDog.cs b/src/TwitchLib.Communication/Services/ConnectionWatchDog.cs new file mode 100644 index 0000000..9776b29 --- /dev/null +++ b/src/TwitchLib.Communication/Services/ConnectionWatchDog.cs @@ -0,0 +1,127 @@ +using Microsoft.Extensions.Logging; +using TwitchLib.Communication.Clients; +using TwitchLib.Communication.Events; +using TwitchLib.Communication.Extensions; + +namespace TwitchLib.Communication.Services; + +/// +/// Service that checks connection state. +/// +internal class ConnectionWatchDog where T : IDisposable +{ + private readonly ILogger? _logger; + private readonly ClientBase _client; + + /// + /// + /// + /// should only be set to a new instance in + /// + /// + /// should only be set to in + /// + /// + /// + private CancellationTokenSource? _cancellationTokenSource; + + private const int MonitorTaskDelayInMilliseconds = 200; + + public bool IsRunning { get; private set; } + + internal ConnectionWatchDog( + ClientBase client, + ILogger? logger = null) + { + _logger = logger; + _client = client; + } + + internal Task StartMonitorTaskAsync() + { + _logger?.TraceMethodCall(GetType()); + // We dont want to start more than one WatchDog + if (_cancellationTokenSource != null) + { + Exception ex = new InvalidOperationException("Monitor Task cant be started more than once!"); + _logger?.LogExceptionAsError(GetType(), ex); + throw ex; + } + + // This should be the only place where a new instance of CancellationTokenSource is set + _cancellationTokenSource = new CancellationTokenSource(); + + IsRunning = true; + return Task.Run(MonitorTaskActionAsync, _cancellationTokenSource.Token); + } + + internal async Task StopAsync() + { + IsRunning = false; + _logger?.TraceMethodCall(GetType()); + _cancellationTokenSource?.Cancel(); + // give MonitorTaskAction a chance to catch cancellation + // otherwise it may result in an Exception + await Task.Delay(MonitorTaskDelayInMilliseconds * 2); + _cancellationTokenSource?.Dispose(); + // set it to null for the check within this.StartMonitorTask() + _cancellationTokenSource = null; + } + + private async Task MonitorTaskActionAsync() + { + _logger?.TraceMethodCall(GetType()); + try + { + while (_cancellationTokenSource != null && + !_cancellationTokenSource.Token.IsCancellationRequested) + { + // we expect the client is connected, + // when this monitor task starts + // cause BaseClient.Open() starts NetworkServices after a connection could be established + if (!_client.IsConnected) + { + _logger?.TraceAction(GetType(), "Client isn't connected anymore"); + // no call to close needed, + // ReconnectInternal() calls the correct Close-Method within the Client + // ReconnectInternal() makes attempts to reconnect according to the ReconnectionPolicy within the IClientOptions + _logger?.TraceAction(GetType(), "Try to reconnect"); + + var connected = await _client.ReconnectInternalAsync(); + if (!connected) + { + _logger?.TraceAction(GetType(), "Client couldn't reconnect"); + // if the ReconnectionPolicy is set up to be finite + // and no connection could be established + // a call to Client.Close() is made + // that public Close() also shuts down this ConnectionWatchDog + await _client.CloseAsync(); + break; + } + + _logger?.TraceAction(GetType(), "Client reconnected"); + } + + await Task.Delay(MonitorTaskDelayInMilliseconds); + } + } + catch (TaskCanceledException) + { + // Swallow any cancellation exceptions + } + catch (OperationCanceledException ex) + { + // Occurs if the Tasks are canceled by the CancellationTokenSource.Token + _logger?.LogExceptionAsInformation(GetType(), ex); + } + catch (Exception ex) + { + _logger?.LogExceptionAsError(GetType(), ex); + await _client.RaiseError(new OnErrorEventArgs(ex)); + await _client.RaiseFatal(); + + // To ensure CancellationTokenSource is set to null again call Stop(); + await StopAsync(); + } + } +} diff --git a/src/TwitchLib.Communication/Services/NetworkServices.cs b/src/TwitchLib.Communication/Services/NetworkServices.cs new file mode 100644 index 0000000..70869ae --- /dev/null +++ b/src/TwitchLib.Communication/Services/NetworkServices.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Logging; +using TwitchLib.Communication.Clients; +using TwitchLib.Communication.Extensions; + +namespace TwitchLib.Communication.Services; + +/// +/// to bundle Network-Service-s +/// +internal class NetworkServices where T : IDisposable +{ + private Task? _listenTask; + private Task? _monitorTask; + private readonly ClientBase _client; + private readonly ILogger? _logger; + private readonly ConnectionWatchDog _connectionWatchDog; + + private CancellationToken Token => _client.Token; + + internal NetworkServices( + ClientBase client, + ILogger? logger = null) + { + _logger = logger; + _client = client; + _connectionWatchDog = new ConnectionWatchDog(_client, logger); + } + + internal void Start() + { + _logger?.TraceMethodCall(GetType()); + if (_monitorTask == null || !_connectionWatchDog.IsRunning) + { + // this task is probably still running + // may be in case of a network connection loss + // all other Tasks haven't been started or have been canceled! + // ConnectionWatchDog is the only one, that has a separate CancellationTokenSource! + + // Let those tasks run in the background, do not await them + _monitorTask = _connectionWatchDog.StartMonitorTaskAsync(); + } + + _listenTask = Task.Run(_client.ListenTaskActionAsync, Token); + } + + internal async Task StopAsync() + { + _logger?.TraceMethodCall(GetType()); + await _connectionWatchDog.StopAsync(); + } +} diff --git a/src/TwitchLib.Communication/Services/Throttlers.cs b/src/TwitchLib.Communication/Services/Throttlers.cs deleted file mode 100644 index 10a98b7..0000000 --- a/src/TwitchLib.Communication/Services/Throttlers.cs +++ /dev/null @@ -1,203 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using TwitchLib.Communication.Clients; -using TwitchLib.Communication.Events; -using TwitchLib.Communication.Interfaces; - -namespace TwitchLib.Communication.Services -{ - public class Throttlers - { - public readonly BlockingCollection> SendQueue = - new BlockingCollection>(); - - public readonly BlockingCollection> WhisperQueue = - new BlockingCollection>(); - - public bool Reconnecting { get; set; } = false; - public bool ShouldDispose { get; set; } = false; - public CancellationTokenSource TokenSource { get; set; } - public bool ResetThrottlerRunning; - public bool ResetWhisperThrottlerRunning; - public int SentCount = 0; - public int WhispersSent = 0; - public Task ResetThrottler; - public Task ResetWhisperThrottler; - - private readonly TimeSpan _throttlingPeriod; - private readonly TimeSpan _whisperThrottlingPeriod; - private readonly IClient _client; - - public Throttlers(IClient client, TimeSpan throttlingPeriod, TimeSpan whisperThrottlingPeriod) - { - _throttlingPeriod = throttlingPeriod; - _whisperThrottlingPeriod = whisperThrottlingPeriod; - _client = client; - } - - public void StartThrottlingWindowReset() - { - ResetThrottler = Task.Run(async () => - { - ResetThrottlerRunning = true; - while (!ShouldDispose && !Reconnecting) - { - Interlocked.Exchange(ref SentCount, 0); - await Task.Delay(_throttlingPeriod, TokenSource.Token); - } - - ResetThrottlerRunning = false; - return Task.CompletedTask; - }); - } - - public void StartWhisperThrottlingWindowReset() - { - ResetWhisperThrottler = Task.Run(async () => - { - ResetWhisperThrottlerRunning = true; - while (!ShouldDispose && !Reconnecting) - { - Interlocked.Exchange(ref WhispersSent, 0); - await Task.Delay(_whisperThrottlingPeriod, TokenSource.Token); - } - - ResetWhisperThrottlerRunning = false; - return Task.CompletedTask; - }); - } - - public void IncrementSentCount() - { - Interlocked.Increment(ref SentCount); - } - - public void IncrementWhisperCount() - { - Interlocked.Increment(ref WhispersSent); - } - - public Task StartSenderTask() - { - StartThrottlingWindowReset(); - - return Task.Run(async () => - { - try - { - while (!ShouldDispose) - { - await Task.Delay(_client.Options.SendDelay); - - if (SentCount == _client.Options.MessagesAllowedInPeriod) - { - _client.MessageThrottled(new OnMessageThrottledEventArgs - { - Message = - "Message Throttle Occured. Too Many Messages within the period specified in WebsocketClientOptions.", - AllowedInPeriod = _client.Options.MessagesAllowedInPeriod, - Period = _client.Options.ThrottlingPeriod, - SentMessageCount = Interlocked.CompareExchange(ref SentCount, 0, 0) - }); - - continue; - } - - if (!_client.IsConnected || ShouldDispose) continue; - - var msg = SendQueue.Take(TokenSource.Token); - if (msg.Item1.Add(_client.Options.SendCacheItemTimeout) < DateTime.UtcNow) continue; - - try - { - switch (_client) - { - case WebSocketClient ws: - await ws.SendAsync(Encoding.UTF8.GetBytes(msg.Item2)); - break; - case TcpClient tcp: - await tcp.SendAsync(msg.Item2); - break; - } - - IncrementSentCount(); - } - catch (Exception ex) - { - _client.SendFailed(new OnSendFailedEventArgs {Data = msg.Item2, Exception = ex}); - break; - } - } - } - catch (Exception ex) - { - _client.SendFailed(new OnSendFailedEventArgs {Data = "", Exception = ex}); - _client.Error(new OnErrorEventArgs {Exception = ex}); - } - }); - } - - public Task StartWhisperSenderTask() - { - StartWhisperThrottlingWindowReset(); - - return Task.Run(async () => - { - try - { - while (!ShouldDispose) - { - await Task.Delay(_client.Options.SendDelay); - - if (WhispersSent == _client.Options.WhispersAllowedInPeriod) - { - _client.WhisperThrottled(new OnWhisperThrottledEventArgs() - { - Message = - "Whisper Throttle Occured. Too Many Whispers within the period specified in ClientOptions.", - AllowedInPeriod = _client.Options.WhispersAllowedInPeriod, - Period = _client.Options.WhisperThrottlingPeriod, - SentWhisperCount = Interlocked.CompareExchange(ref WhispersSent, 0, 0) - }); - - continue; - } - - if (!_client.IsConnected || ShouldDispose) continue; - - var msg = WhisperQueue.Take(TokenSource.Token); - if (msg.Item1.Add(_client.Options.SendCacheItemTimeout) < DateTime.UtcNow) continue; - - try - { - switch (_client) - { - case WebSocketClient ws: - await ws.SendAsync(Encoding.UTF8.GetBytes(msg.Item2)); - break; - case TcpClient tcp: - await tcp.SendAsync(msg.Item2); - break; - } - - IncrementWhisperCount(); - } - catch (Exception ex) - { - _client.SendFailed(new OnSendFailedEventArgs {Data = msg.Item2, Exception = ex}); - break; - } - } - } - catch (Exception ex) - { - _client.SendFailed(new OnSendFailedEventArgs {Data = "", Exception = ex}); - _client.Error(new OnErrorEventArgs {Exception = ex}); - } - }); - } - } -} diff --git a/src/TwitchLib.Communication/TwitchLib.Communication.csproj b/src/TwitchLib.Communication/TwitchLib.Communication.csproj index 6f7f1a9..b59d546 100644 --- a/src/TwitchLib.Communication/TwitchLib.Communication.csproj +++ b/src/TwitchLib.Communication/TwitchLib.Communication.csproj @@ -1,24 +1,34 @@  - - netstandard2.0 - 1.0.6 - $(VersionSuffix) - swiftyspiffy, Prom3theu5, Syzuna, LuckyNoS7evin - swiftyspiffy, Prom3theu5, Syzuna, LuckyNoS7evin - Connection library used throughout TwitchLib to replace third party depedencies. - Copyright 2022 - https://opensource.org/licenses/MIT - https://github.com/TwitchLib/TwitchLib.Communication - https://cdn.syzuna-programs.de/images/twitchlib.png - https://github.com/TwitchLib/TwitchLib.Communication - Git - twitch twitchlib library irc chat c# csharp api events pubsub net standard 2.0 - Fix reconnect loop on disconnect - en-US - 1.0.6 - 1.0.6 - true - - + + netstandard2.0;netstandard2.1;net6.0;net7.0 + enable + enable + latest + 2.0.0 + $(VersionSuffix) + swiftyspiffy, Prom3theu5, Syzuna, LuckyNoS7evin + swiftyspiffy, Prom3theu5, Syzuna, LuckyNoS7evin + Connection library used throughout TwitchLib to replace third party depedencies. + Copyright 2023 + https://opensource.org/licenses/MIT + https://github.com/TwitchLib/TwitchLib.Communication + https://cdn.syzuna-programs.de/images/twitchlib.png + https://github.com/TwitchLib/TwitchLib.Communication + Git + twitch twitchlib library irc chat c# csharp api events pubsub net standard 2.0 + Fix reconnect loop on disconnect + en-US + 2.0.0 + 2.0.0 + true + True + nullable + + + + + + +