Compare commits

..

9 Commits

21 changed files with 1834 additions and 288 deletions

1
README.md Normal file
View File

@@ -0,0 +1 @@
Provides a light-weight client for Twitch chatrooms over IRC. Primarily focused on receiving messages rather than sending them, TwitchIrcClient automatically requests all messages and tags from Twitch and parses these into easy to use classes. Additionally, it provides an event-like interface to receive messages of a specific type, and quality of life features like user tracking and an event for batches of user updates. Future plans include better handling of outgoing messages, providing interfaces to more tags for features like Hype Chats, and a better way to read chat messages with emotes substituted.

View File

@@ -139,19 +139,68 @@ namespace TwitchIrcClient.IRC
var bytes = Encoding.UTF8.GetBytes(line + ENDL);
_Stream.Write(bytes, 0, bytes.Length);
}
//TODO make this unit testable?
/// <summary>
/// Construct an IRC message from parts and sends it. Does little to no validation on inputs.
/// </summary>
/// <param name="command"></param>
/// <param name="parameters"></param>
/// <param name="tags"></param>
/// <param name="prefix"></param>
public void SendMessage(IrcMessageType command, IEnumerable<string>? parameters = null,
Dictionary<string, string?>? tags = null, string? prefix = null)
{
var message = "";
if (tags is not null && tags.Count != 0)
{
message = "@" + string.Join(';',
tags.OrderBy(p => p.Key).Select(p => $"{p.Key}={EscapeTagValue(p.Value)}"))
+ " ";
}
if (prefix is not null && !string.IsNullOrWhiteSpace(prefix))
message += ":" + prefix + " ";
message += command.ToCommand() + " ";
if (parameters is not null && parameters.Any())
{
//if ((command == IrcMessageType.NICK || command == IrcMessageType.PASS)
// && parameters.Count() == 1)
if (false)
{
message += " " + parameters.Single();
}
else
{
message += string.Join(' ', parameters.SkipLast(1));
message += " :" + parameters.Last();
}
}
SendLine(message);
}
private static string EscapeTagValue(string? s)
{
if (s is null)
return "";
return string.Join("", s.Select(c => c switch
{
';' => @"\:",
' ' => @"\s",
'\\' => @"\\",
'\r' => @"\r",
'\n' => @"\n",
char ch => ch.ToString(),
}));
}
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}");
user ??= $"justinfan{Random.Shared.NextInt64(10000):D4}";
pass ??= "pass";
SendMessage(IrcMessageType.PASS, parameters: [pass]);
SendMessage(IrcMessageType.NICK, parameters: [user]);
}
public void JoinChannel(string channel)
{
channel = channel.TrimStart('#');
SendLine($"JOIN #{channel}");
SendMessage(IrcMessageType.JOIN, ["#" + channel]);
}
private async void ListenForInput()
{
@@ -268,21 +317,25 @@ namespace TwitchIrcClient.IRC
public void AddCallback(MessageCallbackItem callbackItem)
{
ObjectDisposedException.ThrowIf(disposedValue, this);
lock (UserCallbacks)
UserCallbacks.Add(callbackItem);
}
public bool RemoveCallback(MessageCallbackItem callbackItem)
{
ObjectDisposedException.ThrowIf(disposedValue, this);
lock (UserCallbacks)
return UserCallbacks.Remove(callbackItem);
}
protected void AddSystemCallback(MessageCallbackItem callbackItem)
{
ObjectDisposedException.ThrowIf(disposedValue, this);
lock (SystemCallbacks)
SystemCallbacks.Add(callbackItem);
}
protected bool RemoveSystemCallback(MessageCallbackItem callbackItem)
{
ObjectDisposedException.ThrowIf(disposedValue, this);
lock (SystemCallbacks)
return SystemCallbacks.Remove(callbackItem);
}
private void RunCallbacks(ReceivedMessage message)
@@ -290,7 +343,9 @@ namespace TwitchIrcClient.IRC
ArgumentNullException.ThrowIfNull(message, nameof(message));
if (disposedValue)
return;
lock (SystemCallbacks)
SystemCallbacks.ForEach(c => c.TryCall(this, message));
lock (UserCallbacks)
UserCallbacks.ForEach(c => c.TryCall(this, message));
}
@@ -306,6 +361,7 @@ namespace TwitchIrcClient.IRC
TokenSource.Dispose();
Client?.Dispose();
_HeartbeatTimer?.Dispose();
_Stream?.Dispose();
}
disposedValue = true;
}

View File

@@ -6,6 +6,9 @@ using System.Threading.Tasks;
namespace TwitchIrcClient.IRC
{
/// <summary>
/// Represents the "command" of an IRC message.
/// </summary>
public enum IrcMessageType
{
//twitch standard messages
@@ -174,12 +177,26 @@ namespace TwitchIrcClient.IRC
}
public static class IrcMessageTypeHelper
{
//parses a string that is either a numeric code or the command name
/// <summary>
/// Parses a string that is either a numeric code or the command name.
/// </summary>
/// <param name="s"></param>
/// <returns></returns>
/// <remarks>
/// The value range 000-999 is reserved for numeric commands, and will
/// be converted to a numeric string when forming a message.
/// </remarks>
public static IrcMessageType Parse(string s)
{
if (int.TryParse(s, out int result))
return (IrcMessageType)result;
return Enum.Parse<IrcMessageType>(s);
}
public static string ToCommand(this IrcMessageType type)
{
if ((int)type >= 0 && (int)type < 1000)
return $"{(int)type,3}";
return type.ToString();
}
}
}

View File

@@ -30,13 +30,13 @@ namespace TwitchIrcClient.IRC.Messages
/// The ID of the user that was banned or put in a timeout.
/// </summary>
public string TargetUserId => TryGetTag("target-user-id");
public DateTime? TmiSentTime
public DateTime? Timestamp
{ get
{
string s = TryGetTag("tmi-sent-ts");
if (!double.TryParse(s, out double d))
return null;
return DateTime.UnixEpoch.AddSeconds(d);
return DateTime.UnixEpoch.AddMilliseconds(d);
}
}
/// <summary>
@@ -52,12 +52,12 @@ namespace TwitchIrcClient.IRC.Messages
/// <summary>
/// The name of the channel that either was cleared or banned the user
/// </summary>
public string Channel => Parameters.First();
public string Channel => Parameters.First().TrimStart('#');
/// <summary>
/// The username of the banned user, or "" if message is a
/// channel clear.
/// </summary>
public string User => Parameters.ElementAtOrDefault(2) ?? "";
public string User => Parameters.ElementAtOrDefault(1) ?? "";
public ClearChat(ReceivedMessage message) : base(message)
{
Debug.Assert(MessageType == IrcMessageType.CLEARCHAT,

View File

@@ -28,7 +28,7 @@ namespace TwitchIrcClient.IRC.Messages
/// <summary>
///
/// </summary>
public DateTime? TmiSentTime
public DateTime? Timestamp
{ get
{
string s = TryGetTag("tmi-sent-ts");
@@ -37,6 +37,8 @@ namespace TwitchIrcClient.IRC.Messages
return DateTime.UnixEpoch.AddSeconds(d / 1000);
}
}
public string Channel => Parameters.FirstOrDefault("").TrimStart('#');
public string Message => Parameters.LastOrDefault("");
public ClearMsg(ReceivedMessage message) : base(message)
{
Debug.Assert(MessageType == IrcMessageType.CLEARMSG,

View File

@@ -6,7 +6,7 @@ using System.Threading.Tasks;
namespace TwitchIrcClient.IRC.Messages
{
public record struct Emote(string Name, int Length)
public record struct Emote(string Name, int Position, int Length)
{
}

View File

@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace TwitchIrcClient.IRC.Messages
{
public class GlobalUserState : ReceivedMessage
{
/// <summary>
/// Contains metadata related to the chat badges in the badges tag.
/// Currently, this tag contains metadata only for subscriber badges,
/// to indicate the number of months the user has been a subscriber.
/// </summary>
public IEnumerable<string> BadgeInfo => TryGetTag("badge-info").Split(',');
/// <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 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>
/// A comma-delimited list of IDs that identify the emote sets that the user has
/// access to. Is always set to at least zero (0). To access the emotes in the set,
/// use the Get Emote Sets API.
/// </summary>
/// <see href="https://dev.twitch.tv/docs/api/reference#get-emote-sets"/>
public IEnumerable<int> EmoteSets
{ get
{
var value = TryGetTag("emote-sets");
foreach (var s in value.Split(','))
{
if (int.TryParse(s, out int num))
yield return num;
else
throw new InvalidDataException();
}
}
}
/// <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";
}
}
public string UserId => TryGetTag("user-id");
/// <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;
}
}
}
public GlobalUserState(ReceivedMessage other) : base(other)
{
Debug.Assert(MessageType == IrcMessageType.GLOBALUSERSTATE,
$"{nameof(GlobalUserState)} must have type {IrcMessageType.GLOBALUSERSTATE}" +
$" but has {MessageType}");
}
}
}

