Improved parsing of more IRC message types, and added optional tracking of which users are in the stream.

This commit is contained in:
Ikatono
2024-03-19 21:27:43 -05:00
parent 6a5d960b3d
commit 57f8337258
22 changed files with 1435 additions and 341 deletions

View File

@@ -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

View 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)
{
}
}

View File

@@ -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;
}
}

View 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
}
}

View File

@@ -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)

View File

@@ -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 = "";

View 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}");
}
}
}

View 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}");
}
}
}

View 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}");
}
}
}

View 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}");
}
}
}

View 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,
//}
}

View 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}");
}
}
}

View 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 fields
/// 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 users 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 users 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 users 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}");
}
}
}

View 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 ?? "";
}
}
}

View 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 fields
/// 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 users 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 users 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 users 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,
}
}

View 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
}
}

View 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,
}
}

View File

@@ -0,0 +1 @@


View File

@@ -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
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -1,2 +0,0 @@
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");