Now successfully connects, authenticates, joins a channel and parses messages.

This commit is contained in:
Ikatono
2024-03-18 05:55:25 -05:00
parent 7051cd28f1
commit 6a5d960b3d
5 changed files with 666 additions and 50 deletions

View File

@@ -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);
/// <summary>
/// Callback to be run for received messages of specific types.
/// </summary>
/// <param name="Callback"></param>
/// <param name="CallbackTypes">set to null to run for all message types</param>
public readonly record struct CallbackItem(
IrcCallback Callback,
IReadOnlyList<IrcMessageType>? CallbackTypes)
{
public bool TryCall(ReceivedMessage message)
{
if (CallbackTypes?.Contains(message.MessageType) ?? true)
{
Callback(message);
return true;
}
return false;
}
}
}

View File

@@ -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
/// <summary>
/// Connects to a single Twitch chat channel via limited IRC implementation.
///
/// </summary>
/// <param name="url"></param>
/// <param name="port"></param>
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<bool> 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<byte>(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<CallbackItem> 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
}
}
}

View File

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

View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
public class MessageTags : IDictionary<string, string>
{
public Dictionary<string, string> Tags = [];
public MessageTags()
{
}
private enum ParseState
{
FindingKey,
FindingValue,
ValueEscaped,
}
//TODO this should be unit tested
/// <summary>
///
/// </summary>
/// <param name="s"></param>
/// <returns></returns>
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<string, string?>
public string this[string key] { get => ((IDictionary<string, string>)Tags)[key]; set => ((IDictionary<string, string>)Tags)[key] = value; }
public ICollection<string> Keys => ((IDictionary<string, string>)Tags).Keys;
public ICollection<string> Values => ((IDictionary<string, string>)Tags).Values;
public int Count => ((ICollection<KeyValuePair<string, string>>)Tags).Count;
public bool IsReadOnly => ((ICollection<KeyValuePair<string, string>>)Tags).IsReadOnly;
public void Add(string key, string value)
{
((IDictionary<string, string>)Tags).Add(key, value);
}
public void Add(KeyValuePair<string, string> item)
{
((ICollection<KeyValuePair<string, string>>)Tags).Add(item);
}
public void Clear()
{
((ICollection<KeyValuePair<string, string>>)Tags).Clear();
}
public bool Contains(KeyValuePair<string, string> item)
{
return ((ICollection<KeyValuePair<string, string>>)Tags).Contains(item);
}
public bool ContainsKey(string key)
{
return ((IDictionary<string, string>)Tags).ContainsKey(key);
}
public void CopyTo(KeyValuePair<string, string>[] array, int arrayIndex)
{
((ICollection<KeyValuePair<string, string>>)Tags).CopyTo(array, arrayIndex);
}
public IEnumerator<KeyValuePair<string, string>> GetEnumerator()
{
return ((IEnumerable<KeyValuePair<string, string>>)Tags).GetEnumerator();
}
public bool Remove(string key)
{
return ((IDictionary<string, string>)Tags).Remove(key);
}
public bool Remove(KeyValuePair<string, string> item)
{
return ((ICollection<KeyValuePair<string, string>>)Tags).Remove(item);
}
public bool TryGetValue(string key, [MaybeNullWhen(false)] out string value)
{
return ((IDictionary<string, string>)Tags).TryGetValue(key, out value);
}
IEnumerator IEnumerable.GetEnumerator()
{
return ((IEnumerable)Tags).GetEnumerator();
}
#endregion //IDictionary<string, string?>
}
}

View File

@@ -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
{
/// <summary>
///
/// </summary>
/// <remarks>
/// This is designed according to <see href="https://ircv3.net/specs/extensions/message-tags.html"/>
/// but only implementing features used by Twitch chat. See <see href="https://dev.twitch.tv/docs/irc/"/> for
/// specifics about Twitch chat's use of IRC. Currently messages are not fully validated.
/// </remarks>
public class ReceivedMessage
{
public IrcMessageType MessageType { get; private set; }
public string? Prefix { get; private set; }
public string? Source { get; private set; }
public List<string> Parameters { get; } = [];
public string RawParameters { get; private set; }
public string RawText { get; private set; }
public MessageTags MessageTags { get; private set; } = [];
/// <summary>
/// Parses an IRC message into the proper message type
/// </summary>
/// <param name="s"></param>
/// <returns>the parsed message</returns>
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;
}
}
}