View File

@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace TwitchIrcClient.IRC.Messages
{
public class HostTarget : ReceivedMessage
{
/// <summary>
/// The channel thats hosting the viewers.
/// </summary>
public string HostingChannel => Parameters.FirstOrDefault("").TrimStart('#');
public string ChannelBeingHosted =>
Parameters.Last().Split(' ').First().TrimStart('-');
/// <summary>
/// true if the channel is now hosting another channel, false if it stopped hosting
/// </summary>
public bool NowHosting => !Parameters.Last().StartsWith('-');
public int NumberOfViewers
{ get
{
var s = Parameters.LastOrDefault("");
var s2 = s.Split(' ', StringSplitOptions.TrimEntries).LastOrDefault("");
if (int.TryParse(s2, out int value))
return value;
return 0;
}
}
public HostTarget(ReceivedMessage other) : base(other)
{
Debug.Assert(MessageType == IrcMessageType.HOSTTARGET,
$"{nameof(HostTarget)} must have type {IrcMessageType.HOSTTARGET}" +
$" but has {MessageType}");
}
}
}

View File

@@ -15,16 +15,6 @@ namespace TwitchIrcClient.IRC.Messages
/// </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)
@@ -170,166 +160,4 @@ namespace TwitchIrcClient.IRC.Messages
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

@@ -18,8 +18,8 @@ namespace TwitchIrcClient.IRC.Messages
/// 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.
/// 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
@@ -79,6 +79,26 @@ namespace TwitchIrcClient.IRC.Messages
return value ?? "";
}
}
public IEnumerable<Emote> Emotes
{ get
{
var tag = TryGetTag("emotes");
foreach (var emote in tag.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
var split = emote.Split(':', 2);
Debug.Assert(split.Length == 2);
var name = split[0];
foreach (var indeces in split[1].Split(','))
{
var split2 = indeces.Split('-');
if (!int.TryParse(split2[0], out int start) ||
!int.TryParse(split2[1], out int end))
throw new InvalidDataException();
yield return new Emote(name, start, end - start + 1);
}
}
}
}
/// <summary>
/// An ID that uniquely identifies the message.
/// </summary>
@@ -166,6 +186,54 @@ namespace TwitchIrcClient.IRC.Messages
/// A Boolean value that determines whether the user that sent the chat is a VIP.
/// </summary>
public bool Vip => MessageTags.ContainsKey("vip");
/// <summary>
/// The level of the Hype Chat. Proper values are 1-10, or 0 if not a Hype Chat.
/// </summary>
public int HypeChatLevel
{ get
{
var value = TryGetTag("pinned-chat-paid-level");
switch (value.ToUpper())
{
case "ONE":
return 1;
case "TWO":
return 2;
case "THREE":
return 3;
case "FOUR":
return 4;
case "FIVE":
return 5;
case "SIX":
return 6;
case "SEVEN":
return 7;
case "EIGHT":
return 8;
case "NINE":
return 9;
case "TEN":
return 10;
default:
return 0;
}
}
}
/// <summary>
/// The ISO 4217 alphabetic currency code the user has sent the Hype Chat in.
/// </summary>
public string HypeChatCurrency => TryGetTag("pinned-chat-paid-currency");
public decimal? HypeChatValue
{ get
{
var numeric = TryGetTag("pinned-chat-paid-amount");
var exp = TryGetTag("pinned-chat-paid-exponent");
if (int.TryParse(numeric, out int d_numeric) && int.TryParse(exp, out int d_exp))
return d_numeric / ((decimal)Math.Pow(10, d_exp));
return null;
}
}
public bool FirstMessage => TryGetTag("first-msg") == "1";
public string ChatMessage => Parameters.Last();
public Privmsg(ReceivedMessage message) : base(message)

View File

@@ -82,38 +82,35 @@ namespace TwitchIrcClient.IRC.Messages
//is stripped
if (s.StartsWith(':'))
{
message.Parameters.Add(s.Substring(1));
message.Parameters.Add(s[1..]);
}
else
{
var spl_final = s.Split(" :", 2);
var spl_initial = spl_final[0].Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
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)
return message.MessageType switch
{
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);
case IrcMessageType.ROOMSTATE:
return new Roomstate(message);
default:
return message;
}
IrcMessageType.CLEARCHAT => new ClearChat(message),
IrcMessageType.CLEARMSG => new ClearMsg(message),
IrcMessageType.JOIN => new Join(message),
IrcMessageType.GLOBALUSERSTATE => new GlobalUserState(message),
IrcMessageType.HOSTTARGET => new HostTarget(message),
IrcMessageType.NOTICE => new Notice(message),
IrcMessageType.PART => new Part(message),
IrcMessageType.PRIVMSG => new Privmsg(message),
IrcMessageType.ROOMSTATE => new Roomstate(message),
IrcMessageType.RPL_NAMREPLY => new NamReply(message),
IrcMessageType.USERNOTICE => new UserNotice(message),
IrcMessageType.USERSTATE => new UserState(message),
IrcMessageType.WHISPER => new Whisper(message),
_ => message,
};
}
/// <summary>
/// Tries to get the value of the tag.

View File

