mirror of
https://github.com/Ikatono/TwitchIrcClient.git
synced 2025-10-29 04:56:12 -05:00
Improved parsing of more IRC message types, and added optional tracking of which users are in the stream.
This commit is contained in:
@@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
|||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.9.34607.119
|
VisualStudioVersion = 17.9.34607.119
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TwitchLogger", "TwitchLogger\TwitchLogger.csproj", "{465639B4-4511-473A-ADC8-23B994E3C21C}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TwitchIrcClient", "TwitchIrcClient\TwitchIrcClient.csproj", "{465639B4-4511-473A-ADC8-23B994E3C21C}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
14
TwitchIrcClient/IRC/Badge.cs
Normal file
14
TwitchIrcClient/IRC/Badge.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data.Common;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace TwitchLogger.IRC
|
||||||
|
{
|
||||||
|
public record struct Badge(string Name, string Version)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,21 +3,22 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using TwitchLogger.IRC.Messages;
|
||||||
|
|
||||||
namespace TwitchLogger.IRC
|
namespace TwitchLogger.IRC
|
||||||
{
|
{
|
||||||
public class IrcMessageEventArgs(ReceivedMessage message) : EventArgs
|
//public class IrcMessageEventArgs(ReceivedMessage message) : EventArgs
|
||||||
{
|
//{
|
||||||
public ReceivedMessage Message = message;
|
// public ReceivedMessage Message = message;
|
||||||
}
|
//}
|
||||||
public delegate void IrcCallback(ReceivedMessage message);
|
public delegate void MessageCallback(ReceivedMessage message);
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Callback to be run for received messages of specific types.
|
/// Callback to be run for received messages of specific types.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="Callback"></param>
|
/// <param name="Callback"></param>
|
||||||
/// <param name="CallbackTypes">set to null to run for all message types</param>
|
/// <param name="CallbackTypes">set to null to run for all message types</param>
|
||||||
public readonly record struct CallbackItem(
|
public readonly record struct MessageCallbackItem(
|
||||||
IrcCallback Callback,
|
MessageCallback Callback,
|
||||||
IReadOnlyList<IrcMessageType>? CallbackTypes)
|
IReadOnlyList<IrcMessageType>? CallbackTypes)
|
||||||
{
|
{
|
||||||
public bool TryCall(ReceivedMessage message)
|
public bool TryCall(ReceivedMessage message)
|
||||||
@@ -30,4 +31,9 @@ namespace TwitchLogger.IRC
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public class UserChangeEventArgs(IList<string> joined, IList<string> left) : EventArgs
|
||||||
|
{
|
||||||
|
public readonly IList<string> Joined = joined;
|
||||||
|
public IList<string> Left = left;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
296
TwitchIrcClient/IRC/IrcConnection.cs
Normal file
296
TwitchIrcClient/IRC/IrcConnection.cs
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
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;
|
||||||
|
using TwitchLogger.IRC.Messages;
|
||||||
|
|
||||||
|
namespace TwitchLogger.IRC
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Connects to a single Twitch chat channel via limited IRC implementation.
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="url"></param>
|
||||||
|
/// <param name="port"></param>
|
||||||
|
public class IrcConnection : IDisposable
|
||||||
|
{
|
||||||
|
public static readonly string ENDL = "\r\n";
|
||||||
|
public int Port { get; }
|
||||||
|
public string Url { get; }
|
||||||
|
public bool Connected { get; } = false;
|
||||||
|
public bool TrackUsers { get; }
|
||||||
|
//this seems to be the only concurrentcollection that allows
|
||||||
|
//removing specific items
|
||||||
|
protected ConcurrentDictionary<string, byte> UserCollection = new();
|
||||||
|
public IEnumerable<string> Users => UserCollection.Keys;
|
||||||
|
|
||||||
|
public event EventHandler? onTimeout;
|
||||||
|
/// <summary>
|
||||||
|
/// Occassionally sends a list of users who have joined and left the server.
|
||||||
|
/// Twitch sends this in bulk, so this event tries to collect all of these
|
||||||
|
/// into one call. Only reacts to users who join through
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<UserChangeEventArgs>? onUserChange;
|
||||||
|
|
||||||
|
private TcpClient Client = new();
|
||||||
|
private NetworkStream Stream => Client.GetStream();
|
||||||
|
private CancellationTokenSource TokenSource = new();
|
||||||
|
//it looks like you can't get the Token after the Source is disposed
|
||||||
|
protected CancellationToken Token;
|
||||||
|
private RateLimiter? Limiter;
|
||||||
|
private Task? ListenerTask;
|
||||||
|
private Task? UserUpdateTask;
|
||||||
|
|
||||||
|
public IrcConnection(string url, int port,
|
||||||
|
RateLimiter? limiter = null, bool trackUsers = false)
|
||||||
|
{
|
||||||
|
Url = url;
|
||||||
|
Port = port;
|
||||||
|
Limiter = limiter;
|
||||||
|
TrackUsers = trackUsers;
|
||||||
|
Token = TokenSource.Token;
|
||||||
|
if (TrackUsers)
|
||||||
|
{
|
||||||
|
AddSystemCallback(new MessageCallbackItem(m =>
|
||||||
|
{
|
||||||
|
if (m is NamReply nr)
|
||||||
|
foreach (var u in nr.Users)
|
||||||
|
UserCollection.TryAdd(u, 0);
|
||||||
|
else
|
||||||
|
throw new ArgumentException(null, nameof(m));
|
||||||
|
}, [IrcMessageType.RPL_NAMREPLY]));
|
||||||
|
AddSystemCallback(new MessageCallbackItem(m =>
|
||||||
|
{
|
||||||
|
if (m is Join j)
|
||||||
|
{
|
||||||
|
UserCollection.TryAdd(j.Username, 0);
|
||||||
|
UserJoin(j);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
throw new ArgumentException(null, nameof(m));
|
||||||
|
}, [IrcMessageType.JOIN]));
|
||||||
|
AddSystemCallback(new MessageCallbackItem(m =>
|
||||||
|
{
|
||||||
|
if (m is Part j)
|
||||||
|
{
|
||||||
|
UserCollection.TryRemove(j.Username, out _);
|
||||||
|
UserLeave(j);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
throw new ArgumentException(null, nameof(m));
|
||||||
|
}, [IrcMessageType.PART]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ConnectAsync()
|
||||||
|
{
|
||||||
|
if (Connected)
|
||||||
|
return true;
|
||||||
|
Client.NoDelay = true;
|
||||||
|
await Client.ConnectAsync(Url, Port);
|
||||||
|
if (!Client.Connected)
|
||||||
|
return false;
|
||||||
|
ListenerTask = Task.Run(ListenForInput, Token);
|
||||||
|
UserUpdateTask = Task.Run(UpdateUsers, Token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
public void Disconnect()
|
||||||
|
{
|
||||||
|
TokenSource.Cancel();
|
||||||
|
}
|
||||||
|
public void SendLine(string line)
|
||||||
|
{
|
||||||
|
Limiter?.WaitForAvailable(Token);
|
||||||
|
if (Token.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
Stream.Write(new Span<byte>(Encoding.UTF8.GetBytes(line + ENDL)));
|
||||||
|
}
|
||||||
|
public void Authenticate(string? user, string? pass)
|
||||||
|
{
|
||||||
|
if (user == null)
|
||||||
|
user = $"justinfan{Random.Shared.NextInt64(10000):D4}";
|
||||||
|
if (pass == null)
|
||||||
|
pass = "pass";
|
||||||
|
SendLine($"NICK {user}");
|
||||||
|
SendLine($"PASS {pass}");
|
||||||
|
}
|
||||||
|
public void JoinChannel(string channel)
|
||||||
|
{
|
||||||
|
channel = channel.TrimStart('#');
|
||||||
|
SendLine($"JOIN #{channel}");
|
||||||
|
}
|
||||||
|
private async void ListenForInput()
|
||||||
|
{
|
||||||
|
using AutoResetEvent ARE = new(false);
|
||||||
|
byte[] buffer = new byte[5 * 1024];
|
||||||
|
while (!Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var bytesRead = await Stream.ReadAsync(buffer, 0, buffer.Length, Token);
|
||||||
|
if (bytesRead > 0)
|
||||||
|
onDataReceived(buffer, bytesRead);
|
||||||
|
if (!Stream.CanRead)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Token.ThrowIfCancellationRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
ConcurrentBag<string> _JoinedUsers = [];
|
||||||
|
ConcurrentBag<string> _LeftUsers = [];
|
||||||
|
private void UserJoin(Join message)
|
||||||
|
{
|
||||||
|
_JoinedUsers.Add(message.Username);
|
||||||
|
}
|
||||||
|
private void UserLeave(Part message)
|
||||||
|
{
|
||||||
|
_LeftUsers.Add(message.Username);
|
||||||
|
}
|
||||||
|
private async void UpdateUsers()
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
List<string> join = [];
|
||||||
|
List<string> leave = [];
|
||||||
|
var args = new UserChangeEventArgs(join, leave);
|
||||||
|
await Task.Delay(2000, Token);
|
||||||
|
if (Token.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
//poll the collections to see if they have items
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (_JoinedUsers.TryTake(out string joinUser))
|
||||||
|
{
|
||||||
|
join.Add(joinUser);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (_LeftUsers.TryTake(out string leaveUser))
|
||||||
|
{
|
||||||
|
leave.Add(leaveUser);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await Task.Delay(500, Token);
|
||||||
|
if (Token.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
//once and item is found, wait a bit for Twitch to send the others
|
||||||
|
await Task.Delay(2000, TokenSource.Token);
|
||||||
|
if (TokenSource.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
while (_JoinedUsers.TryTake(out string user))
|
||||||
|
join.Add(user);
|
||||||
|
while (_LeftUsers.TryTake(out string user))
|
||||||
|
leave.Add(user);
|
||||||
|
onUserChange?.Invoke(this, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(message.RawText.Replace("PING", "PONG"));
|
||||||
|
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 readonly List<MessageCallbackItem> UserCallbacks = [];
|
||||||
|
protected readonly List<MessageCallbackItem> SystemCallbacks = [];
|
||||||
|
public void AddCallback(MessageCallbackItem callbackItem)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||||
|
UserCallbacks.Add(callbackItem);
|
||||||
|
}
|
||||||
|
public bool RemoveCallback(MessageCallbackItem callbackItem)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||||
|
return UserCallbacks.Remove(callbackItem);
|
||||||
|
}
|
||||||
|
protected void AddSystemCallback(MessageCallbackItem callbackItem)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||||
|
SystemCallbacks.Add(callbackItem);
|
||||||
|
}
|
||||||
|
protected bool RemoveSystemCallback(MessageCallbackItem callbackItem)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||||
|
return SystemCallbacks.Remove(callbackItem);
|
||||||
|
}
|
||||||
|
private void RunCallbacks(ReceivedMessage message)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(message, nameof(message));
|
||||||
|
if (disposedValue)
|
||||||
|
return;
|
||||||
|
SystemCallbacks.ForEach(c => c.TryCall(message));
|
||||||
|
UserCallbacks.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();
|
||||||
|
}
|
||||||
|
disposedValue = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Dispose(disposing: true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
#endregion //Dispose
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,6 @@ namespace TwitchLogger.IRC
|
|||||||
CAP = -2000,
|
CAP = -2000,
|
||||||
|
|
||||||
#region Numeric
|
#region Numeric
|
||||||
//none of these are actually Twitch supported other than 421
|
|
||||||
RPL_WELCOME = 001,
|
RPL_WELCOME = 001,
|
||||||
RPL_YOURHOST = 002,
|
RPL_YOURHOST = 002,
|
||||||
RPL_CREATED = 003,
|
RPL_CREATED = 003,
|
||||||
@@ -88,9 +87,16 @@ namespace TwitchLogger.IRC
|
|||||||
RPL_ENDOFEXCEPTLIST = 349,
|
RPL_ENDOFEXCEPTLIST = 349,
|
||||||
RPL_VERSION = 351,
|
RPL_VERSION = 351,
|
||||||
RPL_WHOREPLY = 352,
|
RPL_WHOREPLY = 352,
|
||||||
|
/// <summary>
|
||||||
|
/// Twitch seems to send this when you join a channel to list the users present
|
||||||
|
/// (even if documentation says it doesn't)
|
||||||
|
/// </summary>
|
||||||
RPL_NAMREPLY = 353,
|
RPL_NAMREPLY = 353,
|
||||||
RPL_LINKS = 364,
|
RPL_LINKS = 364,
|
||||||
RPL_ENDOFLINKS = 365,
|
RPL_ENDOFLINKS = 365,
|
||||||
|
/// <summary>
|
||||||
|
/// Sent after a series of 353.
|
||||||
|
/// </summary>
|
||||||
RPL_ENDOFNAMES = 366,
|
RPL_ENDOFNAMES = 366,
|
||||||
RPL_BANLIST = 367,
|
RPL_BANLIST = 367,
|
||||||
RPL_ENDOFBANLIST = 368,
|
RPL_ENDOFBANLIST = 368,
|
||||||
@@ -116,6 +122,9 @@ namespace TwitchLogger.IRC
|
|||||||
ERR_NORECIPIENT = 411,
|
ERR_NORECIPIENT = 411,
|
||||||
ERR_NOTEXTTOSEND = 412,
|
ERR_NOTEXTTOSEND = 412,
|
||||||
ERR_INPUTTOOLONG = 417,
|
ERR_INPUTTOOLONG = 417,
|
||||||
|
/// <summary>
|
||||||
|
/// Twitch should send this if you try using an unsupported command
|
||||||
|
/// </summary>
|
||||||
ERR_UNKNOWNCOMMAND = 421,
|
ERR_UNKNOWNCOMMAND = 421,
|
||||||
ERR_NOMOTD = 422,
|
ERR_NOMOTD = 422,
|
||||||
ERR_NONICKNAMEGIVEN = 431,
|
ERR_NONICKNAMEGIVEN = 431,
|
||||||
@@ -163,7 +172,7 @@ namespace TwitchLogger.IRC
|
|||||||
ERR_SASLMECHS = 908,
|
ERR_SASLMECHS = 908,
|
||||||
#endregion //Numeric
|
#endregion //Numeric
|
||||||
}
|
}
|
||||||
public static class IrcReceiveMessageTypeHelper
|
public static class IrcMessageTypeHelper
|
||||||
{
|
{
|
||||||
//parses a string that is either a numeric code or the command name
|
//parses a string that is either a numeric code or the command name
|
||||||
public static IrcMessageType Parse(string s)
|
public static IrcMessageType Parse(string s)
|
||||||
@@ -37,7 +37,7 @@ namespace TwitchLogger.IRC
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public static MessageTags Parse(string s)
|
public static MessageTags Parse(string s)
|
||||||
{
|
{
|
||||||
s.TrimStart('@');
|
s = s.TrimStart('@');
|
||||||
MessageTags tags = [];
|
MessageTags tags = [];
|
||||||
string key = "";
|
string key = "";
|
||||||
string value = "";
|
string value = "";
|
||||||
68
TwitchIrcClient/IRC/Messages/ClearChat.cs
Normal file
68
TwitchIrcClient/IRC/Messages/ClearChat.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace TwitchLogger.IRC.Messages
|
||||||
|
{
|
||||||
|
public class ClearChat : ReceivedMessage
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The number of seconds the user was timed out for. Is 0 if the
|
||||||
|
/// user was permabanned or the message is a channel clear.
|
||||||
|
/// </summary>
|
||||||
|
public int TimeoutDuration
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
string s = TryGetTag("ban-duration");
|
||||||
|
if (!int.TryParse(s, out int value))
|
||||||
|
return 0;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// The ID of the channel where the messages were removed from.
|
||||||
|
/// </summary>
|
||||||
|
public string RoomId => TryGetTag("room-id");
|
||||||
|
/// <summary>
|
||||||
|
/// The ID of the user that was banned or put in a timeout.
|
||||||
|
/// </summary>
|
||||||
|
public string TargetUserId => TryGetTag("target-user-id");
|
||||||
|
public DateTime? TmiSentTime
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
string s = TryGetTag("tmi-sent-ts");
|
||||||
|
if (!double.TryParse(s, out double d))
|
||||||
|
return null;
|
||||||
|
return DateTime.UnixEpoch.AddSeconds(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// true if the message permabans a user.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsBan
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
return MessageTags.ContainsKey("target-user-id")
|
||||||
|
&& !MessageTags.ContainsKey("ban-duration");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the channel that either was cleared or banned the user
|
||||||
|
/// </summary>
|
||||||
|
public string Channel => Parameters.First();
|
||||||
|
/// <summary>
|
||||||
|
/// The username of the banned user, or "" if message is a
|
||||||
|
/// channel clear.
|
||||||
|
/// </summary>
|
||||||
|
public string User => Parameters.ElementAtOrDefault(2) ?? "";
|
||||||
|
public ClearChat(ReceivedMessage message) : base(message)
|
||||||
|
{
|
||||||
|
Debug.Assert(MessageType == IrcMessageType.CLEARCHAT,
|
||||||
|
$"{nameof(ClearMsg)} must have type {IrcMessageType.CLEARCHAT}" +
|
||||||
|
$" but has {MessageType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
TwitchIrcClient/IRC/Messages/ClearMsg.cs
Normal file
47
TwitchIrcClient/IRC/Messages/ClearMsg.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace TwitchLogger.IRC.Messages
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates that a message was deleted.
|
||||||
|
/// </summary>
|
||||||
|
public class ClearMsg : ReceivedMessage
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The user who sent the deleted message.
|
||||||
|
/// </summary>
|
||||||
|
public string Login => TryGetTag("login");
|
||||||
|
/// <summary>
|
||||||
|
/// Optional. The ID of the channel (chat room) where the
|
||||||
|
/// message was removed from.
|
||||||
|
/// </summary>
|
||||||
|
public string RoomId => TryGetTag("room-id");
|
||||||
|
/// <summary>
|
||||||
|
/// A UUID that identifies the message that was removed.
|
||||||
|
/// </summary>
|
||||||
|
public string TargetMessageId => TryGetTag("target-msg-id");
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? TmiSentTime
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
string s = TryGetTag("tmi-sent-ts");
|
||||||
|
if (!double.TryParse(s, out double d))
|
||||||
|
return null;
|
||||||
|
return DateTime.UnixEpoch.AddSeconds(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public ClearMsg(ReceivedMessage message) : base(message)
|
||||||
|
{
|
||||||
|
Debug.Assert(MessageType == IrcMessageType.CLEARMSG,
|
||||||
|
$"{nameof(ClearMsg)} must have type {IrcMessageType.CLEARMSG}" +
|
||||||
|
$" but has {MessageType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
TwitchIrcClient/IRC/Messages/Join.cs
Normal file
20
TwitchIrcClient/IRC/Messages/Join.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace TwitchLogger.IRC.Messages
|
||||||
|
{
|
||||||
|
public class Join : ReceivedMessage
|
||||||
|
{
|
||||||
|
public string Username => Prefix?.Split('!', 2).First() ?? "";
|
||||||
|
public Join(ReceivedMessage message) : base(message)
|
||||||
|
{
|
||||||
|
Debug.Assert(MessageType == IrcMessageType.JOIN,
|
||||||
|
$"{nameof(Join)} must have type {IrcMessageType.JOIN}" +
|
||||||
|
$" but has {MessageType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
TwitchIrcClient/IRC/Messages/NamReply.cs
Normal file
23
TwitchIrcClient/IRC/Messages/NamReply.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace TwitchLogger.IRC.Messages
|
||||||
|
{
|
||||||
|
public class NamReply : ReceivedMessage
|
||||||
|
{
|
||||||
|
public IEnumerable<string> Users =>
|
||||||
|
Parameters.Last().Split(' ', StringSplitOptions.TrimEntries
|
||||||
|
| StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
public NamReply(ReceivedMessage message) : base(message)
|
||||||
|
{
|
||||||
|
Debug.Assert(MessageType == IrcMessageType.RPL_NAMREPLY,
|
||||||
|
$"{nameof(NamReply)} must have type {IrcMessageType.RPL_NAMREPLY}" +
|
||||||
|
$" but has {MessageType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
335
TwitchIrcClient/IRC/Messages/Notice.cs
Normal file
335
TwitchIrcClient/IRC/Messages/Notice.cs
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace TwitchLogger.IRC.Messages
|
||||||
|
{
|
||||||
|
public class Notice : ReceivedMessage
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see href="https://dev.twitch.tv/docs/irc/msg-id/"/>
|
||||||
|
/// </summary>
|
||||||
|
public NoticeId? MessageId => Enum.TryParse(TryGetTag("msg-id"), out NoticeId value)
|
||||||
|
? value : null;
|
||||||
|
//{ get
|
||||||
|
// {
|
||||||
|
// string spaced = TryGetTag("msg-id").Replace('_', ' ');
|
||||||
|
// string title = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(spaced);
|
||||||
|
// string pascal = title.Replace(" ", "");
|
||||||
|
// if (!Enum.TryParse(pascal, out NoticeId value))
|
||||||
|
// return null;
|
||||||
|
// return value;
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
public string TargetUserId => TryGetTag("target-user-id");
|
||||||
|
|
||||||
|
public Notice(ReceivedMessage message) : base(message)
|
||||||
|
{
|
||||||
|
Debug.Assert(MessageType == IrcMessageType.NOTICE,
|
||||||
|
$"{nameof(Notice)} must have type {IrcMessageType.NOTICE}" +
|
||||||
|
$" but has {MessageType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// <see href="https://dev.twitch.tv/docs/irc/msg-id/"/>
|
||||||
|
/// </summary>
|
||||||
|
public enum NoticeId
|
||||||
|
{
|
||||||
|
already_banned,
|
||||||
|
already_emote_only_off,
|
||||||
|
already_emote_only_on,
|
||||||
|
already_followers_off,
|
||||||
|
already_followers_on,
|
||||||
|
already_r9k_off,
|
||||||
|
already_r9k_on,
|
||||||
|
already_slow_off,
|
||||||
|
already_slow_on,
|
||||||
|
already_subs_off,
|
||||||
|
already_subs_on,
|
||||||
|
autohost_receive,
|
||||||
|
bad_ban_admin,
|
||||||
|
bad_ban_anon,
|
||||||
|
bad_ban_broadcaster,
|
||||||
|
bad_ban_mod,
|
||||||
|
bad_ban_self,
|
||||||
|
bad_ban_staff,
|
||||||
|
bad_commercial_error,
|
||||||
|
bad_delete_message_broadcaster,
|
||||||
|
bad_delete_message_mod,
|
||||||
|
bad_host_error,
|
||||||
|
bad_host_hosting,
|
||||||
|
bad_host_rate_exceeded,
|
||||||
|
bad_host_rejected,
|
||||||
|
bad_host_self,
|
||||||
|
bad_mod_banned,
|
||||||
|
bad_mod_mod,
|
||||||
|
bad_slow_duration,
|
||||||
|
bad_timeout_admin,
|
||||||
|
bad_timeout_anon,
|
||||||
|
bad_timeout_broadcaster,
|
||||||
|
bad_timeout_duration,
|
||||||
|
bad_timeout_mod,
|
||||||
|
bad_timeout_self,
|
||||||
|
bad_timeout_staff,
|
||||||
|
bad_unban_no_ban,
|
||||||
|
bad_unhost_error,
|
||||||
|
bad_unmod_mod,
|
||||||
|
bad_vip_grantee_banned,
|
||||||
|
bad_vip_grantee_already_vip,
|
||||||
|
bad_vip_max_vips_reached,
|
||||||
|
bad_vip_achievement_incomplete,
|
||||||
|
bad_unvip_grantee_not_vip,
|
||||||
|
ban_success,
|
||||||
|
cmds_available,
|
||||||
|
color_changed,
|
||||||
|
commercial_success,
|
||||||
|
delete_message_success,
|
||||||
|
delete_staff_message_success,
|
||||||
|
emote_only_off,
|
||||||
|
emote_only_on,
|
||||||
|
followers_off,
|
||||||
|
followers_on,
|
||||||
|
followers_on_zero,
|
||||||
|
host_off,
|
||||||
|
host_on,
|
||||||
|
host_receive,
|
||||||
|
host_receive_no_count,
|
||||||
|
host_target_went_offline,
|
||||||
|
hosts_remaining,
|
||||||
|
invalid_user,
|
||||||
|
mod_success,
|
||||||
|
msg_banned,
|
||||||
|
msg_bad_characters,
|
||||||
|
msg_channel_blocked,
|
||||||
|
msg_channel_suspended,
|
||||||
|
msg_duplicate,
|
||||||
|
msg_emoteonly,
|
||||||
|
msg_followersonly,
|
||||||
|
msg_followersonly_followed,
|
||||||
|
msg_followersonly_zero,
|
||||||
|
msg_r9k,
|
||||||
|
msg_ratelimit,
|
||||||
|
msg_rejected,
|
||||||
|
msg_rejected_mandatory,
|
||||||
|
msg_requires_verified_phone_number,
|
||||||
|
msg_slowmode,
|
||||||
|
msg_subsonly,
|
||||||
|
msg_suspended,
|
||||||
|
msg_timedout,
|
||||||
|
msg_verified_email,
|
||||||
|
no_help,
|
||||||
|
no_mods,
|
||||||
|
no_vips,
|
||||||
|
not_hosting,
|
||||||
|
no_permission,
|
||||||
|
r9k_off,
|
||||||
|
r9k_on,
|
||||||
|
raid_error_already_raiding,
|
||||||
|
raid_error_forbidden,
|
||||||
|
raid_error_self,
|
||||||
|
raid_error_too_many_viewers,
|
||||||
|
raid_error_unexpected,
|
||||||
|
raid_notice_mature,
|
||||||
|
raid_notice_restricted_chat,
|
||||||
|
room_mods,
|
||||||
|
slow_off,
|
||||||
|
slow_on,
|
||||||
|
subs_off,
|
||||||
|
subs_on,
|
||||||
|
timeout_no_timeout,
|
||||||
|
timeout_success,
|
||||||
|
tos_ban,
|
||||||
|
turbo_only_color,
|
||||||
|
unavailable_command,
|
||||||
|
unban_success,
|
||||||
|
unmod_success,
|
||||||
|
unraid_error_no_active_raid,
|
||||||
|
unraid_error_unexpected,
|
||||||
|
unraid_success,
|
||||||
|
unrecognized_cmd,
|
||||||
|
untimeout_banned,
|
||||||
|
untimeout_success,
|
||||||
|
unvip_success,
|
||||||
|
usage_ban,
|
||||||
|
usage_clear,
|
||||||
|
usage_color,
|
||||||
|
usage_commercial,
|
||||||
|
usage_disconnect,
|
||||||
|
usage_delete,
|
||||||
|
usage_emote_only_off,
|
||||||
|
usage_emote_only_on,
|
||||||
|
usage_followers_off,
|
||||||
|
usage_followers_on,
|
||||||
|
usage_help,
|
||||||
|
usage_host,
|
||||||
|
usage_marker,
|
||||||
|
usage_me,
|
||||||
|
usage_mod,
|
||||||
|
}
|
||||||
|
//public enum NoticeId
|
||||||
|
//{
|
||||||
|
// AlreadyBanned,
|
||||||
|
// AlreadyEmoteOnlyOff,
|
||||||
|
// AlreadyEmoteOnlyOn,
|
||||||
|
// AlreadyFollowersOff,
|
||||||
|
// AlreadyFollowersOn,
|
||||||
|
// AlreadyR9KOff,
|
||||||
|
// AlreadyR9KOn,
|
||||||
|
// AlreadySlowOff,
|
||||||
|
// AlreadySlowOn,
|
||||||
|
// AlreadySubsOff,
|
||||||
|
// AlreadySubsOn,
|
||||||
|
// AutohostReceive,
|
||||||
|
// BadBanAdmin,
|
||||||
|
// BadBanAnon,
|
||||||
|
// BadBanBroadcaster,
|
||||||
|
// BadBanMod,
|
||||||
|
// BadBanSelf,
|
||||||
|
// BadBanStaff,
|
||||||
|
// BadCommercialError,
|
||||||
|
// BadDeleteMessageBroadcaster,
|
||||||
|
// BadDeleteMessageMod,
|
||||||
|
// BadHostError,
|
||||||
|
// BadHostHosting,
|
||||||
|
// BadHostRateExceeded,
|
||||||
|
// BadHostRejected,
|
||||||
|
// BadHostSelf,
|
||||||
|
// BadModBanned,
|
||||||
|
// BadModMod,
|
||||||
|
// BadSlowDuration,
|
||||||
|
// BadTimeoutAdmin,
|
||||||
|
// BadTimeoutAnon,
|
||||||
|
// BadTimeoutBroadcaster,
|
||||||
|
// BadTimeoutDuration,
|
||||||
|
// BadTimeoutMod,
|
||||||
|
// BadTimeoutSelf,
|
||||||
|
// BadTimeoutStaff,
|
||||||
|
// BadUnbanNoBan,
|
||||||
|
// BadUnhostError,
|
||||||
|
// BadUnmodMod,
|
||||||
|
// BadVipGranteeBanned,
|
||||||
|
// BadVipGranteeAlreadyVip,
|
||||||
|
// BadVipMaxVipsReached,
|
||||||
|
// BadVipAchievementIncomplete,
|
||||||
|
// BadUnvipGranteeNotVip,
|
||||||
|
// BanSuccess,
|
||||||
|
// CmdsAvailable,
|
||||||
|
// ColorChanged,
|
||||||
|
// CommercialSuccess,
|
||||||
|
// DeleteMessageSuccess,
|
||||||
|
// DeleteStaffMessageSuccess,
|
||||||
|
// EmoteOnlyOff,
|
||||||
|
// EmoteOnlyOn,
|
||||||
|
// FollowersOff,
|
||||||
|
// FollowersOn,
|
||||||
|
// FollowersOnZero,
|
||||||
|
// HostOff,
|
||||||
|
// HostOn,
|
||||||
|
// HostReceive,
|
||||||
|
// HostReceiveNoCount,
|
||||||
|
// HostTargetWentOffline,
|
||||||
|
// HostsRemaining,
|
||||||
|
// InvalidUser,
|
||||||
|
// ModSuccess,
|
||||||
|
// MsgBanned,
|
||||||
|
// MsgBadCharacters,
|
||||||
|
// MsgChannelBlocked,
|
||||||
|
// MsgChannelSuspended,
|
||||||
|
// MsgDuplicate,
|
||||||
|
// MsgEmoteonly,
|
||||||
|
// MsgFollowersonly,
|
||||||
|
// MsgFollowersonlyFollowed,
|
||||||
|
// MsgFollowersonlyZero,
|
||||||
|
// MsgR9K,
|
||||||
|
// MsgRatelimit,
|
||||||
|
// MsgRejected,
|
||||||
|
// MsgRejectedMandatory,
|
||||||
|
// MsgRequiresVerifiedPhoneNumber,
|
||||||
|
// MsgSlowmode,
|
||||||
|
// MsgSubsonly,
|
||||||
|
// MsgSuspended,
|
||||||
|
// MsgTimedout,
|
||||||
|
// MsgVerifiedEmail,
|
||||||
|
// NoHelp,
|
||||||
|
// NoMods,
|
||||||
|
// NoVips,
|
||||||
|
// NotHosting,
|
||||||
|
// NoPermission,
|
||||||
|
// R9KOff,
|
||||||
|
// R9KOn,
|
||||||
|
// RaidErrorAlreadyRaiding,
|
||||||
|
// RaidErrorForbidden,
|
||||||
|
// RaidErrorSelf,
|
||||||
|
// RaidErrorTooManyViewers,
|
||||||
|
// RaidErrorUnexpected,
|
||||||
|
// RaidNoticeMature,
|
||||||
|
// RaidNoticeRestrictedChat,
|
||||||
|
// RoomMods,
|
||||||
|
// SlowOff,
|
||||||
|
// SlowOn,
|
||||||
|
// SubsOff,
|
||||||
|
// SubsOn,
|
||||||
|
// TimeoutNoTimeout,
|
||||||
|
// TimeoutSuccess,
|
||||||
|
// TosBan,
|
||||||
|
// TurboOnlyColor,
|
||||||
|
// UnavailableCommand,
|
||||||
|
// UnbanSuccess,
|
||||||
|
// UnmodSuccess,
|
||||||
|
// UnraidErrorNoActiveRaid,
|
||||||
|
// UnraidErrorUnexpected,
|
||||||
|
// UnraidSuccess,
|
||||||
|
// UnrecognizedCmd,
|
||||||
|
// UntimeoutBanned,
|
||||||
|
// UntimeoutSuccess,
|
||||||
|
// UnvipSuccess,
|
||||||
|
// UsageBan,
|
||||||
|
// UsageClear,
|
||||||
|
// UsageColor,
|
||||||
|
// UsageCommercial,
|
||||||
|
// UsageDisconnect,
|
||||||
|
// UsageDelete,
|
||||||
|
// UsageEmoteOnlyOff,
|
||||||
|
// UsageEmoteOnlyOn,
|
||||||
|
// UsageFollowersOff,
|
||||||
|
// UsageFollowersOn,
|
||||||
|
// UsageHelp,
|
||||||
|
// UsageHost,
|
||||||
|
// UsageMarker,
|
||||||
|
// UsageMe,
|
||||||
|
// UsageMod,
|
||||||
|
// UsageMods,
|
||||||
|
// UsageR9KOff,
|
||||||
|
// UsageR9KOn,
|
||||||
|
// UsageRaid,
|
||||||
|
// UsageSlowOff,
|
||||||
|
// UsageSlowOn,
|
||||||
|
// UsageSubsOff,
|
||||||
|
// UsageSubsOn,
|
||||||
|
// UsageTimeout,
|
||||||
|
// UsageUnban,
|
||||||
|
// UsageUnhost,
|
||||||
|
// UsageUnmod,
|
||||||
|
// UsageUnraid,
|
||||||
|
// UsageUntimeout,
|
||||||
|
// UsageUnvip,
|
||||||
|
// UsageUser,
|
||||||
|
// UsageVip,
|
||||||
|
// UsageVips,
|
||||||
|
// UsageWhisper,
|
||||||
|
// VipSuccess,
|
||||||
|
// VipsSuccess,
|
||||||
|
// WhisperBanned,
|
||||||
|
// WhisperBannedRecipient,
|
||||||
|
// WhisperInvalidLogin,
|
||||||
|
// WhisperInvalidSelf,
|
||||||
|
// WhisperLimitPerMin,
|
||||||
|
// WhisperLimitPerSec,
|
||||||
|
// WhisperRestricted,
|
||||||
|
// WhisperRestrictedRecipient,
|
||||||
|
//}
|
||||||
|
}
|
||||||
20
TwitchIrcClient/IRC/Messages/Part.cs
Normal file
20
TwitchIrcClient/IRC/Messages/Part.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace TwitchLogger.IRC.Messages
|
||||||
|
{
|
||||||
|
public class Part : ReceivedMessage
|
||||||
|
{
|
||||||
|
public string Username => Prefix?.Split('!', 2).First() ?? "";
|
||||||
|
public Part(ReceivedMessage message) : base(message)
|
||||||
|
{
|
||||||
|
Debug.Assert(MessageType == IrcMessageType.PART,
|
||||||
|
$"{nameof(Part)} must have type {IrcMessageType.PART}" +
|
||||||
|
$" but has {MessageType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
168
TwitchIrcClient/IRC/Messages/Privmsg.cs
Normal file
168
TwitchIrcClient/IRC/Messages/Privmsg.cs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace TwitchLogger.IRC.Messages
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public class Privmsg : ReceivedMessage
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// List of chat badges. Most badges have only 1 version, but some badges like
|
||||||
|
/// subscriber badges offer different versions of the badge depending on how
|
||||||
|
/// long the user has subscribed. To get the badge, use the Get Global Chat
|
||||||
|
/// Badges and Get Channel Chat Badges APIs.Match the badge to the set-id field’s
|
||||||
|
/// value in the response.Then, match the version to the id field in the list of versions.
|
||||||
|
/// </summary>
|
||||||
|
public List<Badge> Badges
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("badges", out string? value))
|
||||||
|
return [];
|
||||||
|
if (value == null)
|
||||||
|
return [];
|
||||||
|
List<Badge> badges = [];
|
||||||
|
foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var spl = item.Split('/', 2);
|
||||||
|
badges.Add(new Badge(spl[0], spl[1]));
|
||||||
|
}
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// The amount of bits cheered. Equals 0 if message did not contain a cheer.
|
||||||
|
/// </summary>
|
||||||
|
public int Bits
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("bits", out string? value))
|
||||||
|
return 0;
|
||||||
|
if (!int.TryParse(value, out int bits))
|
||||||
|
return 0;
|
||||||
|
return bits;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// The color of the user’s name in the chat room. This is a hexadecimal
|
||||||
|
/// RGB color code in the form, #<RGB>. This tag may be empty if it is never set.
|
||||||
|
/// </summary>
|
||||||
|
public Color? Color
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
//should have format "#RRGGBB"
|
||||||
|
if (!MessageTags.TryGetValue("color", out string? value))
|
||||||
|
return null;
|
||||||
|
if (value.Length < 7)
|
||||||
|
return null;
|
||||||
|
int r = Convert.ToInt32(value.Substring(1, 2), 16);
|
||||||
|
int g = Convert.ToInt32(value.Substring(3, 2), 16);
|
||||||
|
int b = Convert.ToInt32(value.Substring(5, 2), 16);
|
||||||
|
return System.Drawing.Color.FromArgb(r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// The user’s display name. This tag may be empty if it is never set.
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayName
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("display-name", out string? value))
|
||||||
|
return "";
|
||||||
|
return value ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// An ID that uniquely identifies the message.
|
||||||
|
/// </summary>
|
||||||
|
public string Id => TryGetTag("id");
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the user is a moderator in this channel
|
||||||
|
/// </summary>
|
||||||
|
public bool Moderator
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("mod", out string? value))
|
||||||
|
return false;
|
||||||
|
return value == "1";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// An ID that identifies the chat room (channel).
|
||||||
|
/// </summary>
|
||||||
|
public string RoomId => TryGetTag("room-id");
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the user is subscribed to the channel
|
||||||
|
/// </summary>
|
||||||
|
public bool Subscriber
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("subscriber", out string? value))
|
||||||
|
return false;
|
||||||
|
return value == "1";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// A Boolean value that indicates whether the user has site-wide commercial
|
||||||
|
/// free mode enabled
|
||||||
|
/// </summary>
|
||||||
|
public bool Turbo
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("turbo", out string? value))
|
||||||
|
return false;
|
||||||
|
return value == "1";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// The user’s ID
|
||||||
|
/// </summary>
|
||||||
|
public string UserId
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("user-id", out string? value))
|
||||||
|
return "";
|
||||||
|
return value ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// The type of the user. Assumes a normal user if this is not provided or is invalid.
|
||||||
|
/// </summary>
|
||||||
|
public UserType UserType
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("user-type", out string? value))
|
||||||
|
return UserType.Normal;
|
||||||
|
switch (value)
|
||||||
|
{
|
||||||
|
case "admin":
|
||||||
|
return UserType.Admin;
|
||||||
|
case "global_mod":
|
||||||
|
return UserType.GlobalMod;
|
||||||
|
case "staff":
|
||||||
|
return UserType.Staff;
|
||||||
|
default:
|
||||||
|
return UserType.Normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// A Boolean value that determines whether the user that sent the chat is a VIP.
|
||||||
|
/// </summary>
|
||||||
|
public bool Vip => MessageTags.ContainsKey("vip");
|
||||||
|
public string ChatMessage => Parameters.Last();
|
||||||
|
public Privmsg(ReceivedMessage message) : base(message)
|
||||||
|
{
|
||||||
|
Debug.Assert(MessageType == IrcMessageType.PRIVMSG,
|
||||||
|
$"{nameof(Privmsg)} must have type {IrcMessageType.PRIVMSG}" +
|
||||||
|
$" but has {MessageType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
TwitchIrcClient/IRC/Messages/ReceivedMessage.cs
Normal file
128
TwitchIrcClient/IRC/Messages/ReceivedMessage.cs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
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.Messages
|
||||||
|
{
|
||||||
|
/// <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; protected set; }
|
||||||
|
public string? Prefix { get; protected set; }
|
||||||
|
public string? Source { get; protected set; }
|
||||||
|
public List<string> Parameters { get; } = [];
|
||||||
|
public string RawParameters { get; protected set; }
|
||||||
|
public string RawText { get; protected set; }
|
||||||
|
public MessageTags MessageTags { get; protected set; } = [];
|
||||||
|
|
||||||
|
protected ReceivedMessage()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
protected ReceivedMessage(ReceivedMessage other)
|
||||||
|
{
|
||||||
|
MessageType = other.MessageType;
|
||||||
|
Prefix = other.Prefix;
|
||||||
|
Source = other.Source;
|
||||||
|
Parameters = [.. other.Parameters];
|
||||||
|
RawParameters = other.RawParameters;
|
||||||
|
RawText = other.RawText;
|
||||||
|
MessageTags = [.. other.MessageTags];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 = IrcMessageTypeHelper.Parse(spl_command[0]);
|
||||||
|
//message has parameters
|
||||||
|
if (spl_command.Length >= 2)
|
||||||
|
{
|
||||||
|
s = spl_command[1];
|
||||||
|
message.RawParameters = s;
|
||||||
|
//message has single parameter marked as the final parameter
|
||||||
|
//this needs to be handled specially because the leading ' '
|
||||||
|
//is stripped
|
||||||
|
if (s.StartsWith(':'))
|
||||||
|
{
|
||||||
|
message.Parameters.Add(s.Substring(1));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (message.MessageType)
|
||||||
|
{
|
||||||
|
case IrcMessageType.PRIVMSG:
|
||||||
|
return new Privmsg(message);
|
||||||
|
case IrcMessageType.CLEARCHAT:
|
||||||
|
return new ClearChat(message);
|
||||||
|
case IrcMessageType.CLEARMSG:
|
||||||
|
return new ClearMsg(message);
|
||||||
|
case IrcMessageType.NOTICE:
|
||||||
|
return new Notice(message);
|
||||||
|
case IrcMessageType.JOIN:
|
||||||
|
return new Join(message);
|
||||||
|
case IrcMessageType.PART:
|
||||||
|
return new Part(message);
|
||||||
|
case IrcMessageType.RPL_NAMREPLY:
|
||||||
|
return new NamReply(message);
|
||||||
|
default:
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to get the value of the tag.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="s"></param>
|
||||||
|
/// <returns>the value of the tag, or "" if not found</returns>
|
||||||
|
protected string TryGetTag(string s)
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue(s, out string? value))
|
||||||
|
return "";
|
||||||
|
return value ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
160
TwitchIrcClient/IRC/Messages/UserNotice.cs
Normal file
160
TwitchIrcClient/IRC/Messages/UserNotice.cs
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace TwitchLogger.IRC.Messages
|
||||||
|
{
|
||||||
|
public class UserNotice : ReceivedMessage
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// List of chat badges. Most badges have only 1 version, but some badges like
|
||||||
|
/// subscriber badges offer different versions of the badge depending on how
|
||||||
|
/// long the user has subscribed. To get the badge, use the Get Global Chat
|
||||||
|
/// Badges and Get Channel Chat Badges APIs.Match the badge to the set-id field’s
|
||||||
|
/// value in the response.Then, match the version to the id field in the list of versions.
|
||||||
|
/// </summary>
|
||||||
|
public List<Badge> Badges
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
string value = TryGetTag("badges");
|
||||||
|
if (value == "")
|
||||||
|
return [];
|
||||||
|
List<Badge> badges = [];
|
||||||
|
foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var spl = item.Split('/', 2);
|
||||||
|
badges.Add(new Badge(spl[0], spl[1]));
|
||||||
|
}
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// The color of the user’s name in the chat room. This is a hexadecimal
|
||||||
|
/// RGB color code in the form, #<RGB>. This tag may be empty if it is never set.
|
||||||
|
/// </summary>
|
||||||
|
public Color? Color
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
//should have format "#RRGGBB"
|
||||||
|
if (!MessageTags.TryGetValue("color", out string? value))
|
||||||
|
return null;
|
||||||
|
if (value.Length < 7)
|
||||||
|
return null;
|
||||||
|
int r = Convert.ToInt32(value.Substring(1, 2), 16);
|
||||||
|
int g = Convert.ToInt32(value.Substring(3, 2), 16);
|
||||||
|
int b = Convert.ToInt32(value.Substring(5, 2), 16);
|
||||||
|
return System.Drawing.Color.FromArgb(r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// The user’s display name. This tag may be empty if it is never set.
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayName
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("display-name", out string? value))
|
||||||
|
return "";
|
||||||
|
return value ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// An ID that uniquely identifies the message.
|
||||||
|
/// </summary>
|
||||||
|
public string Id => TryGetTag("id");
|
||||||
|
public UserNoticeType? UserNoticeType => Enum.TryParse(TryGetTag("msg-id"), out UserNoticeType type)
|
||||||
|
? type : null;
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the user is a moderator in this channel
|
||||||
|
/// </summary>
|
||||||
|
public bool Moderator
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("mod", out string? value))
|
||||||
|
return false;
|
||||||
|
return value == "1";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// An ID that identifies the chat room (channel).
|
||||||
|
/// </summary>
|
||||||
|
public string RoomId => TryGetTag("room-id");
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the user is subscribed to the channel
|
||||||
|
/// </summary>
|
||||||
|
public bool Subscriber
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("subscriber", out string? value))
|
||||||
|
return false;
|
||||||
|
return value == "1";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// A Boolean value that indicates whether the user has site-wide commercial
|
||||||
|
/// free mode enabled
|
||||||
|
/// </summary>
|
||||||
|
public bool Turbo
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("turbo", out string? value))
|
||||||
|
return false;
|
||||||
|
return value == "1";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// The user’s ID
|
||||||
|
/// </summary>
|
||||||
|
public string UserId
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("user-id", out string? value))
|
||||||
|
return "";
|
||||||
|
return value ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public UserType UserType
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("user-type", out string? value))
|
||||||
|
return UserType.Normal;
|
||||||
|
switch (value)
|
||||||
|
{
|
||||||
|
case "admin":
|
||||||
|
return UserType.Admin;
|
||||||
|
case "global_mod":
|
||||||
|
return UserType.GlobalMod;
|
||||||
|
case "staff":
|
||||||
|
return UserType.Staff;
|
||||||
|
default:
|
||||||
|
return UserType.Normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public UserNotice(ReceivedMessage message) : base(message)
|
||||||
|
{
|
||||||
|
Debug.Assert(MessageType == IrcMessageType.USERNOTICE,
|
||||||
|
$"{nameof(UserNotice)} must have type {IrcMessageType.USERNOTICE}" +
|
||||||
|
$" but has {MessageType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public enum UserNoticeType
|
||||||
|
{
|
||||||
|
sub,
|
||||||
|
resub,
|
||||||
|
subgift,
|
||||||
|
submysterygift,
|
||||||
|
giftpaidupgrade,
|
||||||
|
rewardgift,
|
||||||
|
anongiftpaidupgrade,
|
||||||
|
raid,
|
||||||
|
unraid,
|
||||||
|
ritual,
|
||||||
|
bitsbadgetier,
|
||||||
|
}
|
||||||
|
}
|
||||||
113
TwitchIrcClient/IRC/RateLimiter.cs
Normal file
113
TwitchIrcClient/IRC/RateLimiter.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace TwitchLogger.IRC
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Prevents sending too many messages in a time period. A single rate limiter can
|
||||||
|
/// be shared between multiple connections. A <see cref="CancellationToken"/> can be
|
||||||
|
/// passed with each request to track whether the requesting i
|
||||||
|
/// </summary>
|
||||||
|
public class RateLimiter : IDisposable
|
||||||
|
{
|
||||||
|
private SemaphoreSlim Semaphore;
|
||||||
|
private System.Timers.Timer Timer;
|
||||||
|
public int MessageLimit { get; }
|
||||||
|
public int Seconds { get; }
|
||||||
|
|
||||||
|
public RateLimiter(int messages, int seconds)
|
||||||
|
{
|
||||||
|
Semaphore = new(messages, messages);
|
||||||
|
Timer = new(TimeSpan.FromSeconds(seconds));
|
||||||
|
MessageLimit = messages;
|
||||||
|
Seconds = seconds;
|
||||||
|
Timer.AutoReset = true;
|
||||||
|
Timer.Elapsed += ResetLimit;
|
||||||
|
Timer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WaitForAvailable(CancellationToken? token = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (token is CancellationToken actualToken)
|
||||||
|
Semaphore.Wait(actualToken);
|
||||||
|
else
|
||||||
|
Semaphore.Wait();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
//caller is responsible for checking whether connection is cancelled before trying to send
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public bool WaitForAvailable(TimeSpan timeout, CancellationToken? token = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (token is CancellationToken actualToken)
|
||||||
|
return Semaphore.Wait(timeout, actualToken);
|
||||||
|
else
|
||||||
|
return Semaphore.Wait(timeout);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public bool WaitForAvailable(int millis, CancellationToken? token = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (token is CancellationToken actualToken)
|
||||||
|
return Semaphore.Wait(millis, actualToken);
|
||||||
|
else
|
||||||
|
return Semaphore.Wait(millis);
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
16
TwitchIrcClient/IRC/UserType.cs
Normal file
16
TwitchIrcClient/IRC/UserType.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace TwitchLogger.IRC
|
||||||
|
{
|
||||||
|
public enum UserType
|
||||||
|
{
|
||||||
|
Normal,
|
||||||
|
Admin,
|
||||||
|
GlobalMod,
|
||||||
|
Staff,
|
||||||
|
}
|
||||||
|
}
|
||||||
1
TwitchIrcClient/Program.cs
Normal file
1
TwitchIrcClient/Program.cs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
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
|
|
||||||
{
|
|
||||||
/// <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; } = 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<bool> 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<byte>(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<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)
|
|
||||||
{
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// See https://aka.ms/new-console-template for more information
|
|
||||||
Console.WriteLine("Hello, World!");
|
|
||||||
Reference in New Issue
Block a user