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
|
||||
VisualStudioVersion = 17.9.34607.119
|
||||
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
|
||||
Global
|
||||
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.Text;
|
||||
using System.Threading.Tasks;
|
||||
using TwitchLogger.IRC.Messages;
|
||||
|
||||
namespace TwitchLogger.IRC
|
||||
{
|
||||
public class IrcMessageEventArgs(ReceivedMessage message) : EventArgs
|
||||
{
|
||||
public ReceivedMessage Message = message;
|
||||
}
|
||||
public delegate void IrcCallback(ReceivedMessage message);
|
||||
//public class IrcMessageEventArgs(ReceivedMessage message) : EventArgs
|
||||
//{
|
||||
// public ReceivedMessage Message = message;
|
||||
//}
|
||||
public delegate void MessageCallback(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,
|
||||
public readonly record struct MessageCallbackItem(
|
||||
MessageCallback Callback,
|
||||
IReadOnlyList<IrcMessageType>? CallbackTypes)
|
||||
{
|
||||
public bool TryCall(ReceivedMessage message)
|
||||
@@ -30,4 +31,9 @@ namespace TwitchLogger.IRC
|
||||
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,
|
||||
|
||||
#region Numeric
|
||||
//none of these are actually Twitch supported other than 421
|
||||
RPL_WELCOME = 001,
|
||||
RPL_YOURHOST = 002,
|
||||
RPL_CREATED = 003,
|
||||
@@ -88,9 +87,16 @@ namespace TwitchLogger.IRC
|
||||
RPL_ENDOFEXCEPTLIST = 349,
|
||||
RPL_VERSION = 351,
|
||||
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_LINKS = 364,
|
||||
RPL_ENDOFLINKS = 365,
|
||||
/// <summary>
|
||||
/// Sent after a series of 353.
|
||||
/// </summary>
|
||||
RPL_ENDOFNAMES = 366,
|
||||
RPL_BANLIST = 367,
|
||||
RPL_ENDOFBANLIST = 368,
|
||||
@@ -116,6 +122,9 @@ namespace TwitchLogger.IRC
|
||||
ERR_NORECIPIENT = 411,
|
||||
ERR_NOTEXTTOSEND = 412,
|
||||
ERR_INPUTTOOLONG = 417,
|
||||
/// <summary>
|
||||
/// Twitch should send this if you try using an unsupported command
|
||||
/// </summary>
|
||||
ERR_UNKNOWNCOMMAND = 421,
|
||||
ERR_NOMOTD = 422,
|
||||
ERR_NONICKNAMEGIVEN = 431,
|
||||
@@ -163,7 +172,7 @@ namespace TwitchLogger.IRC
|
||||
ERR_SASLMECHS = 908,
|
||||
#endregion //Numeric
|
||||
}
|
||||
public static class IrcReceiveMessageTypeHelper
|
||||
public static class IrcMessageTypeHelper
|
||||
{
|
||||
//parses a string that is either a numeric code or the command name
|
||||
public static IrcMessageType Parse(string s)
|
||||
@@ -37,7 +37,7 @@ namespace TwitchLogger.IRC
|
||||
/// <returns></returns>
|
||||
public static MessageTags Parse(string s)
|
||||
{
|
||||
s.TrimStart('@');
|
||||
s = s.TrimStart('@');
|
||||
MessageTags tags = [];
|
||||
string key = "";
|
||||
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