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;
+ }
+ }
+}