@@ -1,12 +1,19 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Net.WebSockets;
using System.Numerics;
using System.Text;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Xml.Linq;
using static System.Net.Mime.MediaTypeNames;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace TwitchIrcClient.IRC.Messages
{
@@ -16,7 +23,7 @@ namespace TwitchIrcClient.IRC.Messages
/// 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
/// 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
@@ -52,6 +59,8 @@ namespace TwitchIrcClient.IRC.Messages
return System.Drawing.Color.FromArgb(r, g, b);
}
}
public string Channel => Parameters.FirstOrDefault("").TrimStart('#');
public string Message => Parameters.ElementAtOrDefault(1) ?? "";
/// <summary>
/// The users display name. This tag may be empty if it is never set.
/// </summary>
@@ -63,12 +72,20 @@ namespace TwitchIrcClient.IRC.Messages
return value ?? "";
}
}
public IEnumerable<Emote> Emotes
{ get
{
throw new NotImplementedException();
}
}
/// <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;
public string Login => TryGetTag("login");
/// <summary>
/// Whether the user is a moderator in this channel
/// </summary>
@@ -118,24 +135,160 @@ namespace TwitchIrcClient.IRC.Messages
return value ?? "";
}
}
/// <summary>
///
/// </summary>
public string SystemMessage => TryGetTag("system-msg");
/// <summary>
/// When the Twitch IRC server received the message
/// </summary>
public DateTime Timestamp
{ get
{
if (double.TryParse(TryGetTag("tmi-sent-ts"), out double value))
return DateTime.UnixEpoch.AddMilliseconds(value);
throw new InvalidDataException();
}
}
/// <summary>
/// Included only with <see cref="UserNoticeType.raid"/> notices.
/// The display name of the broadcaster raiding this channel.
/// </summary>
public string RaidingChannelDisplayName => TryGetTag("msg-param-displayName");
/// <summary>
/// Included only with <see cref="UserNoticeType.raid"/> notices.
/// The login name of the broadcaster raiding this channel.
/// </summary>
public string RaidingChannelLogin => TryGetTag("msg-param-login");
/// <summary>
/// Included only with <see cref="UserNoticeType.anongiftpaidupgrade"/> and <see cref="UserNoticeType.giftpaidupgrade"/> notices.
/// The subscriptions promo, if any, that is ongoing (for example, Subtember 2018).
/// </summary>
public string SubscriptionPromoName => TryGetTag("msg-param-promo-name");
/// <summary>
/// Included only with <see cref="UserNoticeType.anongiftpaidupgrade"/> and
/// <see cref="UserNoticeType.giftpaidupgrade"/> notices.
/// The number of gifts the gifter has given during the promo indicated by <see cref="SubscriptionPromoName"/>.
/// </summary>
public int SubscriptionPromoCount => int.TryParse(TryGetTag("msg-param-promo-gift-total"),
out int value) ? value : 0;
/// <summary>
/// Included only with <see cref="UserNoticeType.subgift"/> notices.
/// The display name of the subscription gift recipient.
/// </summary>
public string RecipientDisplayName => TryGetTag("msg-param-recipient-display-name");
/// <summary>
/// Included only with <see cref="UserNoticeType.subgift"/> notices.
/// The user ID of the subscription gift recipient.
/// </summary>
public string RecipientId => TryGetTag("msg-param-recipient-id");
/// <summary>
/// Included only with <see cref="UserNoticeType.subgift"/> notices.
/// The user name of the subscription gift recipient.
/// </summary>
public string RecipientUsername => TryGetTag("msg-param-recipient-user-name");
/// <summary>
/// Only Included in <see cref="UserNoticeType.sub"/>, <see cref="UserNoticeType.resub"/>,
/// and <see cref="UserNoticeType.subgift"/>.
/// Either "msg-param-cumulative-months" or "msg-param-months" depending
/// on the notice type.
/// </summary>
public int TotalMonths
{ get
{
var s1 = TryGetTag("msg-param-cumulative-months");
var s2 = TryGetTag("msg-param-months");
if (int.TryParse(s1, out int value1))
return value1;
if (int.TryParse(s2, out int value2))
return value2;
return 0;
}
}
/// <summary>
/// Included only with <see cref="UserNoticeType.sub"/> and <see cref="UserNoticeType.resub"/> notices.
/// A Boolean value that indicates whether the user wants their streaks shared.
/// Is "false" for other message types.
/// </summary>
public bool ShouldShareStreak => TryGetTag("msg-param-should-share-streak")
== "1" ? true : false;
/// <summary>
/// Included only with <see cref="UserNoticeType.sub"/> and <see cref="UserNoticeType.resub"/> notices.
/// The number of consecutive months the user has subscribed.
/// This is zero(0) if <see cref="ShouldShareStreak"/> is 0.
/// </summary>
public int StreakMonths => int.TryParse(TryGetTag("msg-param-streak-months"),
out int value) ? value : 0;
/// <summary>
/// Included only with <see cref="UserNoticeType.sub"/>, <see cref="UserNoticeType.resub"/>
/// and <see cref="UserNoticeType.subgift"/> notices.
/// </summary>
public SubType SubPlan
{ get
{
switch (TryGetTag("msg-param-sub-plan").ToUpper())
{
case "PRIME":
return SubType.Prime;
case "1000":
return SubType.T1;
case "2000":
return SubType.T2;
case "3000":
return SubType.T3;
default:
return SubType.None;
}
}
}
/// <summary>
/// Included only with <see cref="UserNoticeType.sub"/>, <see cref="UserNoticeType.resub"/>,
/// and <see cref="UserNoticeType.subgift"/> notices.
/// The display name of the subscription plan. This may be a default name or one created
/// by the channel owner.
/// </summary>
public string SubPlanName => TryGetTag("msg-param-sub-plan-name");
/// <summary>
/// Included only with <see cref="UserNoticeType.raid"/> notices.
/// The number of viewers raiding this channel from the broadcasters channel.
/// </summary>
public int ViewerCount => int.TryParse(TryGetTag("msg-param-viewerCount"),
out int value) ? value : 0;
/// <summary>
/// The type of user sending the whisper message.
/// </summary>
public UserType UserType
{ get
{
if (!MessageTags.TryGetValue("user-type", out string? value))
return UserType.Normal;
switch (value)
var value = TryGetTag("user-type");
return value.ToUpper() switch
{
case "admin":
return UserType.Admin;
case "global_mod":
return UserType.GlobalMod;
case "staff":
return UserType.Staff;
default:
return UserType.Normal;
}
"ADMIN" => UserType.Admin,
"GLOBAL_MOD" => UserType.GlobalMod,
"STAFF" => UserType.Staff,
"" => UserType.Normal,
_ => UserType.Normal,
};
}
}
/// <summary>
/// Included only with <see cref="UserNoticeType.ritual"/> notices.
/// The name of the ritual being celebrated.
/// </summary>
public RitualType RitualType => Enum.TryParse(TryGetTag("msg-param-ritual-name"),
out RitualType rt) ? rt : RitualType.None;
//TODO possibly deprecate and add an int version in the future if all tiers are numeric
/// <summary>
/// Included only with <see cref="UserNoticeType.bitsbadgetier"/> notices.
/// The tier of the Bits badge the user just earned. For example, 100, 1000, or 10000.
/// </summary>
public string Threshold => TryGetTag("msg-param-threshold");
/// <summary>
/// Included only with <see cref="UserNoticeType.subgift"/> notices.
/// The number of months gifted as part of a single, multi-month gift.
/// </summary>
public int GiftMonths => int.TryParse(TryGetTag("msg-param-gift-months"),
out int value) ? value : 0;
public UserNotice(ReceivedMessage message) : base(message)
{
Debug.Assert(MessageType == IrcMessageType.USERNOTICE,
@@ -145,16 +298,31 @@ namespace TwitchIrcClient.IRC.Messages
}
public enum UserNoticeType
{
sub,
resub,
subgift,
submysterygift,
giftpaidupgrade,
rewardgift,
anongiftpaidupgrade,
raid,
unraid,
ritual,
bitsbadgetier,
sub = 0,
resub = 1,
subgift = 2,
submysterygift = 3,
giftpaidupgrade = 4,
rewardgift = 5,
anongiftpaidupgrade = 6,
raid = 7,
unraid = 8,
ritual = 9,
bitsbadgetier = 10,
}
public enum RitualType
{
new_chatter = 0,
None = int.MinValue,
}
public enum SubType
{
Prime = 0,
T1 = 1,
T2 = 2,
T3 = 3,
None = int.MinValue,
}
}

View File

@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace TwitchIrcClient.IRC.Messages
{
public class UserState : ReceivedMessage
{
public string Channel => Parameters.FirstOrDefault("").TrimStart('#');
/// <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, escaped as described in the IRCv3 spec.
/// </summary>
public string DisplayName => TryGetTag("display-name");
/// <summary>
/// A comma-delimited list of IDs that identify the emote sets that the user has
/// access to. Is always set to at least zero (0). To access the emotes in the set,
/// use the Get Emote Sets API.
/// </summary>
/// <see href="https://dev.twitch.tv/docs/api/reference#get-emote-sets"/>
public IEnumerable<int> EmoteSets
{ get
{
var value = TryGetTag("emote-sets");
foreach (var s in value.Split(','))
{
if (int.TryParse(s, out int num))
yield return num;
else
throw new InvalidDataException();
}
}
}
/// <summary>
/// If a privmsg was sent, an ID that uniquely identifies the message.
/// </summary>
public string Id => TryGetTag("id");
/// <summary>
/// A Boolean value that determines whether the user is a moderator.
/// </summary>
public bool Moderator
{ get
{
if (!MessageTags.TryGetValue("mod", out string? value))
return false;
return value == "1";
}
}
/// <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";
}
}
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 UserState(ReceivedMessage other) : base(other)
{
Debug.Assert(MessageType == IrcMessageType.USERSTATE,
$"{nameof(UserState)} must have type {IrcMessageType.USERSTATE}" +
$" but has {MessageType}");
}
}
}

View File

@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace TwitchIrcClient.IRC.Messages
{
public class Whisper : 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 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 ?? "";
}
}
public IEnumerable<Emote> Emotes
{ get
{
var tag = TryGetTag("emotes");
foreach (var emote in tag.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
var split = emote.Split(':', 2);
Debug.Assert(split.Length == 2);
var name = split[0];
foreach (var indeces in split[1].Split(','))
{
var split2 = indeces.Split('-');
if (!int.TryParse(split2[0], out int start) ||
!int.TryParse(split2[1], out int end))
throw new InvalidDataException();
yield return new Emote(name, start, end - start + 1);
}
}
}
}
/// <summary>
/// An ID that uniquely identifies the whisper message.
/// </summary>
public string MessageId => TryGetTag("message-id");
/// <summary>
/// An ID that uniquely identifies the whisper thread.
/// The ID is in the form, <smaller-value-user-id>_<larger-value-user-id>.
/// </summary>
public string ThreadId => TryGetTag("thread-id");
/// <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 ID of the user sending the whisper message.
/// </summary>
public string UserId => TryGetTag("user-id");
public string Message => Parameters.LastOrDefault("");
/// <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;
}
}
}
public Whisper(ReceivedMessage other) : base(other)
{
Debug.Assert(MessageType == IrcMessageType.WHISPER,
$"{nameof(Whisper)} must have type {IrcMessageType.WHISPER}" +
$" but has {MessageType}");
}
}
}

View File

