From 57f83372582f80bcea8e82f61a6c426a9dbd9dfe Mon Sep 17 00:00:00 2001 From: Ikatono Date: Tue, 19 Mar 2024 21:27:43 -0500 Subject: [PATCH] Improved parsing of more IRC message types, and added optional tracking of which users are in the stream. --- TwitchLogger.sln => TwitchIrcClient.sln | 2 +- TwitchIrcClient/IRC/Badge.cs | 14 + .../IRC/Callbacks.cs | 20 +- TwitchIrcClient/IRC/IrcConnection.cs | 296 ++++++++++++++++ .../IRC/IrcMessageType.cs | 13 +- .../IRC/MessageTags.cs | 2 +- TwitchIrcClient/IRC/Messages/ClearChat.cs | 68 ++++ TwitchIrcClient/IRC/Messages/ClearMsg.cs | 47 +++ TwitchIrcClient/IRC/Messages/Join.cs | 20 ++ TwitchIrcClient/IRC/Messages/NamReply.cs | 23 ++ TwitchIrcClient/IRC/Messages/Notice.cs | 335 ++++++++++++++++++ TwitchIrcClient/IRC/Messages/Part.cs | 20 ++ TwitchIrcClient/IRC/Messages/Privmsg.cs | 168 +++++++++ .../IRC/Messages/ReceivedMessage.cs | 128 +++++++ TwitchIrcClient/IRC/Messages/UserNotice.cs | 160 +++++++++ TwitchIrcClient/IRC/RateLimiter.cs | 113 ++++++ TwitchIrcClient/IRC/UserType.cs | 16 + TwitchIrcClient/Program.cs | 1 + .../TwitchIrcClient.csproj | 0 TwitchLogger/IRC/IrcConnection.cs | 254 ------------- TwitchLogger/IRC/ReceivedMessage.cs | 74 ---- TwitchLogger/Program.cs | 2 - 22 files changed, 1435 insertions(+), 341 deletions(-) rename TwitchLogger.sln => TwitchIrcClient.sln (86%) create mode 100644 TwitchIrcClient/IRC/Badge.cs rename {TwitchLogger => TwitchIrcClient}/IRC/Callbacks.cs (58%) create mode 100644 TwitchIrcClient/IRC/IrcConnection.cs rename {TwitchLogger => TwitchIrcClient}/IRC/IrcMessageType.cs (92%) rename {TwitchLogger => TwitchIrcClient}/IRC/MessageTags.cs (99%) create mode 100644 TwitchIrcClient/IRC/Messages/ClearChat.cs create mode 100644 TwitchIrcClient/IRC/Messages/ClearMsg.cs create mode 100644 TwitchIrcClient/IRC/Messages/Join.cs create mode 100644 TwitchIrcClient/IRC/Messages/NamReply.cs create mode 100644 TwitchIrcClient/IRC/Messages/Notice.cs create mode 100644 TwitchIrcClient/IRC/Messages/Part.cs create mode 100644 TwitchIrcClient/IRC/Messages/Privmsg.cs create mode 100644 TwitchIrcClient/IRC/Messages/ReceivedMessage.cs create mode 100644 TwitchIrcClient/IRC/Messages/UserNotice.cs create mode 100644 TwitchIrcClient/IRC/RateLimiter.cs create mode 100644 TwitchIrcClient/IRC/UserType.cs create mode 100644 TwitchIrcClient/Program.cs rename TwitchLogger/TwitchLogger.csproj => TwitchIrcClient/TwitchIrcClient.csproj (100%) delete mode 100644 TwitchLogger/IRC/IrcConnection.cs delete mode 100644 TwitchLogger/IRC/ReceivedMessage.cs delete mode 100644 TwitchLogger/Program.cs diff --git a/TwitchLogger.sln b/TwitchIrcClient.sln similarity index 86% rename from TwitchLogger.sln rename to TwitchIrcClient.sln index 0c6a7a6..c705abd 100644 --- a/TwitchLogger.sln +++ b/TwitchIrcClient.sln @@ -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 diff --git a/TwitchIrcClient/IRC/Badge.cs b/TwitchIrcClient/IRC/Badge.cs new file mode 100644 index 0000000..8d5ec48 --- /dev/null +++ b/TwitchIrcClient/IRC/Badge.cs @@ -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) + { + + } +} diff --git a/TwitchLogger/IRC/Callbacks.cs b/TwitchIrcClient/IRC/Callbacks.cs similarity index 58% rename from TwitchLogger/IRC/Callbacks.cs rename to TwitchIrcClient/IRC/Callbacks.cs index 9cfdd69..21b7528 100644 --- a/TwitchLogger/IRC/Callbacks.cs +++ b/TwitchIrcClient/IRC/Callbacks.cs @@ -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); /// /// Callback to be run for received messages of specific types. /// /// /// set to null to run for all message types - public readonly record struct CallbackItem( - IrcCallback Callback, + public readonly record struct MessageCallbackItem( + MessageCallback Callback, IReadOnlyList? CallbackTypes) { public bool TryCall(ReceivedMessage message) @@ -30,4 +31,9 @@ namespace TwitchLogger.IRC return false; } } + public class UserChangeEventArgs(IList joined, IList left) : EventArgs + { + public readonly IList Joined = joined; + public IList Left = left; + } } diff --git a/TwitchIrcClient/IRC/IrcConnection.cs b/TwitchIrcClient/IRC/IrcConnection.cs new file mode 100644 index 0000000..2ea6cc6 --- /dev/null +++ b/TwitchIrcClient/IRC/IrcConnection.cs @@ -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 +{ + /// + /// Connects to a single Twitch chat channel via limited IRC implementation. + /// + /// + /// + /// + 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 UserCollection = new(); + public IEnumerable Users => UserCollection.Keys; + + public event EventHandler? onTimeout; + /// + /// 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 + /// + public event EventHandler? 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 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(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 _JoinedUsers = []; + ConcurrentBag _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 join = []; + List 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 UserCallbacks = []; + protected readonly List 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 + } +} diff --git a/TwitchLogger/IRC/IrcMessageType.cs b/TwitchIrcClient/IRC/IrcMessageType.cs similarity index 92% rename from TwitchLogger/IRC/IrcMessageType.cs rename to TwitchIrcClient/IRC/IrcMessageType.cs index 7b89eee..1d31e6c 100644 --- a/TwitchLogger/IRC/IrcMessageType.cs +++ b/TwitchIrcClient/IRC/IrcMessageType.cs @@ -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, + /// + /// Twitch seems to send this when you join a channel to list the users present + /// (even if documentation says it doesn't) + /// RPL_NAMREPLY = 353, RPL_LINKS = 364, RPL_ENDOFLINKS = 365, + /// + /// Sent after a series of 353. + /// 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, + /// + /// Twitch should send this if you try using an unsupported command + /// 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) diff --git a/TwitchLogger/IRC/MessageTags.cs b/TwitchIrcClient/IRC/MessageTags.cs similarity index 99% rename from TwitchLogger/IRC/MessageTags.cs rename to TwitchIrcClient/IRC/MessageTags.cs index b57650c..7aae2f5 100644 --- a/TwitchLogger/IRC/MessageTags.cs +++ b/TwitchIrcClient/IRC/MessageTags.cs @@ -37,7 +37,7 @@ namespace TwitchLogger.IRC /// public static MessageTags Parse(string s) { - s.TrimStart('@'); + s = s.TrimStart('@'); MessageTags tags = []; string key = ""; string value = ""; diff --git a/TwitchIrcClient/IRC/Messages/ClearChat.cs b/TwitchIrcClient/IRC/Messages/ClearChat.cs new file mode 100644 index 0000000..ba92d79 --- /dev/null +++ b/TwitchIrcClient/IRC/Messages/ClearChat.cs @@ -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 + { + /// + /// The number of seconds the user was timed out for. Is 0 if the + /// user was permabanned or the message is a channel clear. + /// + public int TimeoutDuration + { get + { + string s = TryGetTag("ban-duration"); + if (!int.TryParse(s, out int value)) + return 0; + return value; + } + } + /// + /// The ID of the channel where the messages were removed from. + /// + public string RoomId => TryGetTag("room-id"); + /// + /// The ID of the user that was banned or put in a timeout. + /// + 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); + } + } + /// + /// true if the message permabans a user. + /// + public bool IsBan + { get + { + return MessageTags.ContainsKey("target-user-id") + && !MessageTags.ContainsKey("ban-duration"); + } + } + /// + /// The name of the channel that either was cleared or banned the user + /// + public string Channel => Parameters.First(); + /// + /// The username of the banned user, or "" if message is a + /// channel clear. + /// + 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}"); + } + } +} diff --git a/TwitchIrcClient/IRC/Messages/ClearMsg.cs b/TwitchIrcClient/IRC/Messages/ClearMsg.cs new file mode 100644 index 0000000..df2bea2 --- /dev/null +++ b/TwitchIrcClient/IRC/Messages/ClearMsg.cs @@ -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 +{ + /// + /// Indicates that a message was deleted. + /// + public class ClearMsg : ReceivedMessage + { + /// + /// The user who sent the deleted message. + /// + public string Login => TryGetTag("login"); + /// + /// Optional. The ID of the channel (chat room) where the + /// message was removed from. + /// + public string RoomId => TryGetTag("room-id"); + /// + /// A UUID that identifies the message that was removed. + /// + public string TargetMessageId => TryGetTag("target-msg-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); + } + } + public ClearMsg(ReceivedMessage message) : base(message) + { + Debug.Assert(MessageType == IrcMessageType.CLEARMSG, + $"{nameof(ClearMsg)} must have type {IrcMessageType.CLEARMSG}" + + $" but has {MessageType}"); + } + } +} diff --git a/TwitchIrcClient/IRC/Messages/Join.cs b/TwitchIrcClient/IRC/Messages/Join.cs new file mode 100644 index 0000000..e05f9eb --- /dev/null +++ b/TwitchIrcClient/IRC/Messages/Join.cs @@ -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}"); + } + } +} diff --git a/TwitchIrcClient/IRC/Messages/NamReply.cs b/TwitchIrcClient/IRC/Messages/NamReply.cs new file mode 100644 index 0000000..b1cd951 --- /dev/null +++ b/TwitchIrcClient/IRC/Messages/NamReply.cs @@ -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 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}"); + } + } +} diff --git a/TwitchIrcClient/IRC/Messages/Notice.cs b/TwitchIrcClient/IRC/Messages/Notice.cs new file mode 100644 index 0000000..abb21e7 --- /dev/null +++ b/TwitchIrcClient/IRC/Messages/Notice.cs @@ -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 + { + /// + /// + /// + 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}"); + } + } + /// + /// + /// + 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, + //} +} diff --git a/TwitchIrcClient/IRC/Messages/Part.cs b/TwitchIrcClient/IRC/Messages/Part.cs new file mode 100644 index 0000000..bae8337 --- /dev/null +++ b/TwitchIrcClient/IRC/Messages/Part.cs @@ -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}"); + } + } +} diff --git a/TwitchIrcClient/IRC/Messages/Privmsg.cs b/TwitchIrcClient/IRC/Messages/Privmsg.cs new file mode 100644 index 0000000..e8c2480 --- /dev/null +++ b/TwitchIrcClient/IRC/Messages/Privmsg.cs @@ -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 +{ + /// + /// + /// + public class Privmsg : ReceivedMessage + { + /// + /// 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. + /// + public List Badges + { get + { + if (!MessageTags.TryGetValue("badges", out string? value)) + return []; + if (value == null) + return []; + List badges = []; + foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + var spl = item.Split('/', 2); + badges.Add(new Badge(spl[0], spl[1])); + } + return badges; + } + } + /// + /// The amount of bits cheered. Equals 0 if message did not contain a cheer. + /// + public int Bits + { get + { + if (!MessageTags.TryGetValue("bits", out string? value)) + return 0; + if (!int.TryParse(value, out int bits)) + return 0; + return bits; + } + } + /// + /// The color of the user’s name in the chat room. This is a hexadecimal + /// RGB color code in the form, #. This tag may be empty if it is never set. + /// + 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); + } + } + /// + /// The user’s display name. This tag may be empty if it is never set. + /// + public string DisplayName + { get + { + if (!MessageTags.TryGetValue("display-name", out string? value)) + return ""; + return value ?? ""; + } + } + /// + /// An ID that uniquely identifies the message. + /// + public string Id => TryGetTag("id"); + /// + /// Whether the user is a moderator in this channel + /// + public bool Moderator + { get + { + if (!MessageTags.TryGetValue("mod", out string? value)) + return false; + return value == "1"; + } + } + /// + /// An ID that identifies the chat room (channel). + /// + public string RoomId => TryGetTag("room-id"); + /// + /// Whether the user is subscribed to the channel + /// + public bool Subscriber + { get + { + if (!MessageTags.TryGetValue("subscriber", out string? value)) + return false; + return value == "1"; + } + } + /// + /// A Boolean value that indicates whether the user has site-wide commercial + /// free mode enabled + /// + public bool Turbo + { get + { + if (!MessageTags.TryGetValue("turbo", out string? value)) + return false; + return value == "1"; + } + } + /// + /// The user’s ID + /// + public string UserId + { get + { + if (!MessageTags.TryGetValue("user-id", out string? value)) + return ""; + return value ?? ""; + } + } + /// + /// The type of the user. Assumes a normal user if this is not provided or is invalid. + /// + 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; + } + } + } + /// + /// A Boolean value that determines whether the user that sent the chat is a VIP. + /// + 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}"); + } + } +} diff --git a/TwitchIrcClient/IRC/Messages/ReceivedMessage.cs b/TwitchIrcClient/IRC/Messages/ReceivedMessage.cs new file mode 100644 index 0000000..57096bb --- /dev/null +++ b/TwitchIrcClient/IRC/Messages/ReceivedMessage.cs @@ -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 +{ + /// + /// + /// + /// + /// This is designed according to + /// but only implementing features used by Twitch chat. See for + /// specifics about Twitch chat's use of IRC. Currently messages are not fully validated. + /// + public class ReceivedMessage + { + public IrcMessageType MessageType { get; protected set; } + public string? Prefix { get; protected set; } + public string? Source { get; protected set; } + public List 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]; + } + + /// + /// Parses an IRC message into the proper message type + /// + /// + /// the parsed message + public static ReceivedMessage Parse(string s) + { + ReceivedMessage message = new(); + message.RawText = s; + //message has tags + if (s.StartsWith('@')) + { + s = s[1..]; + //first ' ' acts as the delimeter + var split = s.Split(' ', 2); + Debug.Assert(split.Length == 2, "no space found to end tag section"); + string tagString = split[0]; + s = split[1].TrimStart(' '); + message.MessageTags = MessageTags.Parse(tagString); + } + //message has source + if (s.StartsWith(':')) + { + s = s[1..]; + var split = s.Split(' ', 2); + Debug.Assert(split.Length == 2, "no space found to end prefix"); + message.Prefix = split[0]; + s = split[1].TrimStart(' '); + } + var spl_command = s.Split(' ', 2); + message.MessageType = 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; + } + } + /// + /// Tries to get the value of the tag. + /// + /// + /// the value of the tag, or "" if not found + protected string TryGetTag(string s) + { + if (!MessageTags.TryGetValue(s, out string? value)) + return ""; + return value ?? ""; + } + } +} diff --git a/TwitchIrcClient/IRC/Messages/UserNotice.cs b/TwitchIrcClient/IRC/Messages/UserNotice.cs new file mode 100644 index 0000000..11d7a94 --- /dev/null +++ b/TwitchIrcClient/IRC/Messages/UserNotice.cs @@ -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 + { + /// + /// 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. + /// + public List Badges + { get + { + string value = TryGetTag("badges"); + if (value == "") + return []; + List badges = []; + foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + var spl = item.Split('/', 2); + badges.Add(new Badge(spl[0], spl[1])); + } + return badges; + } + } + /// + /// The color of the user’s name in the chat room. This is a hexadecimal + /// RGB color code in the form, #. This tag may be empty if it is never set. + /// + 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); + } + } + /// + /// The user’s display name. This tag may be empty if it is never set. + /// + public string DisplayName + { get + { + if (!MessageTags.TryGetValue("display-name", out string? value)) + return ""; + return value ?? ""; + } + } + /// + /// An ID that uniquely identifies the message. + /// + public string Id => TryGetTag("id"); + public UserNoticeType? UserNoticeType => Enum.TryParse(TryGetTag("msg-id"), out UserNoticeType type) + ? type : null; + /// + /// Whether the user is a moderator in this channel + /// + public bool Moderator + { get + { + if (!MessageTags.TryGetValue("mod", out string? value)) + return false; + return value == "1"; + } + } + /// + /// An ID that identifies the chat room (channel). + /// + public string RoomId => TryGetTag("room-id"); + /// + /// Whether the user is subscribed to the channel + /// + public bool Subscriber + { get + { + if (!MessageTags.TryGetValue("subscriber", out string? value)) + return false; + return value == "1"; + } + } + /// + /// A Boolean value that indicates whether the user has site-wide commercial + /// free mode enabled + /// + public bool Turbo + { get + { + if (!MessageTags.TryGetValue("turbo", out string? value)) + return false; + return value == "1"; + } + } + /// + /// The user’s ID + /// + 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, + } +} diff --git a/TwitchIrcClient/IRC/RateLimiter.cs b/TwitchIrcClient/IRC/RateLimiter.cs new file mode 100644 index 0000000..4b9ff0c --- /dev/null +++ b/TwitchIrcClient/IRC/RateLimiter.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TwitchLogger.IRC +{ + /// + /// Prevents sending too many messages in a time period. A single rate limiter can + /// be shared between multiple connections. A can be + /// passed with each request to track whether the requesting i + /// + 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 + } +} diff --git a/TwitchIrcClient/IRC/UserType.cs b/TwitchIrcClient/IRC/UserType.cs new file mode 100644 index 0000000..28cacfa --- /dev/null +++ b/TwitchIrcClient/IRC/UserType.cs @@ -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, + } +} diff --git a/TwitchIrcClient/Program.cs b/TwitchIrcClient/Program.cs new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/TwitchIrcClient/Program.cs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/TwitchLogger/TwitchLogger.csproj b/TwitchIrcClient/TwitchIrcClient.csproj similarity index 100% rename from TwitchLogger/TwitchLogger.csproj rename to TwitchIrcClient/TwitchIrcClient.csproj diff --git a/TwitchLogger/IRC/IrcConnection.cs b/TwitchLogger/IRC/IrcConnection.cs deleted file mode 100644 index d608edf..0000000 --- a/TwitchLogger/IRC/IrcConnection.cs +++ /dev/null @@ -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 -{ - /// - /// Connects to a single Twitch chat channel via limited IRC implementation. - /// - /// - /// - /// - public class IrcConnection(string url, int port) : IDisposable - { - public static readonly string ENDL = "\r\n"; - public int Port { get; } = 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 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(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 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 - } - } -} diff --git a/TwitchLogger/IRC/ReceivedMessage.cs b/TwitchLogger/IRC/ReceivedMessage.cs deleted file mode 100644 index 5a2d707..0000000 --- a/TwitchLogger/IRC/ReceivedMessage.cs +++ /dev/null @@ -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 -{ - /// - /// - /// - /// - /// This is designed according to - /// but only implementing features used by Twitch chat. See for - /// specifics about Twitch chat's use of IRC. Currently messages are not fully validated. - /// - public class ReceivedMessage - { - public IrcMessageType MessageType { get; private set; } - public string? Prefix { get; private set; } - public string? Source { get; private set; } - public List Parameters { get; } = []; - public string RawParameters { get; private set; } - public string RawText { get; private set; } - public MessageTags MessageTags { get; private set; } = []; - - /// - /// Parses an IRC message into the proper message type - /// - /// - /// the parsed message - public static ReceivedMessage Parse(string s) - { - ReceivedMessage message = new(); - message.RawText = s; - //message has tags - if (s.StartsWith('@')) - { - s = s[1..]; - //first ' ' acts as the delimeter - var split = s.Split(' ', 2); - Debug.Assert(split.Length == 2, "no space found to end tag section"); - string tagString = split[0]; - s = split[1].TrimStart(' '); - message.MessageTags = MessageTags.Parse(tagString); - } - //message has source - if (s.StartsWith(':')) - { - s = s[1..]; - var split = s.Split(' ', 2); - Debug.Assert(split.Length == 2, "no space found to end prefix"); - message.Prefix = split[0]; - s = split[1].TrimStart(' '); - } - var spl_command = s.Split(' ', 2); - message.MessageType = IrcReceiveMessageTypeHelper.Parse(spl_command[0]); - //message has parameters - if (spl_command.Length >= 2) - { - s = spl_command[1]; - message.RawParameters = s; - var spl_final = s.Split(':', 2); - var spl_initial = spl_final[0].Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - message.Parameters.AddRange(spl_initial); - if (spl_final.Length >= 2) - message.Parameters.Add(spl_final[1]); - } - return message; - } - } -} diff --git a/TwitchLogger/Program.cs b/TwitchLogger/Program.cs deleted file mode 100644 index 3751555..0000000 --- a/TwitchLogger/Program.cs +++ /dev/null @@ -1,2 +0,0 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!");