mirror of
https://github.com/Ikatono/TwitchIrcClient.git
synced 2025-10-29 04:56:12 -05:00
Now successfully connects, authenticates, joins a channel and parses messages.
This commit is contained in:
33
TwitchLogger/IRC/Callbacks.cs
Normal file
33
TwitchLogger/IRC/Callbacks.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
176
TwitchLogger/IRC/IrcMessageType.cs
Normal file
176
TwitchLogger/IRC/IrcMessageType.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
214
TwitchLogger/IRC/MessageTags.cs
Normal file
214
TwitchLogger/IRC/MessageTags.cs
Normal 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?>
|
||||
|
||||
}
|
||||
}
|
||||
74
TwitchLogger/IRC/ReceivedMessage.cs
Normal file
74
TwitchLogger/IRC/ReceivedMessage.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user