From 6a5d960b3da02ca858f3abcd06173e24adca3917 Mon Sep 17 00:00:00 2001 From: Ikatono Date: Mon, 18 Mar 2024 05:55:25 -0500 Subject: [PATCH] Now successfully connects, authenticates, joins a channel and parses messages. --- TwitchLogger/IRC/Callbacks.cs | 33 +++++ TwitchLogger/IRC/IrcConnection.cs | 219 +++++++++++++++++++++------- TwitchLogger/IRC/IrcMessageType.cs | 176 ++++++++++++++++++++++ TwitchLogger/IRC/MessageTags.cs | 214 +++++++++++++++++++++++++++ TwitchLogger/IRC/ReceivedMessage.cs | 74 ++++++++++ 5 files changed, 666 insertions(+), 50 deletions(-) create mode 100644 TwitchLogger/IRC/Callbacks.cs create mode 100644 TwitchLogger/IRC/IrcMessageType.cs create mode 100644 TwitchLogger/IRC/MessageTags.cs create mode 100644 TwitchLogger/IRC/ReceivedMessage.cs diff --git a/TwitchLogger/IRC/Callbacks.cs b/TwitchLogger/IRC/Callbacks.cs new file mode 100644 index 0000000..9cfdd69 --- /dev/null +++ b/TwitchLogger/IRC/Callbacks.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TwitchLogger.IRC +{ + public class IrcMessageEventArgs(ReceivedMessage message) : EventArgs + { + public ReceivedMessage Message = message; + } + public delegate void IrcCallback(ReceivedMessage message); + /// + /// Callback to be run for received messages of specific types. + /// + /// + /// set to null to run for all message types + public readonly record struct CallbackItem( + IrcCallback Callback, + IReadOnlyList? CallbackTypes) + { + public bool TryCall(ReceivedMessage message) + { + if (CallbackTypes?.Contains(message.MessageType) ?? true) + { + Callback(message); + return true; + } + return false; + } + } +} diff --git a/TwitchLogger/IRC/IrcConnection.cs b/TwitchLogger/IRC/IrcConnection.cs index db09fb7..d608edf 100644 --- a/TwitchLogger/IRC/IrcConnection.cs +++ b/TwitchLogger/IRC/IrcConnection.cs @@ -2,8 +2,10 @@ 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; @@ -11,79 +13,72 @@ using System.Timers; namespace TwitchLogger.IRC { - public class IrcConnection : IDisposable + /// + /// 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; } - public string Url { get; } + public int Port { get; } = port; + public string Url { get; } = url; public bool Connected { get; } = false; public event EventHandler? onTimeout; - private Socket Socket = new(SocketType.Stream, ProtocolType.Tcp); - private CancellationTokenSource CancellationTokenSource = new(); - private Thread? ListenerThread; + private TcpClient Client = new(); + private NetworkStream Stream => Client.GetStream(); + private CancellationTokenSource TokenSource = new(); + private RateLimiter? Limiter; + private Task? ListenerTask; - public IrcConnection(string url, int port) - { - Url = url; - Port = port; - } public async Task ConnectAsync() { if (Connected) return true; - await Socket.ConnectAsync(Url, Port); - if (!Socket.Connected) + Client.NoDelay = true; + await Client.ConnectAsync(Url, Port); + if (!Client.Connected) return false; - ListenerThread = new(() => ListenForInput(CancellationTokenSource.Token)); - ListenerThread.Start(); + ListenerTask = Task.Run(() => ListenForInput(TokenSource.Token), TokenSource.Token); return true; } public void Disconnect() { - CancellationTokenSource.Cancel(); - throw new NotImplementedException(); + TokenSource.Cancel(); } public void SendLine(string line) { - int sent = Socket.Send(Encoding.UTF8.GetBytes(line + ENDL)); + Limiter?.WaitForAvailable(); + if (TokenSource.IsCancellationRequested) + return; + Stream.Write(new Span(Encoding.UTF8.GetBytes(line + ENDL))); } - public bool Authenticate(string user, string pass) + public void Authenticate(string user, string pass) { - throw new NotImplementedException(); + SendLine($"NICK {user}"); + SendLine($"PASS {pass}"); } - private void ListenForInput(CancellationToken token) + private async void ListenForInput(CancellationToken token) { using AutoResetEvent ARE = new(false); - while (true) + byte[] buffer = new byte[5 * 1024]; + while (!token.IsCancellationRequested) { - SocketAsyncEventArgs args = new(); - args.Completed += (sender, e) => - { - onDataReceived(e); - ARE.Set(); - }; - bool started = Socket.ReceiveAsync(args); - while (true) - { - bool reset = ARE.WaitOne(100); - if (reset) - break; - //returning ends the thread running this - if (token.IsCancellationRequested) - return; - } + 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(SocketAsyncEventArgs args) + private void onDataReceived(byte[] buffer, int length) { - if (args.SocketError != SocketError.Success) - throw new SocketException((int)args.SocketError, $"Socket Error: {args.SocketError}"); - if (args.Buffer is null) - throw new ArgumentNullException(); - string receivedString = Encoding.UTF8.GetString(args.Buffer, args.Offset, args.BytesTransferred); + 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" @@ -93,34 +88,64 @@ namespace TwitchLogger.IRC } private void onLineReceived(string line) { - throw new NotImplementedException(); + 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); } - private System.Timers.Timer _HeartbeatTimer = new(); + //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() { - throw new NotImplementedException(); + if (disposedValue) + return; + _HeartbeatTimer.Stop(); + _HeartbeatTimer.Start(); } private void HeartbeatTimedOut(object? caller, ElapsedEventArgs e) { - onTimeout?.Invoke(this, new()); + 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) { - CancellationTokenSource.Cancel(); + TokenSource.Cancel(); if (disposing) { - Socket?.Dispose(); + TokenSource.Dispose(); + Client?.Dispose(); _HeartbeatTimer?.Dispose(); + Limiter?.Dispose(); } disposedValue = true; } @@ -131,5 +156,99 @@ namespace TwitchLogger.IRC 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 + } } } diff --git a/TwitchLogger/IRC/IrcMessageType.cs b/TwitchLogger/IRC/IrcMessageType.cs new file mode 100644 index 0000000..7b89eee --- /dev/null +++ b/TwitchLogger/IRC/IrcMessageType.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TwitchLogger.IRC +{ + public enum IrcMessageType + { + //twitch standard messages + JOIN = -1000, + NICK = -1001, + NOTICE = -1002, + PART = -1003, + PASS = -1004, + PING = -1005, + PONG = -1006, + PRIVMSG = -1007, + CLEARCHAT = -1008, + CLEARMSG = -1009, + GLOBALUSERSTATE = -1010, + HOSTTARGET = -1011, + RECONNECT = -1012, + ROOMSTATE = -1013, + USERNOTICE = -1014, + USERSTATE = -1015, + WHISPER = -1016, + + CAP = -2000, + + #region Numeric + //none of these are actually Twitch supported other than 421 + RPL_WELCOME = 001, + RPL_YOURHOST = 002, + RPL_CREATED = 003, + RPL_MYINFO = 004, + RPL_ISUPPORT = 005, + RPL_BOUNCE = 010, + RPL_STATSCOMMANDS = 212, + RPL_ENDOFSTATS = 219, + RPL_UMODEIS = 221, + RPL_STATSUPTIME = 242, + RPL_LUSERCLIENT = 251, + RPL_LUSEROP = 252, + RPL_LUSERUNKNOWN = 253, + RPL_LUSERCHANNELS = 254, + RPL_LUSERME = 255, + RPL_ADMINME = 256, + RPL_ADMINLOC1 = 257, + RPL_ADMINLOC2 = 258, + RPL_ADMINEMAIL = 259, + RPL_TRYAGAIN = 263, + RPL_LOCALUSERS = 265, + RPL_GLOBALUSERS = 266, + RPL_WHOISCERTFP = 276, + RPL_NONE = 300, + RPL_AWAY = 301, + RPL_USERHOST = 302, + RPL_UNAWAY = 305, + RPL_NOWAWAY = 306, + RPL_WHOISREGNICK = 307, + RPL_WHOISUSER = 311, + RPL_WHOISSERVER = 312, + RPL_WHOISOPERATOR = 313, + RPL_WHOWASUSER = 314, + RPL_ENDOFWHO = 315, + RPL_WHOISIDLE = 317, + RPL_ENDOFWHOIS = 318, + RPL_WHOISCHANNELS = 319, + RPL_WHOISSPECIAL = 320, + RPL_LISTSTART = 321, + RPL_LIST = 322, + RPL_LISTEND = 323, + RPL_CHANNELMODEIS = 324, + RPL_CREATIONTIME = 329, + RPL_WHOISACCOUNT = 330, + RPL_NOTOPIC = 331, + RPL_TOPIC = 332, + RPL_TOPICWHOTIME = 333, + RPL_INVITELIST = 336, + RPL_ENDOFINVITELIST = 337, + RPL_WHOISACTUALLY = 338, + RPL_INVITING = 341, + RPL_INVEXLIST = 346, + RPL_ENDOFINVEXLIST = 347, + RPL_EXCEPTLIST = 348, + RPL_ENDOFEXCEPTLIST = 349, + RPL_VERSION = 351, + RPL_WHOREPLY = 352, + RPL_NAMREPLY = 353, + RPL_LINKS = 364, + RPL_ENDOFLINKS = 365, + RPL_ENDOFNAMES = 366, + RPL_BANLIST = 367, + RPL_ENDOFBANLIST = 368, + RPL_ENDOFWHOWAS = 369, + RPL_INFO = 371, + RPL_MOTD = 372, + RPL_ENDOFINFO = 374, + RPL_MOTDSTART = 375, + RPL_ENDOFMOTD = 376, + RPL_WHOISHOST = 378, + RPL_WHOISMODES = 379, + RPL_YOUREOPER = 381, + RPL_REHASHING = 382, + RPL_TIME = 391, + ERR_UNKNOWNERROR = 400, + ERR_NOSUCHNICK = 401, + ERR_NOSUCHSERVER = 402, + ERR_NOSUCHCHANNEL = 403, + ERR_CANNOTSENDTOCHANNEL = 404, + ERR_TOOMANYCHANNELS = 405, + ERR_WASNOSUCHNICK = 406, + ERR_NOORIGIN = 409, + ERR_NORECIPIENT = 411, + ERR_NOTEXTTOSEND = 412, + ERR_INPUTTOOLONG = 417, + ERR_UNKNOWNCOMMAND = 421, + ERR_NOMOTD = 422, + ERR_NONICKNAMEGIVEN = 431, + ERR_ERRONEUSNICKNAME = 432, + ERR_NICKNAMEINUSE = 433, + ERR_NICKCOLLISION = 436, + ERR_USERNOTINCHANNEL = 441, + ERR_NOTONCHANNEL = 442, + ERR_USERONCHANNEL = 443, + ERR_NOTREGISTERED = 451, + ERR_NEEDMOREPARAMS = 461, + ERR_ALREADYREGISTERED = 462, + ERR_PASSWDMISMATCH = 464, + ERR_YOUREBANNEDCREEP = 465, + ERR_CHANNELISFULL = 471, + ERR_UNKNOWNMODE = 472, + ERR_INVITEONLYCHAN = 473, + ERR_BANNEDFROMCHAN = 474, + ERR_BADCHANNELKEY = 475, + ERR_BADCHANMASK = 476, + ERR_NOPRIVILEGES = 481, + ERR_CHANOPRIVSNEEDED = 482, + ERR_CANTKILLSERVER = 483, + ERR_NOOPERHOST = 491, + ERR_UMODEUNKNOWNFLAG = 501, + ERR_USERSDONTMATCH = 502, + ERR_HELPNOTFOUND = 524, + ERR_INVALIDKEY = 525, + RPL_STARTTLS = 670, + RPL_WHOISSECURE = 671, + ERR_STARTTLS = 691, + ERR_INVALIDMODEPARAM = 696, + RPL_HELPSTART = 704, + RPL_HELPTXT = 705, + RPL_ENDOFHELP = 706, + RPL_NOPRIVS = 723, + RPL_LOGGEDIN = 900, + RPL_LOGGEDOUT = 901, + ERR_NICKLOCKED = 902, + RPL_SASLSUCCESS = 903, + ERR_SASLFAIL = 904, + ERR_SASLTOOLONG = 905, + ERR_SALSABORTED = 906, + ERR_SASLALREADY = 907, + ERR_SASLMECHS = 908, + #endregion //Numeric + } + public static class IrcReceiveMessageTypeHelper + { + //parses a string that is either a numeric code or the command name + public static IrcMessageType Parse(string s) + { + if (int.TryParse(s, out int result)) + return (IrcMessageType)result; + return Enum.Parse(s); + } + } +} diff --git a/TwitchLogger/IRC/MessageTags.cs b/TwitchLogger/IRC/MessageTags.cs new file mode 100644 index 0000000..b57650c --- /dev/null +++ b/TwitchLogger/IRC/MessageTags.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace TwitchLogger.IRC +{ + /// + /// Holds key-value pairs of tags. Tag names are case-sensitive and DO NOT parse + /// the "client prefix" or "vendor", instead treating these as part of the "key name". + /// Because of this, repeat "key name" with different "client prefix" or "vendor" will + /// be treated as distinct. + /// + public class MessageTags : IDictionary + { + public Dictionary Tags = []; + public MessageTags() + { + + } + private enum ParseState + { + FindingKey, + FindingValue, + ValueEscaped, + } + //TODO this should be unit tested + /// + /// + /// + /// + /// + public static MessageTags Parse(string s) + { + s.TrimStart('@'); + MessageTags tags = []; + string key = ""; + string value = ""; + var state = ParseState.FindingKey; + foreach (char c in s) + { + switch (state) + { + case ParseState.FindingKey: + if (c == '=') + state = ParseState.FindingValue; + else if (c == ';') + { + state = ParseState.FindingKey; + tags.Add(key, ""); + key = ""; + } + else if (c == ' ') + { + tags.Add(key, ""); + goto EndParse; + } + else + key += c; + break; + case ParseState.FindingValue: + if (c == '\\') + { + state = ParseState.ValueEscaped; + } + else if (c == ';') + { + tags.Add(key, value); + key = value = ""; + state = ParseState.FindingKey; + } + else if (c == ' ') + { + tags.Add(key, value); + goto EndParse; + } + else if ("\r\n\0".Contains(c)) + throw new ArgumentException("Invalid character in tag string", nameof(s)); + else + { + value += c; + } + break; + case ParseState.ValueEscaped: + if (c == ':') + { + value += ';'; + state = ParseState.FindingValue; + } + else if (c == 's') + { + value += ' '; + state = ParseState.FindingValue; + } + else if (c == '\\') + { + value += '\\'; + state = ParseState.FindingValue; + } + else if (c == 'r') + { + value += '\r'; + state = ParseState.FindingValue; + } + else if (c == 'n') + { + value += '\n'; + state = ParseState.FindingValue; + } + else if (c == ';') + { + tags.Add(key, value); + key = value = ""; + state = ParseState.FindingKey; + } + //spaces should already be stripped, but handle this as end of tags just in case + else if (c == ' ') + { + tags.Add(key, value); + key = value = ""; + goto EndParse; + } + else if ("\r\n\0".Contains(c)) + throw new ArgumentException("Invalid character in tag string", nameof(s)); + else + { + value += c; + state = ParseState.FindingValue; + } + break; + default: + throw new InvalidEnumArgumentException("Invalid state enum"); + + } + } + //this is reached after processing the last character without hitting a space + tags.Add(key, value); + EndParse: + return tags; + } + #region IDictionary + public string this[string key] { get => ((IDictionary)Tags)[key]; set => ((IDictionary)Tags)[key] = value; } + + public ICollection Keys => ((IDictionary)Tags).Keys; + + public ICollection Values => ((IDictionary)Tags).Values; + + public int Count => ((ICollection>)Tags).Count; + + public bool IsReadOnly => ((ICollection>)Tags).IsReadOnly; + + public void Add(string key, string value) + { + ((IDictionary)Tags).Add(key, value); + } + + public void Add(KeyValuePair item) + { + ((ICollection>)Tags).Add(item); + } + + public void Clear() + { + ((ICollection>)Tags).Clear(); + } + + public bool Contains(KeyValuePair item) + { + return ((ICollection>)Tags).Contains(item); + } + + public bool ContainsKey(string key) + { + return ((IDictionary)Tags).ContainsKey(key); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + ((ICollection>)Tags).CopyTo(array, arrayIndex); + } + + public IEnumerator> GetEnumerator() + { + return ((IEnumerable>)Tags).GetEnumerator(); + } + + public bool Remove(string key) + { + return ((IDictionary)Tags).Remove(key); + } + + public bool Remove(KeyValuePair item) + { + return ((ICollection>)Tags).Remove(item); + } + + public bool TryGetValue(string key, [MaybeNullWhen(false)] out string value) + { + return ((IDictionary)Tags).TryGetValue(key, out value); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)Tags).GetEnumerator(); + } + #endregion //IDictionary + + } +} diff --git a/TwitchLogger/IRC/ReceivedMessage.cs b/TwitchLogger/IRC/ReceivedMessage.cs new file mode 100644 index 0000000..5a2d707 --- /dev/null +++ b/TwitchLogger/IRC/ReceivedMessage.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection.Metadata.Ecma335; +using System.Text; +using System.Threading.Tasks; + +namespace TwitchLogger.IRC +{ + /// + /// + /// + /// + /// This is designed according to + /// but only implementing features used by Twitch chat. See for + /// specifics about Twitch chat's use of IRC. Currently messages are not fully validated. + /// + public class ReceivedMessage + { + public IrcMessageType MessageType { get; private set; } + public string? Prefix { get; private set; } + public string? Source { get; private set; } + public List Parameters { get; } = []; + public string RawParameters { get; private set; } + public string RawText { get; private set; } + public MessageTags MessageTags { get; private set; } = []; + + /// + /// Parses an IRC message into the proper message type + /// + /// + /// the parsed message + public static ReceivedMessage Parse(string s) + { + ReceivedMessage message = new(); + message.RawText = s; + //message has tags + if (s.StartsWith('@')) + { + s = s[1..]; + //first ' ' acts as the delimeter + var split = s.Split(' ', 2); + Debug.Assert(split.Length == 2, "no space found to end tag section"); + string tagString = split[0]; + s = split[1].TrimStart(' '); + message.MessageTags = MessageTags.Parse(tagString); + } + //message has source + if (s.StartsWith(':')) + { + s = s[1..]; + var split = s.Split(' ', 2); + Debug.Assert(split.Length == 2, "no space found to end prefix"); + message.Prefix = split[0]; + s = split[1].TrimStart(' '); + } + var spl_command = s.Split(' ', 2); + message.MessageType = IrcReceiveMessageTypeHelper.Parse(spl_command[0]); + //message has parameters + if (spl_command.Length >= 2) + { + s = spl_command[1]; + message.RawParameters = s; + var spl_final = s.Split(':', 2); + var spl_initial = spl_final[0].Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + message.Parameters.AddRange(spl_initial); + if (spl_final.Length >= 2) + message.Parameters.Add(spl_final[1]); + } + return message; + } + } +}