using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Linq.Expressions; using System.Net.Sockets; using System.Reflection.Metadata; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Timers; namespace TwitchLogger.IRC { /// /// Connects to a single Twitch chat channel via limited IRC implementation. /// /// /// /// public class IrcConnection(string url, int port) : IDisposable { public static readonly string ENDL = "\r\n"; public int Port { get; } = port; public string Url { get; } = url; public bool Connected { get; } = false; public event EventHandler? onTimeout; private TcpClient Client = new(); private NetworkStream Stream => Client.GetStream(); private CancellationTokenSource TokenSource = new(); private RateLimiter? Limiter; private Task? ListenerTask; public async Task ConnectAsync() { if (Connected) return true; Client.NoDelay = true; await Client.ConnectAsync(Url, Port); if (!Client.Connected) return false; ListenerTask = Task.Run(() => ListenForInput(TokenSource.Token), TokenSource.Token); return true; } public void Disconnect() { TokenSource.Cancel(); } public void SendLine(string line) { Limiter?.WaitForAvailable(); if (TokenSource.IsCancellationRequested) return; Stream.Write(new Span(Encoding.UTF8.GetBytes(line + ENDL))); } public void Authenticate(string user, string pass) { SendLine($"NICK {user}"); SendLine($"PASS {pass}"); } private async void ListenForInput(CancellationToken token) { using AutoResetEvent ARE = new(false); byte[] buffer = new byte[5 * 1024]; while (!token.IsCancellationRequested) { var bytesRead = await Stream.ReadAsync(buffer, 0, buffer.Length, TokenSource.Token); if (bytesRead > 0) onDataReceived(buffer, bytesRead); if (!Stream.CanRead) return; } token.ThrowIfCancellationRequested(); } private string _ReceivedDataBuffer = ""; private void onDataReceived(byte[] buffer, int length) { string receivedString = Encoding.UTF8.GetString(buffer, 0, length); _ReceivedDataBuffer += receivedString; string[] lines = _ReceivedDataBuffer.Split(ENDL); //if last line is terminated, there should be an empty string at the end of "lines" foreach (var line in lines.SkipLast(1)) onLineReceived(line); _ReceivedDataBuffer = lines.Last(); } private void onLineReceived(string line) { if (string.IsNullOrWhiteSpace(line)) return; var message = ReceivedMessage.Parse(line); HeartbeatReceived(); //PONG must be sent automatically if (message.MessageType == IrcMessageType.PING) SendLine($"PONG :{message.Source} {message.RawParameters}"); RunCallbacks(message); } //TODO consider changing to a System.Threading.Timer, I'm not sure //if it's a better fit private readonly System.Timers.Timer _HeartbeatTimer = new(); private void InitializeHeartbeat(int millis) { ObjectDisposedException.ThrowIf(disposedValue, GetType()); _HeartbeatTimer.AutoReset = false; _HeartbeatTimer.Interval = millis; _HeartbeatTimer.Elapsed += HeartbeatTimedOut; _HeartbeatTimer.Start(); } private void HeartbeatReceived() { if (disposedValue) return; _HeartbeatTimer.Stop(); _HeartbeatTimer.Start(); } private void HeartbeatTimedOut(object? caller, ElapsedEventArgs e) { if (disposedValue) return; onTimeout?.Invoke(this, EventArgs.Empty); } private List Callbacks = []; public void AddCallback(CallbackItem callbackItem) => Callbacks.Add(callbackItem); public bool RemoveCallback(CallbackItem callbackItem) => Callbacks.Remove(callbackItem); private void RunCallbacks(ReceivedMessage message) { ArgumentNullException.ThrowIfNull(message, nameof(message)); Callbacks.ForEach(c => c.TryCall(message)); } #region Dispose private bool disposedValue; protected virtual void Dispose(bool disposing) { if (!disposedValue) { TokenSource.Cancel(); if (disposing) { TokenSource.Dispose(); Client?.Dispose(); _HeartbeatTimer?.Dispose(); Limiter?.Dispose(); } disposedValue = true; } } public void Dispose() { Dispose(disposing: true); GC.SuppressFinalize(this); } #endregion //Dispose private class RateLimiter : IDisposable { private SemaphoreSlim Semaphore; private System.Timers.Timer Timer; public int MessageLimit { get; } public int Seconds { get; } private CancellationToken Token { get; } public RateLimiter(int messages, int seconds, CancellationToken token) { Semaphore = new(messages, messages); Timer = new(TimeSpan.FromSeconds(seconds)); MessageLimit = messages; Seconds = seconds; Token = token; Timer.AutoReset = true; Timer.Elapsed += ResetLimit; Timer.Start(); } public void WaitForAvailable() { try { Semaphore.Wait(Token); } catch (OperationCanceledException) { //caller is responsible for checking whether connection is cancelled before trying to send } } public bool WaitForAvailable(TimeSpan timeout) { try { return Semaphore.Wait(timeout, Token); } catch (OperationCanceledException) { return false; } } public bool WaitForAvailable(int millis) { try { return Semaphore.Wait(millis, Token); } catch (OperationCanceledException) { return false; } } private void ResetLimit(object? sender, EventArgs e) { try { Semaphore.Release(MessageLimit); } catch (SemaphoreFullException) { } catch (ObjectDisposedException) { } } #region RateLimiter Dispose private bool disposedValue; protected virtual void Dispose(bool disposing) { if (!disposedValue) { if (disposing) { Semaphore?.Dispose(); Timer?.Dispose(); } disposedValue = true; } } public void Dispose() { Dispose(disposing: true); GC.SuppressFinalize(this); } #endregion //RateLimiter Dispose } } }