@@ -29,29 +29,36 @@ namespace TwitchIrcClient.IRC
Timer.Start();
}
public void WaitForAvailable(CancellationToken? token = null)
public bool WaitForAvailable(CancellationToken? token = null)
{
try
{
lock (Semaphore)
{
if (token is CancellationToken actualToken)
Semaphore.Wait(actualToken);
else
Semaphore.Wait();
return true;
}
}
catch (OperationCanceledException)
{
//caller is responsible for checking whether connection is cancelled before trying to send
return false;
}
}
public bool WaitForAvailable(TimeSpan timeout, CancellationToken? token = null)
{
try
{
lock (Semaphore)
{
if (token is CancellationToken actualToken)
return Semaphore.Wait(timeout, actualToken);
else
return Semaphore.Wait(timeout);
}
}
catch (OperationCanceledException)
{
return false;
@@ -60,12 +67,15 @@ namespace TwitchIrcClient.IRC
public bool WaitForAvailable(int millis, CancellationToken? token = null)
{
try
{
lock (Semaphore)
{
if (token is CancellationToken actualToken)
return Semaphore.Wait(millis, actualToken);
else
return Semaphore.Wait(millis);
}
}
catch (OperationCanceledException)
{
return false;
@@ -76,30 +86,29 @@ namespace TwitchIrcClient.IRC
{
try
{
Semaphore.Release(MessageLimit);
}
catch (SemaphoreFullException)
lock (Semaphore)
{
var count = MessageLimit - Semaphore.CurrentCount;
if (count > 0)
Semaphore.Release(count);
}
catch (ObjectDisposedException)
{
}
catch (SemaphoreFullException) { }
catch (ObjectDisposedException) { }
}
#region RateLimiter Dispose
private bool disposedValue;
//https://stackoverflow.com/questions/8927878/what-is-the-correct-way-of-adding-thread-safety-to-an-idisposable-object
private int _disposedCount;
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
if (Interlocked.Increment(ref _disposedCount) == 1)
{
if (disposing)
{
Semaphore?.Dispose();
Timer?.Dispose();
}
disposedValue = true;
}
}

View File

@@ -1 +1,60 @@

using System.Collections.Concurrent;
using System.Reflection.Metadata;
using System.Security.AccessControl;
using System.Threading.Channels;
using TwitchIrcClient.IRC;
using TwitchIrcClient.IRC.Messages;
RateLimiter limiter = new(20, 30);
bool ssl = true;
async Task<IrcConnection> CreateConnection(string channel)
{
IrcConnection connection = ssl
? connection = new IrcConnection("irc.chat.twitch.tv", 6697, limiter, true, true)
: connection = new IrcConnection("irc.chat.twitch.tv", 6667, limiter, true, false);
connection.AddCallback(new MessageCallbackItem(
(o, m) =>
{
if (m is Privmsg priv)
{
if (priv.Bits > 0)
lock (Console.Out)
Console.WriteLine($"{priv.DisplayName}: {priv.Bits}{Environment.NewLine}");
}
else
throw new ArgumentException("Received an unrequested message type", nameof(m));
}, [IrcMessageType.PRIVMSG]));
connection.onUserChange += (object? o, UserChangeEventArgs args) =>
{
lock (Console.Out)
{
var resetColor = Console.BackgroundColor;
Console.BackgroundColor = ConsoleColor.DarkGreen;
Console.WriteLine(string.Join(", ", args.Joined.Order()));
Console.BackgroundColor = ConsoleColor.DarkRed;
Console.WriteLine(string.Join(", ", args.Left.Order()));
Console.BackgroundColor = resetColor;
Console.WriteLine();
}
};
if (!await connection.ConnectAsync())
{
Console.WriteLine("failed to connect");
Environment.Exit(-1);
}
connection.Authenticate(null, null);
connection.SendLine("CAP REQ :twitch.tv/commands twitch.tv/membership twitch.tv/tags");
connection.JoinChannel(channel);
return connection;
}
Console.Write("Channel: ");
var channelName = Console.ReadLine();
ArgumentNullException.ThrowIfNullOrWhiteSpace(channelName, nameof(channelName));
var connection = await CreateConnection(channelName);
while (true)
{
//all the work happens in other threads
//specifically the threadpool used by Task.Run for
//the tasks owned by the IrcConnection
await Task.Delay(1000);
}

View File

@@ -0,0 +1,133 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using System.Xml.Linq;
namespace TwitchIrcClient.PubSub.Message
{
public class PubSubMessage : IDictionary<string, JsonNode?>
{
private readonly JsonObject Node;
public string TypeString
{
get => (Node["type"] ?? throw new InvalidDataException()).ToJsonString();
set
{
Node["type"] = value;
}
}
//PING and PONG messages don't seem to have any data member
public string? DataString =>
Node["data"]?.ToJsonString();
public string? Nonce
{
get => Node["nonce"]?.ToJsonString();
set
{
Node["nonce"] = value;
}
}
private PubSubMessage(JsonObject node)
{
Node = node;
}
public PubSubMessage() : this(new JsonObject())
{
}
public string Serialize()
{
return Node.ToJsonString();
}
public static PubSubMessage Parse(string s)
{
var obj = JsonNode.Parse(s)
?? throw new InvalidDataException();
var psm = new PubSubMessage(obj as JsonObject
?? throw new InvalidOperationException());
return psm;
}
public static PubSubMessage PING()
{
return new PubSubMessage
{
["type"] = "PING",
};
}
#region IDictionary<string, JsonNode?>
public ICollection<string> Keys => ((IDictionary<string, JsonNode?>)Node).Keys;
public ICollection<JsonNode?> Values => ((IDictionary<string, JsonNode?>)Node).Values;
public int Count => Node.Count;
public bool IsReadOnly => ((ICollection<KeyValuePair<string, JsonNode?>>)Node).IsReadOnly;
public JsonNode? this[string key] { get => ((IDictionary<string, JsonNode?>)Node)[key]; set => ((IDictionary<string, JsonNode?>)Node)[key] = value; }
public void Add(string key, JsonNode? value)
{
Node.Add(key, value);
}
public bool ContainsKey(string key)
{
return Node.ContainsKey(key);
}
public bool Remove(string key)
{
return Node.Remove(key);
}
public bool TryGetValue(string key, [MaybeNullWhen(false)] out JsonNode? value)
{
return ((IDictionary<string, JsonNode?>)Node).TryGetValue(key, out value);
}
public void Add(KeyValuePair<string, JsonNode?> item)
{
Node.Add(item);
}
public void Clear()
{
Node.Clear();
}
public bool Contains(KeyValuePair<string, JsonNode?> item)
{
return ((ICollection<KeyValuePair<string, JsonNode?>>)Node).Contains(item);
}
public void CopyTo(KeyValuePair<string, JsonNode?>[] array, int arrayIndex)
{
((ICollection<KeyValuePair<string, JsonNode?>>)Node).CopyTo(array, arrayIndex);
}
public bool Remove(KeyValuePair<string, JsonNode?> item)
{
return ((ICollection<KeyValuePair<string, JsonNode?>>)Node).Remove(item);
}
public IEnumerator<KeyValuePair<string, JsonNode?>> GetEnumerator()
{
return Node.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return ((IEnumerable)Node).GetEnumerator();
}
#endregion //IDictionary<string, JsonNode?>
}
}

View File

@@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using TwitchIrcClient.PubSub.Message;
namespace TwitchIrcClient.PubSub
{
public delegate void PubSubCallback(PubSubMessage message, PubSubConnection connection);
public record struct PubSubCallbackItem(PubSubCallback Callback, IList<string>? Types)
{
public readonly bool MaybeRunCallback(PubSubMessage message, PubSubConnection connection)
{
if (Types is null || Types.Contains(message.TypeString))
{
Callback?.Invoke(message, connection);
return true;
}
return false;
}
}
}

View File

@@ -0,0 +1,355 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http.Headers;
using System.Net.Security;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Reflection.Metadata.Ecma335;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using System.Web;
using TwitchIrcClient.PubSub.Message;
namespace TwitchIrcClient.PubSub
{
public sealed class PubSubConnection : IDisposable
{
//private TcpClient Client = new();
//private SslStream SslStream;
//private WebSocket Socket;
private ClientWebSocket Socket = new();
private CancellationTokenSource TokenSource = new();
private string? ClientId;
private string? AuthToken;
private DateTime? AuthExpiration;
public string RefreshToken { get; private set; }
public PubSubConnection()
{
}
//this needs to be locked for thread-safety
public async Task SendMessageAsync(string message)
{
await Socket.SendAsync(Encoding.UTF8.GetBytes(message), WebSocketMessageType.Text,
WebSocketMessageFlags.EndOfMessage | WebSocketMessageFlags.DisableCompression,
TokenSource.Token);
}
public async Task SendMessageAsync(PubSubMessage message)
{
await SendMessageAsync(message.Serialize());
}
public async Task<bool> ConnectAsync()
{
const string url = "wss://pubsub-edge.twitch.tv";
await Socket.ConnectAsync(new Uri(url), TokenSource.Token);
if (Socket.State != WebSocketState.Open)
return false;
_ = Task.Run(HandlePings, TokenSource.Token);
_ = Task.Run(HandleIncomingMessages, TokenSource.Token);
return true;
}
public async Task<bool> GetImplicitTokenAsync(string clientId, string clientSecret,
IEnumerable<string> scopes)
{
const int PORT = 17563;
using var listener = new TcpListener(System.Net.IPAddress.Any, PORT);
listener.Start();
//using var client = new HttpClient();
var scopeString = string.Join(' ', scopes);
var stateNonce = MakeNonce();
var url = $"https://id.twitch.tv/oauth2/authorize" +
$"?response_type=code" +
$"&client_id={HttpUtility.UrlEncode(clientId)}" +
$"&redirect_uri=http://localhost:{PORT}" +
$"&scope={HttpUtility.UrlEncode(scopeString)}" +
$"&state={stateNonce}";
var startInfo = new ProcessStartInfo()
{
//FileName = "explorer",
//Arguments = url,
FileName = url,
UseShellExecute = true,
};
Process.Start(startInfo);
//Console.WriteLine(url);
using var socket = await listener.AcceptSocketAsync(TokenSource.Token);
var arr = new byte[2048];
var buffer = new ArraySegment<byte>(arr);
var count = await socket.ReceiveAsync(buffer, TokenSource.Token);
var http204 = "HTTP/1.1 204 No Content\r\n\r\n";
var sentCount = await socket.SendAsync(Encoding.UTF8.GetBytes(http204));
var resp = Encoding.UTF8.GetString(arr, 0, count);
var dict =
//get the first line of HTTP response
HttpUtility.UrlDecode(resp.Split("\r\n").First()
//extract location component (trim leading /?)
.Split(' ').ElementAt(1)[2..])
//make a dictionary
.Split('&').Select(s =>
{
var p = s.Split('=');
return new KeyValuePair<string, string>(p[0], p[1]);
}).ToDictionary();
if (dict["state"] != stateNonce)
return false;
var payload = DictToBody(new Dictionary<string,string>
{
["client_id"] = clientId,
["client_secret"] = clientSecret,
["code"] = dict["code"],
["grant_type"] = "authorization_code",
["redirect_uri"] = $"http://localhost:{PORT}",
});
var client = new HttpClient();
var startTime = DateTime.Now;
var httpResp = await client.PostAsync("https://id.twitch.tv/oauth2/token",
new StringContent(payload, new MediaTypeHeaderValue("application/x-www-form-urlencoded")));
if (httpResp is null)
return false;
if (httpResp.Content is null)
return false;
if (!httpResp.IsSuccessStatusCode)
return false;
var respStr = await httpResp.Content.ReadAsStringAsync();
var json = JsonNode.Parse(respStr);
string authToken;
double expiresIn;
string refreshToken;
if ((json?["access_token"]?.AsValue().TryGetValue(out authToken) ?? false)
&& (json?["expires_in"]?.AsValue().TryGetValue(out expiresIn) ?? false)
&& (json?["refresh_token"]?.AsValue().TryGetValue(out refreshToken) ?? false))
{
AuthToken = authToken;
RefreshToken = refreshToken;
AuthExpiration = startTime.AddSeconds(expiresIn);
ClientId = clientId;
return true;
}
return false;
}
private static string DictToBody(IEnumerable<KeyValuePair<string,string>> dict)
{
return string.Join('&', dict.Select(p =>
HttpUtility.UrlEncode(p.Key) + '=' + HttpUtility.UrlEncode(p.Value)));
}
public async Task<bool> GetDcfTokenAsync(string clientId, IEnumerable<string> scopes)
{
using var client = new HttpClient();
var scopeString = string.Join(',', scopes);
var startTime = DateTime.Now;
var resp = await client.PostAsync("https://id.twitch.tv/oauth2/device",
new StringContent($"client_id={clientId}&scopes={scopeString}",
MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded")));
if (resp is null)
return false;
if (!resp.IsSuccessStatusCode)
return false;
if (resp.Content is null)
return false;
var contentString = await resp.Content.ReadAsStringAsync();
var json = JsonNode.Parse(contentString);
if (json is null)
return false;
throw new NotImplementedException();
}
public async Task<bool> GetTokenAsync(string clientId, string clientSecret)
{
using var client = new HttpClient();
var startTime = DateTime.Now;
var resp = await client.PostAsync($"https://id.twitch.tv/oauth2/token" +
$"?client_id={clientId}&client_secret={clientSecret}" +
$"&grant_type=client_credentials", null);
if (resp is null)
return false;
if (!resp.IsSuccessStatusCode)
return false;
if (resp.Content is null)
return false;
var json = JsonNode.Parse(await resp.Content.ReadAsStringAsync());
if (json is null)
return false;
var authToken = json["access_token"]?.GetValue<string>();
var expiresIn = json["expires_in"]?.GetValue<double>();
if (authToken is string token && expiresIn is double expires)
{
ClientId = clientId;
AuthToken = token;
AuthExpiration = startTime.AddSeconds(expires);
}
else
return false;
return true;
}
public async Task SubscribeAsync(IEnumerable<string> topics)
{
var psm = new PubSubMessage
{
["type"] = "LISTEN",
["data"] = new JsonObject
{
//TODO there's probably a cleaner way to do this
["topics"] = new JsonArray(topics.Select(t => (JsonValue)t).ToArray()),
["auth_token"] = AuthToken,
},
["nonce"] = MakeNonce(),
};
await SendMessageAsync(psm);
}
//TODO change or dupe this to get multiple at once
public async Task<string?> GetChannelIdFromNameAsync(string channelName)
{
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {AuthToken}");
client.DefaultRequestHeaders.Add("Client-Id", ClientId);
var resp = await client.GetAsync($"https://api.twitch.tv/helix/users?login={channelName}");
if (resp is null)
return null;
if (!resp.IsSuccessStatusCode)
return null;
if (resp.Content is null)
return null;
var json = JsonNode.Parse(await resp.Content.ReadAsStringAsync());
if (json is null)
return null;
var arr = json["data"];
if (arr is null)
return null;
JsonArray jarr;
try
{
jarr = arr.AsArray();
}
catch (InvalidOperationException)
{
return null;
}
var item = jarr.SingleOrDefault();
if (item is null)
return null;
return item["id"]?.ToString();
}
private static string MakeNonce(int length = 16)
{
var buffer = new byte[length * 2];
Random.Shared.NextBytes(buffer);
return Convert.ToHexString(buffer);
}
private AutoResetEvent PingResetEvent = new(false);
private async Task HandlePings()
{
//send ping every <5 minutes
//wait until pong or >10 seconds
//raise error if necessary
AddSystemCallback(new PubSubCallbackItem(
(m, s) =>
{
s.PingResetEvent.Set();
}, ["PONG"]));
while (true)
{
await Task.Delay(TimeSpan.FromMinutes(4 * Jitter(0.05)));
await SendMessageAsync(PubSubMessage.PING());
await Task.Delay(TimeSpan.FromSeconds(10));
if (!PingResetEvent.WaitOne(0))
{
//timeout
}
}
}
private async void HandleIncomingMessages()
{
string s = "";
while (true)
{
var buffer = new ArraySegment<byte>(new byte[4096]);
var result = await Socket.ReceiveAsync(buffer, TokenSource.Token);
s += Encoding.UTF8.GetString(buffer.Take(result.Count).ToArray());
if (result.EndOfMessage)
{
IncomingMessage(PubSubMessage.Parse(s));
s = "";
}
}
}
private void IncomingMessage(PubSubMessage message)
{
RunCallbacks(message);
}
private void RunCallbacks(PubSubMessage message)
{
ArgumentNullException.ThrowIfNull(message);
if (disposedValue)
return;
lock (SystemCallbacks)
SystemCallbacks.ForEach(c => c.MaybeRunCallback(message, this));
lock (UserCallbacks)
UserCallbacks.ForEach(c => c.MaybeRunCallback(message, this));
}
private readonly List<PubSubCallbackItem> UserCallbacks = [];
public void AddCallback(PubSubCallbackItem callback)
{
ObjectDisposedException.ThrowIf(disposedValue, this);
lock (UserCallbacks)
UserCallbacks.Add(callback);
}
public bool RemoveCallback(PubSubCallbackItem callback)
{
ObjectDisposedException.ThrowIf(disposedValue, this);
lock (UserCallbacks)
return UserCallbacks.Remove(callback);
}
private readonly List<PubSubCallbackItem> SystemCallbacks = [];
private void AddSystemCallback(PubSubCallbackItem callback)
{
ObjectDisposedException.ThrowIf(disposedValue, this);
lock (SystemCallbacks)
SystemCallbacks.Add(callback);
}
private bool RemoveSystemCallback(PubSubCallbackItem callback)
{
ObjectDisposedException.ThrowIf(disposedValue, this);
lock (SystemCallbacks)
return SystemCallbacks.Remove(callback);
}
/// <summary>
/// produces a number between -limit and limit
/// </summary>
/// <param name="limit"></param>
/// <returns></returns>
private static double Jitter(double limit)
{
return (Random.Shared.NextDouble() - 0.5) * 2 * limit;
}
#region Dispose
private bool disposedValue;
private void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
//Client?.Dispose();
//SslStream?.Dispose();
Socket?.Dispose();
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
#endregion //Dispose
}
}

View File

@@ -2,6 +2,7 @@ using System.Drawing;
using System;
using TwitchIrcClient.IRC;
using TwitchIrcClient.IRC.Messages;
using System.Diagnostics;
namespace TwitchIrcClientTests
{
@@ -9,24 +10,9 @@ namespace TwitchIrcClientTests
public class ParserTest
{
[TestMethod]
public void TestSimpleMessages()
public void TestRoomstate()
{
var ROOMSTATE = "@emote-only=0;followers-only=-1;r9k=0;room-id=321654987;slow=0;subs-only=0 :tmi.twitch.tv ROOMSTATE #channelname";
var NAMREPLY = ":justinfan7550.tmi.twitch.tv 353 justinfan7550 = #channelname :user1 user2 user3 user4 user5";
var JOIN = ":newuser!newuser@newuser.tmi.twitch.tv JOIN #channelname";
var PART = ":leavinguser!leavinguser@leavinguser.tmi.twitch.tv PART #channelname";
var PRIVMSG = "@badge-info=subscriber/1;badges=subscriber/0;client-nonce=202e32113a3768963eded865e051fc5b;color=#AAAAFF;" +
"display-name=ChattingUser;emotes=;first-msg=0;flags=;id=24fe75a1-06a5-4078-a31f-cf615107b2a2;mod=0;returning-chatter=0;" +
"room-id=321654987;subscriber=1;tmi-sent-ts=1710920497332;turbo=0;user-id=01234567;user-type= " +
":chattinguser!chattinguser@chattinguser.tmi.twitch.tv PRIVMSG #channelname :This is a test chat message";
var CHEER = "@badge-info=subscriber/9;badges=subscriber/9,twitch-recap-2023/1;bits=100;color=#FF0000;display-name=CheeringUser;" +
//I haven't fixed this emote tag after rewriting the message
"emotes=emotesv2_44a39d65e08f43adac871a80e9b96d85:17-24;first-msg=1;flags=;id=5eab1319-5d46-4c55-be29-33c2f834e42e;mod=0;" +
"returning-chatter=0;room-id=321654987;subscriber=0;tmi-sent-ts=1710920826069;turbo=1;user-id=012345678;user-type=;vip " +
":cheeringuser!cheeringuser@cheeringuser.tmi.twitch.tv PRIVMSG #channelname :This includes a cheer Cheer100";
//var CLEARMSG = "";
//var CLEARROOM = "";
var _roomstate = ReceivedMessage.Parse(ROOMSTATE);
Assert.AreEqual(IrcMessageType.ROOMSTATE, _roomstate.MessageType);
if (_roomstate is Roomstate roomstate)
@@ -55,7 +41,11 @@ namespace TwitchIrcClientTests
{
Assert.Fail();
}
}
[TestMethod]
public void TestNamreply()
{
var NAMREPLY = ":justinfan7550.tmi.twitch.tv 353 justinfan7550 = #channelname :user1 user2 user3 user4 user5";
var _namReply = ReceivedMessage.Parse(NAMREPLY);
Assert.AreEqual(IrcMessageType.RPL_NAMREPLY, _namReply.MessageType);
if (_namReply is NamReply namReply)
@@ -68,7 +58,11 @@ namespace TwitchIrcClientTests
{
Assert.Fail();
}
}
[TestMethod]
public void TestJoin()
{
var JOIN = ":newuser!newuser@newuser.tmi.twitch.tv JOIN #channelname";
var _join = ReceivedMessage.Parse(JOIN);
Assert.AreEqual(IrcMessageType.JOIN, _join.MessageType);
if (_join is Join join)
@@ -80,7 +74,11 @@ namespace TwitchIrcClientTests
{
Assert.Fail();
}
}
[TestMethod]
public void TestPart()
{
var PART = ":leavinguser!leavinguser@leavinguser.tmi.twitch.tv PART #channelname";
var _part = ReceivedMessage.Parse(PART);
Assert.AreEqual(IrcMessageType.PART, _part.MessageType);
if (_part is Part part)
@@ -92,6 +90,25 @@ namespace TwitchIrcClientTests
{
Assert.Fail();
}
}
[TestMethod]
public void TestPrivmsg()
{
var PRIVMSG = "@badge-info=subscriber/1;badges=subscriber/0;client-nonce=202e32113a3768963eded865e051fc5b;color=#AAAAFF;" +
"display-name=ChattingUser;emotes=;first-msg=0;flags=;id=24fe75a1-06a5-4078-a31f-cf615107b2a2;mod=0;returning-chatter=0;" +
"room-id=321654987;subscriber=1;tmi-sent-ts=1710920497332;turbo=0;user-id=01234567;user-type= " +
":chattinguser!chattinguser@chattinguser.tmi.twitch.tv PRIVMSG #channelname :This is a test chat message";
var CHEER = "@badge-info=subscriber/9;badges=subscriber/9,twitch-recap-2023/1;bits=100;color=#FF0000;display-name=CheeringUser;" +
//I haven't fixed this emote tag after rewriting the message
"emotes=emotesv2_44a39d65e08f43adac871a80e9b96d85:17-24;first-msg=1;flags=;id=5eab1319-5d46-4c55-be29-33c2f834e42e;mod=0;" +
"returning-chatter=0;room-id=321654987;subscriber=0;tmi-sent-ts=1710920826069;turbo=1;user-id=012345678;user-type=;vip " +
":cheeringuser!cheeringuser@cheeringuser.tmi.twitch.tv PRIVMSG #channelname :This includes a cheer Cheer100";
var ESCAPE = @"@escaped=\:\s\\\r\n\a\b\c PRIVMSG #channelname :message";
var EMOTES = @"@badge-info=subscriber/4;badges=subscriber/3;client-nonce=2cc8bb73f5d946b22ec2905c8ccdee7a;color=#1E90FF;" +
@"display-name=Ikatono;emote-only=1;emotes=emotesv2_4f3ee26e385b46aa88d5f45307489939:0-12,14-26/emotesv2_9046ad54f76f42389edb4cc828b1b057" +
@":28-35,37-44;first-msg=0;flags=;id=08424675-217f-44bc-b9c0-24e2e2dd5f33;mod=0;returning-chatter=0;room-id=230151386;" +
@"subscriber=1;tmi-sent-ts=1711136008625;turbo=0;user-id=24866530;user-type= :ikatono!ikatono@ikatono.tmi.twitch.tv " +
@"PRIVMSG #bajiru_en :bajiBUFFERING bajiBUFFERING bajiBONK bajiBONK";
var _priv = ReceivedMessage.Parse(PRIVMSG);
Assert.AreEqual(IrcMessageType.PRIVMSG, _priv.MessageType);
@@ -111,6 +128,7 @@ namespace TwitchIrcClientTests
Assert.AreEqual(UserType.Normal, priv.UserType);
Assert.IsFalse(priv.Vip);
Assert.AreEqual(new DateTime(2024, 3, 20, 7, 41, 37, 332, DateTimeKind.Utc), priv.Timestamp);
Assert.IsTrue(priv.Badges.SequenceEqual([new Badge("subscriber", "0")]));
}
else
{
@@ -134,8 +152,388 @@ namespace TwitchIrcClientTests
Assert.AreEqual("012345678", cheer.UserId);
Assert.AreEqual(UserType.Normal, cheer.UserType);
Assert.IsTrue(cheer.Vip);
//test that timestamp is within 1 second
Assert.AreEqual(new DateTime(2024, 3, 20, 7, 47, 6, 069, DateTimeKind.Utc), cheer.Timestamp);
Assert.IsTrue(cheer.Badges.SequenceEqual([
new Badge("subscriber", "9"),
new Badge("twitch-recap-2023", "1"),
]));
}
else
{
Assert.Fail();
}
var _escape = ReceivedMessage.Parse(ESCAPE);
Assert.AreEqual(IrcMessageType.PRIVMSG, _escape.MessageType);
if (_escape is Privmsg escape)
{
Assert.AreEqual("; \\\r\nabc", escape.MessageTags["escaped"]);
}
else
{
Assert.Fail();
}
var _emotes = ReceivedMessage.Parse(EMOTES);
Assert.AreEqual(IrcMessageType.PRIVMSG, _emotes.MessageType);
if (_emotes is Privmsg emotes)
{
Assert.IsTrue(emotes.Emotes.SequenceEqual([
new Emote("emotesv2_4f3ee26e385b46aa88d5f45307489939", 0, 12-0+1),
new Emote("emotesv2_4f3ee26e385b46aa88d5f45307489939", 14, 26-14+1),
new Emote("emotesv2_9046ad54f76f42389edb4cc828b1b057", 28, 35-28+1),
new Emote("emotesv2_9046ad54f76f42389edb4cc828b1b057", 37, 44-37+1),
]));
}
else
{
Assert.Fail();
}
}
[TestMethod]
public void TestUserNotice()
{
//these 4 are examples given from Twitch's USERNOTICE tags page
var RESUB = @"@badge-info=;badges=staff/1,broadcaster/1,turbo/1;color=#008000;display-name=ronni;emotes=;" +
@"id=db25007f-7a18-43eb-9379-80131e44d633;login=ronni;mod=0;msg-id=resub;msg-param-cumulative-months=6;msg-param-streak-months=2;" +
@"msg-param-should-share-streak=1;msg-param-sub-plan=Prime;msg-param-sub-plan-name=Prime;room-id=12345678;subscriber=1;" +
@"system-msg=ronni\shas\ssubscribed\sfor\s6\smonths!;tmi-sent-ts=1507246572675;turbo=1;user-id=87654321;user-type=staff" +
@" :tmi.twitch.tv USERNOTICE #dallas :Great stream -- keep it up!";
var GIFTED = @"@badge-info=;badges=staff/1,premium/1;color=#0000FF;display-name=TWW2;emotes=;" +
@"id=e9176cd8-5e22-4684-ad40-ce53c2561c5e;login=tww2;mod=0;msg-id=subgift;msg-param-months=1;" +
@"msg-param-recipient-display-name=Mr_Woodchuck;msg-param-recipient-id=55554444;msg-param-recipient-name=mr_woodchuck;" +
@"msg-param-sub-plan-name=House\sof\sNyoro~n;msg-param-sub-plan=1000;room-id=19571752;subscriber=0;" +
@"system-msg=TWW2\sgifted\sa\sTier\s1\ssub\sto\sMr_Woodchuck!;tmi-sent-ts=1521159445153;turbo=0;user-id=87654321;user-type=staff" +
@" :tmi.twitch.tv USERNOTICE #forstycup";
var RAID = @"@badge-info=;badges=turbo/1;color=#9ACD32;display-name=TestChannel;emotes=;id=3d830f12-795c-447d-af3c-ea05e40fbddb;" +
@"login=testchannel;mod=0;msg-id=raid;msg-param-displayName=TestChannel;msg-param-login=testchannel;msg-param-viewerCount=15;" +
@"room-id=33332222;subscriber=0;system-msg=15\sraiders\sfrom\sTestChannel\shave\sjoined\n!;tmi-sent-ts=1507246572675;turbo=1;" +
@"user-id=123456;user-type= :tmi.twitch.tv USERNOTICE #othertestchannel";
var NEWCHATTER = @"@badge-info=;badges=;color=;display-name=SevenTest1;emotes=30259:0-6;id=37feed0f-b9c7-4c3a-b475-21c6c6d21c3d;" +
@"login=seventest1;mod=0;msg-id=ritual;msg-param-ritual-name=new_chatter;room-id=87654321;subscriber=0;" +
@"system-msg=Seventoes\sis\snew\shere!;tmi-sent-ts=1508363903826;turbo=0;user-id=77776666;user-type=" +
@" :tmi.twitch.tv USERNOTICE #seventoes :HeyGuys";
var _resub = ReceivedMessage.Parse(RESUB);
Assert.AreEqual(IrcMessageType.USERNOTICE, _resub.MessageType);
if (_resub is UserNotice resub)
{
Assert.AreEqual(Color.FromArgb(0, 128, 0), resub.Color);
Assert.AreEqual("ronni", resub.DisplayName);
Assert.AreEqual("db25007f-7a18-43eb-9379-80131e44d633", resub.Id);
Assert.AreEqual("ronni", resub.Login);
Assert.IsFalse(resub.Moderator);
Assert.AreEqual(RitualType.None, resub.RitualType);
Assert.AreEqual(UserNoticeType.resub, resub.UserNoticeType);
Assert.AreEqual(6, resub.TotalMonths);
Assert.AreEqual(2, resub.StreakMonths);
Assert.IsTrue(resub.ShouldShareStreak);
Assert.AreEqual(SubType.Prime, resub.SubPlan);
Assert.AreEqual("Prime", resub.SubPlanName);
Assert.AreEqual("12345678", resub.RoomId);
Assert.IsTrue(resub.Subscriber);
Assert.AreEqual("ronni has subscribed for 6 months!", resub.SystemMessage);
Assert.AreEqual(new DateTime(2017, 10, 5, 23, 36, 12, 675, DateTimeKind.Utc),
resub.Timestamp);
Assert.IsTrue(resub.Turbo);
Assert.AreEqual("87654321", resub.UserId);
Assert.AreEqual(UserType.Staff, resub.UserType);
Assert.AreEqual("dallas", resub.Channel);
Assert.AreEqual("Great stream -- keep it up!", resub.Message);
}
else
{
Assert.Fail();
}
var _gifted = ReceivedMessage.Parse(GIFTED);
Assert.AreEqual(IrcMessageType.USERNOTICE, _gifted.MessageType);
if (_gifted is UserNotice gifted)
{
Assert.AreEqual(Color.FromArgb(0, 0, 255), gifted.Color);
Assert.AreEqual("TWW2", gifted.DisplayName);
Assert.AreEqual("e9176cd8-5e22-4684-ad40-ce53c2561c5e", gifted.Id);
Assert.AreEqual("tww2", gifted.Login);
Assert.IsFalse(gifted.Moderator);
Assert.AreEqual(RitualType.None, gifted.RitualType);
Assert.AreEqual(UserNoticeType.subgift, gifted.UserNoticeType);
Assert.AreEqual(1, gifted.TotalMonths);
Assert.AreEqual("Mr_Woodchuck", gifted.RecipientDisplayName);
//Twitch's example uses "msg-param-recipient-name" which doesn't appear anywhere
//else in the documentation. I believe this was inteded to be "msg-param-recipient-user-name"
//Assert.AreEqual("mr_woodchuck", gifted.RecipientUsername);
Assert.AreEqual("55554444", gifted.RecipientId);
Assert.AreEqual("House of Nyoro~n", gifted.SubPlanName);
Assert.AreEqual(SubType.T1, gifted.SubPlan);
Assert.AreEqual("19571752", gifted.RoomId);
Assert.IsFalse(gifted.Subscriber);
Assert.AreEqual("TWW2 gifted a Tier 1 sub to Mr_Woodchuck!", gifted.SystemMessage);
Assert.AreEqual(new DateTime(2018, 3, 16, 0, 17, 25, 153, DateTimeKind.Utc),
gifted.Timestamp);
Assert.IsFalse(gifted.Turbo);
Assert.AreEqual("87654321", gifted.UserId);
Assert.AreEqual(UserType.Staff, gifted.UserType);
Assert.AreEqual("forstycup", gifted.Channel);
}
else
{
Assert.Fail();
}
var _raid = ReceivedMessage.Parse(RAID);
Assert.AreEqual(IrcMessageType.USERNOTICE, _raid.MessageType);
if (_raid is UserNotice raid)
{
Assert.AreEqual(Color.FromArgb(154, 205, 50), raid.Color);
Assert.AreEqual("TestChannel", raid.DisplayName);
Assert.AreEqual("3d830f12-795c-447d-af3c-ea05e40fbddb", raid.Id);
Assert.AreEqual("testchannel", raid.Login);
Assert.IsFalse(raid.Moderator);
Assert.AreEqual(RitualType.None, raid.RitualType);
Assert.AreEqual(UserNoticeType.raid, raid.UserNoticeType);
Assert.AreEqual("TestChannel", raid.RaidingChannelDisplayName);
Assert.AreEqual("testchannel", raid.RaidingChannelLogin);
Assert.AreEqual(15, raid.ViewerCount);
Assert.AreEqual("33332222", raid.RoomId);
Assert.IsFalse(raid.Subscriber);
Assert.AreEqual("15 raiders from TestChannel have joined\n!", raid.SystemMessage);
Assert.AreEqual(new DateTime(2017, 10, 5, 23, 36, 12, 675, DateTimeKind.Utc),
raid.Timestamp);
Assert.IsTrue(raid.Turbo);
Assert.AreEqual("123456", raid.UserId);
Assert.AreEqual(UserType.Normal, raid.UserType);
}
else
{
Assert.Fail();
}
var _newchatter = ReceivedMessage.Parse(NEWCHATTER);
Assert.AreEqual(IrcMessageType.USERNOTICE, _newchatter.MessageType);
if (_newchatter is UserNotice newchatter)
{
Assert.AreEqual(null, newchatter.Color);
Assert.AreEqual("SevenTest1", newchatter.DisplayName);
Assert.AreEqual("37feed0f-b9c7-4c3a-b475-21c6c6d21c3d", newchatter.Id);
Assert.AreEqual("seventest1", newchatter.Login);
Assert.IsFalse(newchatter.Moderator);
Assert.AreEqual(RitualType.new_chatter, newchatter.RitualType);
Assert.AreEqual("87654321", newchatter.RoomId);
Assert.IsFalse(newchatter.Subscriber);
Assert.AreEqual("Seventoes is new here!", newchatter.SystemMessage);
Assert.AreEqual(new DateTime(2017, 10, 18, 21, 58, 23, 826, DateTimeKind.Utc),
newchatter.Timestamp);
Assert.IsFalse(newchatter.Turbo);
Assert.AreEqual("77776666", newchatter.UserId);
Assert.AreEqual(UserType.Normal, newchatter.UserType);
}
else
{
Assert.Fail();
}
}
[TestMethod]
public void TestUserstate()
{
var USERSTATE = @"@badge-info=;badges=staff/1;color=#0D4200;display-name=ronni;" +
@"emote-sets=0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239;mod=1;subscriber=1;" +
@"turbo=1;user-type=staff :tmi.twitch.tv USERSTATE #dallas";
var _userstate = ReceivedMessage.Parse(USERSTATE);
Assert.AreEqual(IrcMessageType.USERSTATE, _userstate.MessageType);
if (_userstate is UserState userstate)
{
Assert.AreEqual("dallas", userstate.Channel);
Assert.AreEqual(Color.FromArgb(13, 66, 0), userstate.Color);
Assert.AreEqual("ronni", userstate.DisplayName);
Assert.IsTrue(userstate.EmoteSets.SequenceEqual([0, 33, 50, 237, 793, 2126, 3517, 4578, 5569, 9400, 10337, 12239]));
Assert.IsTrue(userstate.Moderator);
Assert.IsTrue(userstate.Subscriber);
Assert.IsTrue(userstate.Turbo);
Assert.AreEqual(UserType.Staff, userstate.UserType);
}
else
{
Assert.Fail();
}
}
[TestMethod]
public void TestWhisper()
{
//Taken from a Twitch documentation example
//https://dev.twitch.tv/docs/irc/tags/#whisper-tags
var WHISPER = @"@badges=staff/1,bits-charity/1;color=#8A2BE2;display-name=PetsgomOO;emotes=;message-id=306;" +
@"thread-id=12345678_87654321;turbo=0;user-id=87654321;user-type=staff" +
@" :petsgomoo!petsgomoo@petsgomoo.tmi.twitch.tv WHISPER foo :hello";
var _whisper = ReceivedMessage.Parse(WHISPER);
Assert.AreEqual(IrcMessageType.WHISPER, _whisper.MessageType);
if (_whisper is Whisper whisper)
{
Assert.IsTrue(whisper.Badges.SequenceEqual([
new Badge("staff", "1"),
new Badge("bits-charity", "1"),
]));
Assert.AreEqual(Color.FromArgb(138, 43, 226), whisper.Color);
Assert.AreEqual("PetsgomOO", whisper.DisplayName);
Assert.IsTrue(whisper.Emotes.SequenceEqual([]));
Assert.AreEqual("306", whisper.MessageId);
Assert.AreEqual("12345678_87654321", whisper.ThreadId);
Assert.IsFalse(whisper.Turbo);
Assert.AreEqual("87654321", whisper.UserId);
Assert.AreEqual(UserType.Staff, whisper.UserType);
Assert.AreEqual("hello", whisper.Message);
}
else
{
Assert.Fail();
}
}
[TestMethod]
public void TestGlobalUserState()
{
var GLOBAL = @"@badge-info=subscriber/8;badges=subscriber/6;color=#0D4200;display-name=dallas;" +
@"emote-sets=0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239;turbo=0;user-id=12345678;" +
@"user-type=admin :tmi.twitch.tv GLOBALUSERSTATE";
var _global = ReceivedMessage.Parse(GLOBAL);
Assert.AreEqual(IrcMessageType.GLOBALUSERSTATE, _global.MessageType);
if (_global is GlobalUserState global)
{
Assert.IsTrue(global.BadgeInfo.SequenceEqual(["subscriber/8"]));
Assert.IsTrue(global.Badges.SequenceEqual([new Badge("subscriber", "6")]));
Assert.AreEqual(Color.FromArgb(13, 66, 0), global.Color);
Assert.AreEqual("dallas", global.DisplayName);
Assert.IsTrue(global.EmoteSets.SequenceEqual([
0, 33, 50, 237, 793, 2126, 3517, 4578, 5569, 9400, 10337,
12239]));
Assert.IsFalse(global.Turbo);
Assert.AreEqual("12345678", global.UserId);
Assert.AreEqual(UserType.Admin, global.UserType);
}
else
{
Assert.Fail();
}
}
[TestMethod]
public void TestClearMsg()
{
var CLEARMSG = @"@login=ronni;room-id=;target-msg-id=abc-123-def;tmi-sent-ts=1642720582342" +
@" :tmi.twitch.tv CLEARMSG #dallas :HeyGuys";
var _clearmsg = ReceivedMessage.Parse(CLEARMSG);
Assert.AreEqual(IrcMessageType.CLEARMSG, _clearmsg.MessageType);
if (_clearmsg is ClearMsg clearmsg)
{
Assert.AreEqual("ronni", clearmsg.Login);
Assert.AreEqual("", clearmsg.RoomId);
Assert.AreEqual("abc-123-def", clearmsg.TargetMessageId);
Assert.AreEqual(new DateTime(2022, 1, 20, 23, 16, 22, 342, DateTimeKind.Utc),
clearmsg.Timestamp);
Assert.AreEqual("dallas", clearmsg.Channel);
Assert.AreEqual("HeyGuys", clearmsg.Message);
}
else
{
Assert.Fail();
}
}
[TestMethod]
public void TestClearChat()
{
var PERMA = @"@room-id=12345678;target-user-id=87654321;tmi-sent-ts=1642715756806" +
@" :tmi.twitch.tv CLEARCHAT #dallas :ronni";
var CLEARCHAT = @"@room-id=12345678;tmi-sent-ts=1642715695392 :tmi.twitch.tv CLEARCHAT #dallas";
var TIMEOUT = @"@ban-duration=350;room-id=12345678;target-user-id=87654321;tmi-sent-ts=1642719320727" +
@" :tmi.twitch.tv CLEARCHAT #dallas :ronni";
var _perma = ReceivedMessage.Parse(PERMA);
Assert.AreEqual(IrcMessageType.CLEARCHAT, _perma.MessageType);
if (_perma is ClearChat perma)
{
Assert.AreEqual("12345678", perma.RoomId);
Assert.AreEqual("87654321", perma.TargetUserId);
Assert.AreEqual(new DateTime(2022, 1, 20, 21, 55, 56, 806, DateTimeKind.Utc),
perma.Timestamp);
Assert.AreEqual("dallas", perma.Channel);
Assert.AreEqual("ronni", perma.User);
}
else
{
Assert.Fail();
}
var _clearchat = ReceivedMessage.Parse(CLEARCHAT);
Assert.AreEqual(IrcMessageType.CLEARCHAT, _clearchat.MessageType);
if (_clearchat is ClearChat clearchat)
{
Assert.AreEqual("12345678", clearchat.RoomId);
Assert.AreEqual(new DateTime(2022, 1, 20, 21, 54, 55, 392),
clearchat.Timestamp);
Assert.AreEqual("dallas", clearchat.Channel);
}
else
{
Assert.Fail();
}
var _timeout = ReceivedMessage.Parse(TIMEOUT);
Assert.AreEqual(IrcMessageType.CLEARCHAT, _timeout.MessageType);
if (_timeout is ClearChat timeout)
{
}
else
{
Assert.Fail();
}
}
[TestMethod]
public void TestHostTarget()
{
var START = @":tmi.twitch.tv HOSTTARGET #abc :xyz 10";
var END = @":tmi.twitch.tv HOSTTARGET #abc :- 10";
//this should be valid based on the Twitch documentation but there
//doesn't seem to be a real use case
var NOCHAN = @":tmi.twitch.tv HOSTTARGET #abc : 10";
var _start = ReceivedMessage.Parse(START);
Assert.AreEqual(IrcMessageType.HOSTTARGET, _start.MessageType);
if (_start is HostTarget start)
{
Assert.AreEqual("abc", start.HostingChannel);
Assert.AreEqual("xyz", start.ChannelBeingHosted);
Assert.AreEqual(10, start.NumberOfViewers);
Assert.IsTrue(start.NowHosting);
}
else
{
Assert.Fail();
}
var _end = ReceivedMessage.Parse(END);
Assert.AreEqual(IrcMessageType.HOSTTARGET, _end.MessageType);
if (_end is HostTarget end)
{
Assert.AreEqual("abc", end.HostingChannel);
Assert.AreEqual("", end.ChannelBeingHosted);
Assert.IsFalse(end.NowHosting);
Assert.AreEqual(10, end.NumberOfViewers);
}
else
{
Assert.Fail();
}
var _nochan = ReceivedMessage.Parse(NOCHAN);
Assert.AreEqual(IrcMessageType.HOSTTARGET, _nochan.MessageType);
if (_nochan is HostTarget nochan)
{
Assert.AreEqual("abc", nochan.HostingChannel);
Assert.AreEqual("", nochan.ChannelBeingHosted);
Assert.IsTrue(nochan.NowHosting);
Assert.AreEqual(10, nochan.NumberOfViewers);
}
else
{