mirror of
https://github.com/Ikatono/TwitchIrcClient.git
synced 2025-10-29 04:56:12 -05:00
Compare commits
5 Commits
cceae30d5e
...
PubSub-Fai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bda8536464 | ||
|
|
4806e50736 | ||
|
|
1bf8afc68b | ||
|
|
81651a0e59 | ||
|
|
e9bffa4dea |
@@ -139,19 +139,68 @@ namespace TwitchIrcClient.IRC
|
|||||||
var bytes = Encoding.UTF8.GetBytes(line + ENDL);
|
var bytes = Encoding.UTF8.GetBytes(line + ENDL);
|
||||||
_Stream.Write(bytes, 0, bytes.Length);
|
_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)
|
public void Authenticate(string? user, string? pass)
|
||||||
{
|
{
|
||||||
if (user == null)
|
user ??= $"justinfan{Random.Shared.NextInt64(10000):D4}";
|
||||||
user = $"justinfan{Random.Shared.NextInt64(10000):D4}";
|
pass ??= "pass";
|
||||||
if (pass == null)
|
SendMessage(IrcMessageType.PASS, parameters: [pass]);
|
||||||
pass = "pass";
|
SendMessage(IrcMessageType.NICK, parameters: [user]);
|
||||||
SendLine($"NICK {user}");
|
|
||||||
SendLine($"PASS {pass}");
|
|
||||||
}
|
}
|
||||||
public void JoinChannel(string channel)
|
public void JoinChannel(string channel)
|
||||||
{
|
{
|
||||||
channel = channel.TrimStart('#');
|
channel = channel.TrimStart('#');
|
||||||
SendLine($"JOIN #{channel}");
|
SendMessage(IrcMessageType.JOIN, ["#" + channel]);
|
||||||
}
|
}
|
||||||
private async void ListenForInput()
|
private async void ListenForInput()
|
||||||
{
|
{
|
||||||
@@ -268,21 +317,25 @@ namespace TwitchIrcClient.IRC
|
|||||||
public void AddCallback(MessageCallbackItem callbackItem)
|
public void AddCallback(MessageCallbackItem callbackItem)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||||
|
lock (UserCallbacks)
|
||||||
UserCallbacks.Add(callbackItem);
|
UserCallbacks.Add(callbackItem);
|
||||||
}
|
}
|
||||||
public bool RemoveCallback(MessageCallbackItem callbackItem)
|
public bool RemoveCallback(MessageCallbackItem callbackItem)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||||
|
lock (UserCallbacks)
|
||||||
return UserCallbacks.Remove(callbackItem);
|
return UserCallbacks.Remove(callbackItem);
|
||||||
}
|
}
|
||||||
protected void AddSystemCallback(MessageCallbackItem callbackItem)
|
protected void AddSystemCallback(MessageCallbackItem callbackItem)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||||
|
lock (SystemCallbacks)
|
||||||
SystemCallbacks.Add(callbackItem);
|
SystemCallbacks.Add(callbackItem);
|
||||||
}
|
}
|
||||||
protected bool RemoveSystemCallback(MessageCallbackItem callbackItem)
|
protected bool RemoveSystemCallback(MessageCallbackItem callbackItem)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||||
|
lock (SystemCallbacks)
|
||||||
return SystemCallbacks.Remove(callbackItem);
|
return SystemCallbacks.Remove(callbackItem);
|
||||||
}
|
}
|
||||||
private void RunCallbacks(ReceivedMessage message)
|
private void RunCallbacks(ReceivedMessage message)
|
||||||
@@ -290,7 +343,9 @@ namespace TwitchIrcClient.IRC
|
|||||||
ArgumentNullException.ThrowIfNull(message, nameof(message));
|
ArgumentNullException.ThrowIfNull(message, nameof(message));
|
||||||
if (disposedValue)
|
if (disposedValue)
|
||||||
return;
|
return;
|
||||||
|
lock (SystemCallbacks)
|
||||||
SystemCallbacks.ForEach(c => c.TryCall(this, message));
|
SystemCallbacks.ForEach(c => c.TryCall(this, message));
|
||||||
|
lock (UserCallbacks)
|
||||||
UserCallbacks.ForEach(c => c.TryCall(this, message));
|
UserCallbacks.ForEach(c => c.TryCall(this, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +361,7 @@ namespace TwitchIrcClient.IRC
|
|||||||
TokenSource.Dispose();
|
TokenSource.Dispose();
|
||||||
Client?.Dispose();
|
Client?.Dispose();
|
||||||
_HeartbeatTimer?.Dispose();
|
_HeartbeatTimer?.Dispose();
|
||||||
|
_Stream?.Dispose();
|
||||||
}
|
}
|
||||||
disposedValue = true;
|
disposedValue = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ using System.Threading.Tasks;
|
|||||||
|
|
||||||
namespace TwitchIrcClient.IRC
|
namespace TwitchIrcClient.IRC
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the "command" of an IRC message.
|
||||||
|
/// </summary>
|
||||||
public enum IrcMessageType
|
public enum IrcMessageType
|
||||||
{
|
{
|
||||||
//twitch standard messages
|
//twitch standard messages
|
||||||
@@ -174,12 +177,26 @@ namespace TwitchIrcClient.IRC
|
|||||||
}
|
}
|
||||||
public static class IrcMessageTypeHelper
|
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)
|
public static IrcMessageType Parse(string s)
|
||||||
{
|
{
|
||||||
if (int.TryParse(s, out int result))
|
if (int.TryParse(s, out int result))
|
||||||
return (IrcMessageType)result;
|
return (IrcMessageType)result;
|
||||||
return Enum.Parse<IrcMessageType>(s);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ namespace TwitchIrcClient.IRC.Messages
|
|||||||
/// The ID of the user that was banned or put in a timeout.
|
/// The ID of the user that was banned or put in a timeout.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string TargetUserId => TryGetTag("target-user-id");
|
public string TargetUserId => TryGetTag("target-user-id");
|
||||||
public DateTime? TmiSentTime
|
public DateTime? Timestamp
|
||||||
{ get
|
{ get
|
||||||
{
|
{
|
||||||
string s = TryGetTag("tmi-sent-ts");
|
string s = TryGetTag("tmi-sent-ts");
|
||||||
if (!double.TryParse(s, out double d))
|
if (!double.TryParse(s, out double d))
|
||||||
return null;
|
return null;
|
||||||
return DateTime.UnixEpoch.AddSeconds(d);
|
return DateTime.UnixEpoch.AddMilliseconds(d);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -52,12 +52,12 @@ namespace TwitchIrcClient.IRC.Messages
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The name of the channel that either was cleared or banned the user
|
/// The name of the channel that either was cleared or banned the user
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Channel => Parameters.First();
|
public string Channel => Parameters.First().TrimStart('#');
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The username of the banned user, or "" if message is a
|
/// The username of the banned user, or "" if message is a
|
||||||
/// channel clear.
|
/// channel clear.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string User => Parameters.ElementAtOrDefault(2) ?? "";
|
public string User => Parameters.ElementAtOrDefault(1) ?? "";
|
||||||
public ClearChat(ReceivedMessage message) : base(message)
|
public ClearChat(ReceivedMessage message) : base(message)
|
||||||
{
|
{
|
||||||
Debug.Assert(MessageType == IrcMessageType.CLEARCHAT,
|
Debug.Assert(MessageType == IrcMessageType.CLEARCHAT,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ namespace TwitchIrcClient.IRC.Messages
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
///
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime? TmiSentTime
|
public DateTime? Timestamp
|
||||||
{ get
|
{ get
|
||||||
{
|
{
|
||||||
string s = TryGetTag("tmi-sent-ts");
|
string s = TryGetTag("tmi-sent-ts");
|
||||||
@@ -37,6 +37,8 @@ namespace TwitchIrcClient.IRC.Messages
|
|||||||
return DateTime.UnixEpoch.AddSeconds(d / 1000);
|
return DateTime.UnixEpoch.AddSeconds(d / 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public string Channel => Parameters.FirstOrDefault("").TrimStart('#');
|
||||||
|
public string Message => Parameters.LastOrDefault("");
|
||||||
public ClearMsg(ReceivedMessage message) : base(message)
|
public ClearMsg(ReceivedMessage message) : base(message)
|
||||||
{
|
{
|
||||||
Debug.Assert(MessageType == IrcMessageType.CLEARMSG,
|
Debug.Assert(MessageType == IrcMessageType.CLEARMSG,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using System.Threading.Tasks;
|
|||||||
|
|
||||||
namespace TwitchIrcClient.IRC.Messages
|
namespace TwitchIrcClient.IRC.Messages
|
||||||
{
|
{
|
||||||
public record struct Emote(string Name, int Length)
|
public record struct Emote(string Name, int Position, int Length)
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
132
TwitchIrcClient/IRC/Messages/GlobalUserState.cs
Normal file
132
TwitchIrcClient/IRC/Messages/GlobalUserState.cs
Normal 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 field’s
|
||||||
|
/// value in the response. Then, match the version to the id field in the list of versions.
|
||||||
|
/// </summary>
|
||||||
|
public List<Badge> Badges
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("badges", out string? value))
|
||||||
|
return [];
|
||||||
|
if (value == null)
|
||||||
|
return [];
|
||||||
|
List<Badge> badges = [];
|
||||||
|
foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var spl = item.Split('/', 2);
|
||||||
|
badges.Add(new Badge(spl[0], spl[1]));
|
||||||
|
}
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// The color of the user’s name in the chat room. This is a hexadecimal
|
||||||
|
/// RGB color code in the form, #<RGB>. This tag may be empty if it is never set.
|
||||||
|
/// </summary>
|
||||||
|
public Color? Color
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
//should have format "#RRGGBB"
|
||||||
|
if (!MessageTags.TryGetValue("color", out string? value))
|
||||||
|
return null;
|
||||||
|
if (value.Length < 7)
|
||||||
|
return null;
|
||||||
|
int r = Convert.ToInt32(value.Substring(1, 2), 16);
|
||||||
|
int g = Convert.ToInt32(value.Substring(3, 2), 16);
|
||||||
|
int b = Convert.ToInt32(value.Substring(5, 2), 16);
|
||||||
|
return System.Drawing.Color.FromArgb(r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// The user’s display name. This tag may be empty if it is never set.
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayName
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("display-name", out string? value))
|
||||||
|
return "";
|
||||||
|
return value ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
TwitchIrcClient/IRC/Messages/HostTarget.cs
Normal file
41
TwitchIrcClient/IRC/Messages/HostTarget.cs
Normal 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 that’s 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,166 +160,4 @@ namespace TwitchIrcClient.IRC.Messages
|
|||||||
usage_me,
|
usage_me,
|
||||||
usage_mod,
|
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,
|
|
||||||
//}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ namespace TwitchIrcClient.IRC.Messages
|
|||||||
/// List of chat badges. Most badges have only 1 version, but some badges like
|
/// 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
|
/// 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
|
/// long the user has subscribed. To get the badge, use the Get Global Chat
|
||||||
/// Badges and Get Channel Chat Badges APIs.Match the badge to the set-id field’s
|
/// Badges and Get Channel Chat Badges APIs. Match the badge to the set-id field’s
|
||||||
/// value in the response.Then, match the version to the id field in the list of versions.
|
/// value in the response. Then, match the version to the id field in the list of versions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<Badge> Badges
|
public List<Badge> Badges
|
||||||
{ get
|
{ get
|
||||||
@@ -79,6 +79,26 @@ namespace TwitchIrcClient.IRC.Messages
|
|||||||
return value ?? "";
|
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>
|
/// <summary>
|
||||||
/// An ID that uniquely identifies the message.
|
/// An ID that uniquely identifies the message.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -166,6 +186,54 @@ namespace TwitchIrcClient.IRC.Messages
|
|||||||
/// A Boolean value that determines whether the user that sent the chat is a VIP.
|
/// A Boolean value that determines whether the user that sent the chat is a VIP.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Vip => MessageTags.ContainsKey("vip");
|
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 bool FirstMessage => TryGetTag("first-msg") == "1";
|
||||||
public string ChatMessage => Parameters.Last();
|
public string ChatMessage => Parameters.Last();
|
||||||
public Privmsg(ReceivedMessage message) : base(message)
|
public Privmsg(ReceivedMessage message) : base(message)
|
||||||
|
|||||||
@@ -94,27 +94,23 @@ namespace TwitchIrcClient.IRC.Messages
|
|||||||
message.Parameters.Add(spl_final[1]);
|
message.Parameters.Add(spl_final[1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch (message.MessageType)
|
return message.MessageType switch
|
||||||
{
|
{
|
||||||
case IrcMessageType.PRIVMSG:
|
IrcMessageType.CLEARCHAT => new ClearChat(message),
|
||||||
return new Privmsg(message);
|
IrcMessageType.CLEARMSG => new ClearMsg(message),
|
||||||
case IrcMessageType.CLEARCHAT:
|
IrcMessageType.JOIN => new Join(message),
|
||||||
return new ClearChat(message);
|
IrcMessageType.GLOBALUSERSTATE => new GlobalUserState(message),
|
||||||
case IrcMessageType.CLEARMSG:
|
IrcMessageType.HOSTTARGET => new HostTarget(message),
|
||||||
return new ClearMsg(message);
|
IrcMessageType.NOTICE => new Notice(message),
|
||||||
case IrcMessageType.NOTICE:
|
IrcMessageType.PART => new Part(message),
|
||||||
return new Notice(message);
|
IrcMessageType.PRIVMSG => new Privmsg(message),
|
||||||
case IrcMessageType.JOIN:
|
IrcMessageType.ROOMSTATE => new Roomstate(message),
|
||||||
return new Join(message);
|
IrcMessageType.RPL_NAMREPLY => new NamReply(message),
|
||||||
case IrcMessageType.PART:
|
IrcMessageType.USERNOTICE => new UserNotice(message),
|
||||||
return new Part(message);
|
IrcMessageType.USERSTATE => new UserState(message),
|
||||||
case IrcMessageType.RPL_NAMREPLY:
|
IrcMessageType.WHISPER => new Whisper(message),
|
||||||
return new NamReply(message);
|
_ => message,
|
||||||
case IrcMessageType.ROOMSTATE:
|
};
|
||||||
return new Roomstate(message);
|
|
||||||
default:
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tries to get the value of the tag.
|
/// Tries to get the value of the tag.
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
using System.Data;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
|
using System.Numerics;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading.Channels;
|
||||||
using System.Threading.Tasks;
|
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
|
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
|
/// 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
|
/// 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
|
/// long the user has subscribed. To get the badge, use the Get Global Chat
|
||||||
/// Badges and Get Channel Chat Badges APIs.Match the badge to the set-id field’s
|
/// Badges and Get Channel Chat Badges APIs. Match the badge to the set-id field’s
|
||||||
/// value in the response.Then, match the version to the id field in the list of versions.
|
/// value in the response.Then, match the version to the id field in the list of versions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<Badge> Badges
|
public List<Badge> Badges
|
||||||
@@ -52,6 +59,8 @@ namespace TwitchIrcClient.IRC.Messages
|
|||||||
return System.Drawing.Color.FromArgb(r, g, b);
|
return System.Drawing.Color.FromArgb(r, g, b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public string Channel => Parameters.FirstOrDefault("").TrimStart('#');
|
||||||
|
public string Message => Parameters.ElementAtOrDefault(1) ?? "";
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The user’s display name. This tag may be empty if it is never set.
|
/// The user’s display name. This tag may be empty if it is never set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -63,12 +72,20 @@ namespace TwitchIrcClient.IRC.Messages
|
|||||||
return value ?? "";
|
return value ?? "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public IEnumerable<Emote> Emotes
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An ID that uniquely identifies the message.
|
/// An ID that uniquely identifies the message.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Id => TryGetTag("id");
|
public string Id => TryGetTag("id");
|
||||||
public UserNoticeType? UserNoticeType => Enum.TryParse(TryGetTag("msg-id"), out UserNoticeType type)
|
public UserNoticeType? UserNoticeType => Enum.TryParse(TryGetTag("msg-id"), out UserNoticeType type)
|
||||||
? type : null;
|
? type : null;
|
||||||
|
public string Login => TryGetTag("login");
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether the user is a moderator in this channel
|
/// Whether the user is a moderator in this channel
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -118,24 +135,160 @@ namespace TwitchIrcClient.IRC.Messages
|
|||||||
return value ?? "";
|
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 broadcaster’s 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
|
public UserType UserType
|
||||||
{ get
|
{ get
|
||||||
{
|
{
|
||||||
if (!MessageTags.TryGetValue("user-type", out string? value))
|
var value = TryGetTag("user-type");
|
||||||
return UserType.Normal;
|
return value.ToUpper() switch
|
||||||
switch (value)
|
|
||||||
{
|
{
|
||||||
case "admin":
|
"ADMIN" => UserType.Admin,
|
||||||
return UserType.Admin;
|
"GLOBAL_MOD" => UserType.GlobalMod,
|
||||||
case "global_mod":
|
"STAFF" => UserType.Staff,
|
||||||
return UserType.GlobalMod;
|
"" => UserType.Normal,
|
||||||
case "staff":
|
_ => UserType.Normal,
|
||||||
return UserType.Staff;
|
};
|
||||||
default:
|
|
||||||
return 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)
|
public UserNotice(ReceivedMessage message) : base(message)
|
||||||
{
|
{
|
||||||
Debug.Assert(MessageType == IrcMessageType.USERNOTICE,
|
Debug.Assert(MessageType == IrcMessageType.USERNOTICE,
|
||||||
@@ -145,16 +298,31 @@ namespace TwitchIrcClient.IRC.Messages
|
|||||||
}
|
}
|
||||||
public enum UserNoticeType
|
public enum UserNoticeType
|
||||||
{
|
{
|
||||||
sub,
|
sub = 0,
|
||||||
resub,
|
resub = 1,
|
||||||
subgift,
|
subgift = 2,
|
||||||
submysterygift,
|
submysterygift = 3,
|
||||||
giftpaidupgrade,
|
giftpaidupgrade = 4,
|
||||||
rewardgift,
|
rewardgift = 5,
|
||||||
anongiftpaidupgrade,
|
anongiftpaidupgrade = 6,
|
||||||
raid,
|
raid = 7,
|
||||||
unraid,
|
unraid = 8,
|
||||||
ritual,
|
ritual = 9,
|
||||||
bitsbadgetier,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
TwitchIrcClient/IRC/Messages/UserState.cs
Normal file
119
TwitchIrcClient/IRC/Messages/UserState.cs
Normal 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 user’s name in the chat room. This is a hexadecimal
|
||||||
|
/// RGB color code in the form, #<RGB>. This tag may be empty if it is never set.
|
||||||
|
/// </summary>
|
||||||
|
public Color? Color
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
//should have format "#RRGGBB"
|
||||||
|
if (!MessageTags.TryGetValue("color", out string? value))
|
||||||
|
return null;
|
||||||
|
if (value.Length < 7)
|
||||||
|
return null;
|
||||||
|
int r = Convert.ToInt32(value.Substring(1, 2), 16);
|
||||||
|
int g = Convert.ToInt32(value.Substring(3, 2), 16);
|
||||||
|
int b = Convert.ToInt32(value.Substring(5, 2), 16);
|
||||||
|
return System.Drawing.Color.FromArgb(r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// The user’s display name, 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
TwitchIrcClient/IRC/Messages/Whisper.cs
Normal file
140
TwitchIrcClient/IRC/Messages/Whisper.cs
Normal 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 field’s
|
||||||
|
/// value in the response.Then, match the version to the id field in the list of versions.
|
||||||
|
/// </summary>
|
||||||
|
public List<Badge> Badges
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("badges", out string? value))
|
||||||
|
return [];
|
||||||
|
if (value == null)
|
||||||
|
return [];
|
||||||
|
List<Badge> badges = [];
|
||||||
|
foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var spl = item.Split('/', 2);
|
||||||
|
badges.Add(new Badge(spl[0], spl[1]));
|
||||||
|
}
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// The color of the user’s name in the chat room. This is a hexadecimal
|
||||||
|
/// RGB color code in the form, #<RGB>. This tag may be empty if it is never set.
|
||||||
|
/// </summary>
|
||||||
|
public Color? Color
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
//should have format "#RRGGBB"
|
||||||
|
if (!MessageTags.TryGetValue("color", out string? value))
|
||||||
|
return null;
|
||||||
|
if (value.Length < 7)
|
||||||
|
return null;
|
||||||
|
int r = Convert.ToInt32(value.Substring(1, 2), 16);
|
||||||
|
int g = Convert.ToInt32(value.Substring(3, 2), 16);
|
||||||
|
int b = Convert.ToInt32(value.Substring(5, 2), 16);
|
||||||
|
return System.Drawing.Color.FromArgb(r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// The user’s display name. This tag may be empty if it is never set.
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayName
|
||||||
|
{ get
|
||||||
|
{
|
||||||
|
if (!MessageTags.TryGetValue("display-name", out string? value))
|
||||||
|
return "";
|
||||||
|
return value ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,7 +88,9 @@ namespace TwitchIrcClient.IRC
|
|||||||
{
|
{
|
||||||
lock (Semaphore)
|
lock (Semaphore)
|
||||||
{
|
{
|
||||||
Semaphore.Release(MessageLimit - Semaphore.CurrentCount);
|
var count = MessageLimit - Semaphore.CurrentCount;
|
||||||
|
if (count > 0)
|
||||||
|
Semaphore.Release(count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (SemaphoreFullException) { }
|
catch (SemaphoreFullException) { }
|
||||||
@@ -96,17 +98,17 @@ namespace TwitchIrcClient.IRC
|
|||||||
}
|
}
|
||||||
|
|
||||||
#region RateLimiter Dispose
|
#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)
|
protected virtual void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
if (!disposedValue)
|
if (Interlocked.Increment(ref _disposedCount) == 1)
|
||||||
{
|
{
|
||||||
if (disposing)
|
if (disposing)
|
||||||
{
|
{
|
||||||
Semaphore?.Dispose();
|
Semaphore?.Dispose();
|
||||||
Timer?.Dispose();
|
Timer?.Dispose();
|
||||||
}
|
}
|
||||||
disposedValue = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ RateLimiter limiter = new(20, 30);
|
|||||||
bool ssl = true;
|
bool ssl = true;
|
||||||
async Task<IrcConnection> CreateConnection(string channel)
|
async Task<IrcConnection> CreateConnection(string channel)
|
||||||
{
|
{
|
||||||
IrcConnection connection;
|
IrcConnection connection = ssl
|
||||||
if (ssl)
|
? connection = new IrcConnection("irc.chat.twitch.tv", 6697, limiter, true, true)
|
||||||
connection = new IrcConnection("irc.chat.twitch.tv", 6697, limiter, true, true);
|
: connection = new IrcConnection("irc.chat.twitch.tv", 6667, limiter, true, false);
|
||||||
else
|
|
||||||
connection = new IrcConnection("irc.chat.twitch.tv", 6667, limiter, true, false);
|
|
||||||
connection.AddCallback(new MessageCallbackItem(
|
connection.AddCallback(new MessageCallbackItem(
|
||||||
(o, m) =>
|
(o, m) =>
|
||||||
{
|
{
|
||||||
@@ -51,8 +49,8 @@ async Task<IrcConnection> CreateConnection(string channel)
|
|||||||
}
|
}
|
||||||
Console.Write("Channel: ");
|
Console.Write("Channel: ");
|
||||||
var channelName = Console.ReadLine();
|
var channelName = Console.ReadLine();
|
||||||
ArgumentNullException.ThrowIfNull(channelName, nameof(Channel));
|
ArgumentNullException.ThrowIfNullOrWhiteSpace(channelName, nameof(channelName));
|
||||||
var connection = CreateConnection(channelName);
|
var connection = await CreateConnection(channelName);
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
//all the work happens in other threads
|
//all the work happens in other threads
|
||||||
|
|||||||
133
TwitchIrcClient/PubSub/Message/PubSubMessage.cs
Normal file
133
TwitchIrcClient/PubSub/Message/PubSubMessage.cs
Normal 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?>
|
||||||
|
}
|
||||||
|
}
|
||||||
23
TwitchIrcClient/PubSub/PubSubCallbackItem.cs
Normal file
23
TwitchIrcClient/PubSub/PubSubCallbackItem.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
355
TwitchIrcClient/PubSub/PubSubConnection.cs
Normal file
355
TwitchIrcClient/PubSub/PubSubConnection.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.Drawing;
|
|||||||
using System;
|
using System;
|
||||||
using TwitchIrcClient.IRC;
|
using TwitchIrcClient.IRC;
|
||||||
using TwitchIrcClient.IRC.Messages;
|
using TwitchIrcClient.IRC.Messages;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
namespace TwitchIrcClientTests
|
namespace TwitchIrcClientTests
|
||||||
{
|
{
|
||||||
@@ -9,24 +10,9 @@ namespace TwitchIrcClientTests
|
|||||||
public class ParserTest
|
public class ParserTest
|
||||||
{
|
{
|
||||||
[TestMethod]
|
[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 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);
|
var _roomstate = ReceivedMessage.Parse(ROOMSTATE);
|
||||||
Assert.AreEqual(IrcMessageType.ROOMSTATE, _roomstate.MessageType);
|
Assert.AreEqual(IrcMessageType.ROOMSTATE, _roomstate.MessageType);
|
||||||
if (_roomstate is Roomstate roomstate)
|
if (_roomstate is Roomstate roomstate)
|
||||||
@@ -55,7 +41,11 @@ namespace TwitchIrcClientTests
|
|||||||
{
|
{
|
||||||
Assert.Fail();
|
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);
|
var _namReply = ReceivedMessage.Parse(NAMREPLY);
|
||||||
Assert.AreEqual(IrcMessageType.RPL_NAMREPLY, _namReply.MessageType);
|
Assert.AreEqual(IrcMessageType.RPL_NAMREPLY, _namReply.MessageType);
|
||||||
if (_namReply is NamReply namReply)
|
if (_namReply is NamReply namReply)
|
||||||
@@ -68,7 +58,11 @@ namespace TwitchIrcClientTests
|
|||||||
{
|
{
|
||||||
Assert.Fail();
|
Assert.Fail();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
[TestMethod]
|
||||||
|
public void TestJoin()
|
||||||
|
{
|
||||||
|
var JOIN = ":newuser!newuser@newuser.tmi.twitch.tv JOIN #channelname";
|
||||||
var _join = ReceivedMessage.Parse(JOIN);
|
var _join = ReceivedMessage.Parse(JOIN);
|
||||||
Assert.AreEqual(IrcMessageType.JOIN, _join.MessageType);
|
Assert.AreEqual(IrcMessageType.JOIN, _join.MessageType);
|
||||||
if (_join is Join join)
|
if (_join is Join join)
|
||||||
@@ -80,7 +74,11 @@ namespace TwitchIrcClientTests
|
|||||||
{
|
{
|
||||||
Assert.Fail();
|
Assert.Fail();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
[TestMethod]
|
||||||
|
public void TestPart()
|
||||||
|
{
|
||||||
|
var PART = ":leavinguser!leavinguser@leavinguser.tmi.twitch.tv PART #channelname";
|
||||||
var _part = ReceivedMessage.Parse(PART);
|
var _part = ReceivedMessage.Parse(PART);
|
||||||
Assert.AreEqual(IrcMessageType.PART, _part.MessageType);
|
Assert.AreEqual(IrcMessageType.PART, _part.MessageType);
|
||||||
if (_part is Part part)
|
if (_part is Part part)
|
||||||
@@ -92,6 +90,25 @@ namespace TwitchIrcClientTests
|
|||||||
{
|
{
|
||||||
Assert.Fail();
|
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);
|
var _priv = ReceivedMessage.Parse(PRIVMSG);
|
||||||
Assert.AreEqual(IrcMessageType.PRIVMSG, _priv.MessageType);
|
Assert.AreEqual(IrcMessageType.PRIVMSG, _priv.MessageType);
|
||||||
@@ -111,6 +128,7 @@ namespace TwitchIrcClientTests
|
|||||||
Assert.AreEqual(UserType.Normal, priv.UserType);
|
Assert.AreEqual(UserType.Normal, priv.UserType);
|
||||||
Assert.IsFalse(priv.Vip);
|
Assert.IsFalse(priv.Vip);
|
||||||
Assert.AreEqual(new DateTime(2024, 3, 20, 7, 41, 37, 332, DateTimeKind.Utc), priv.Timestamp);
|
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
|
else
|
||||||
{
|
{
|
||||||
@@ -134,8 +152,388 @@ namespace TwitchIrcClientTests
|
|||||||
Assert.AreEqual("012345678", cheer.UserId);
|
Assert.AreEqual("012345678", cheer.UserId);
|
||||||
Assert.AreEqual(UserType.Normal, cheer.UserType);
|
Assert.AreEqual(UserType.Normal, cheer.UserType);
|
||||||
Assert.IsTrue(cheer.Vip);
|
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.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
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user