mirror of
https://github.com/Ikatono/TwitchIrcClient.git
synced 2025-10-29 04:56:12 -05:00
Compare commits
12 Commits
7e089e1705
...
PubSub-Fai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bda8536464 | ||
|
|
4806e50736 | ||
|
|
1bf8afc68b | ||
|
|
81651a0e59 | ||
|
|
e9bffa4dea | ||
|
|
cceae30d5e | ||
|
|
917e90558d | ||
|
|
29b5b111b2 | ||
|
|
c2ad6b9a8e | ||
|
|
9dc86478a8 | ||
|
|
8302b2639b | ||
|
|
9a69404c66 |
1
README.md
Normal file
1
README.md
Normal file
@@ -0,0 +1 @@
|
||||
Provides a light-weight client for Twitch chatrooms over IRC. Primarily focused on receiving messages rather than sending them, TwitchIrcClient automatically requests all messages and tags from Twitch and parses these into easy to use classes. Additionally, it provides an event-like interface to receive messages of a specific type, and quality of life features like user tracking and an event for batches of user updates. Future plans include better handling of outgoing messages, providing interfaces to more tags for features like Hype Chats, and a better way to read chat messages with emotes substituted.
|
||||
@@ -3,7 +3,12 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.9.34607.119
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TwitchIrcClient", "TwitchIrcClient\TwitchIrcClient.csproj", "{465639B4-4511-473A-ADC8-23B994E3C21C}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TwitchIrcClient", "TwitchIrcClient\TwitchIrcClient.csproj", "{465639B4-4511-473A-ADC8-23B994E3C21C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TwitchIrcClientTests", "TwitchIrcClientTests\TwitchIrcClientTests.csproj", "{D1047D1F-2B92-40B3-90FE-D16E4D631333}"
|
||||
ProjectSection(ProjectDependencies) = postProject
|
||||
{465639B4-4511-473A-ADC8-23B994E3C21C} = {465639B4-4511-473A-ADC8-23B994E3C21C}
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@@ -15,6 +20,10 @@ Global
|
||||
{465639B4-4511-473A-ADC8-23B994E3C21C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{465639B4-4511-473A-ADC8-23B994E3C21C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{465639B4-4511-473A-ADC8-23B994E3C21C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D1047D1F-2B92-40B3-90FE-D16E4D631333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D1047D1F-2B92-40B3-90FE-D16E4D631333}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1047D1F-2B92-40B3-90FE-D16E4D631333}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1047D1F-2B92-40B3-90FE-D16E4D631333}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -5,10 +5,10 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC
|
||||
namespace TwitchIrcClient.IRC
|
||||
{
|
||||
public record struct Badge(string Name, string Version)
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using TwitchLogger.IRC.Messages;
|
||||
using TwitchIrcClient.IRC.Messages;
|
||||
|
||||
namespace TwitchLogger.IRC
|
||||
namespace TwitchIrcClient.IRC
|
||||
{
|
||||
//public class IrcMessageEventArgs(ReceivedMessage message) : EventArgs
|
||||
//{
|
||||
|
||||
@@ -13,9 +13,9 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using TwitchIrcClient.IRC.Messages;
|
||||
using TwitchLogger.IRC.Messages;
|
||||
using TwitchIrcClient.IRC.Messages;
|
||||
|
||||
namespace TwitchLogger.IRC
|
||||
namespace TwitchIrcClient.IRC
|
||||
{
|
||||
/// <summary>
|
||||
/// Connects to a single Twitch chat channel via limited IRC implementation.
|
||||
@@ -139,19 +139,68 @@ namespace TwitchLogger.IRC
|
||||
var bytes = Encoding.UTF8.GetBytes(line + ENDL);
|
||||
_Stream.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
//TODO make this unit testable?
|
||||
/// <summary>
|
||||
/// Construct an IRC message from parts and sends it. Does little to no validation on inputs.
|
||||
/// </summary>
|
||||
/// <param name="command"></param>
|
||||
/// <param name="parameters"></param>
|
||||
/// <param name="tags"></param>
|
||||
/// <param name="prefix"></param>
|
||||
public void SendMessage(IrcMessageType command, IEnumerable<string>? parameters = null,
|
||||
Dictionary<string, string?>? tags = null, string? prefix = null)
|
||||
{
|
||||
var message = "";
|
||||
if (tags is not null && tags.Count != 0)
|
||||
{
|
||||
message = "@" + string.Join(';',
|
||||
tags.OrderBy(p => p.Key).Select(p => $"{p.Key}={EscapeTagValue(p.Value)}"))
|
||||
+ " ";
|
||||
}
|
||||
if (prefix is not null && !string.IsNullOrWhiteSpace(prefix))
|
||||
message += ":" + prefix + " ";
|
||||
message += command.ToCommand() + " ";
|
||||
if (parameters is not null && parameters.Any())
|
||||
{
|
||||
//if ((command == IrcMessageType.NICK || command == IrcMessageType.PASS)
|
||||
// && parameters.Count() == 1)
|
||||
if (false)
|
||||
{
|
||||
message += " " + parameters.Single();
|
||||
}
|
||||
else
|
||||
{
|
||||
message += string.Join(' ', parameters.SkipLast(1));
|
||||
message += " :" + parameters.Last();
|
||||
}
|
||||
}
|
||||
SendLine(message);
|
||||
}
|
||||
private static string EscapeTagValue(string? s)
|
||||
{
|
||||
if (s is null)
|
||||
return "";
|
||||
return string.Join("", s.Select(c => c switch
|
||||
{
|
||||
';' => @"\:",
|
||||
' ' => @"\s",
|
||||
'\\' => @"\\",
|
||||
'\r' => @"\r",
|
||||
'\n' => @"\n",
|
||||
char ch => ch.ToString(),
|
||||
}));
|
||||
}
|
||||
public void Authenticate(string? user, string? pass)
|
||||
{
|
||||
if (user == null)
|
||||
user = $"justinfan{Random.Shared.NextInt64(10000):D4}";
|
||||
if (pass == null)
|
||||
pass = "pass";
|
||||
SendLine($"NICK {user}");
|
||||
SendLine($"PASS {pass}");
|
||||
user ??= $"justinfan{Random.Shared.NextInt64(10000):D4}";
|
||||
pass ??= "pass";
|
||||
SendMessage(IrcMessageType.PASS, parameters: [pass]);
|
||||
SendMessage(IrcMessageType.NICK, parameters: [user]);
|
||||
}
|
||||
public void JoinChannel(string channel)
|
||||
{
|
||||
channel = channel.TrimStart('#');
|
||||
SendLine($"JOIN #{channel}");
|
||||
SendMessage(IrcMessageType.JOIN, ["#" + channel]);
|
||||
}
|
||||
private async void ListenForInput()
|
||||
{
|
||||
@@ -268,30 +317,36 @@ namespace TwitchLogger.IRC
|
||||
public void AddCallback(MessageCallbackItem callbackItem)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||
UserCallbacks.Add(callbackItem);
|
||||
lock (UserCallbacks)
|
||||
UserCallbacks.Add(callbackItem);
|
||||
}
|
||||
public bool RemoveCallback(MessageCallbackItem callbackItem)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||
return UserCallbacks.Remove(callbackItem);
|
||||
lock (UserCallbacks)
|
||||
return UserCallbacks.Remove(callbackItem);
|
||||
}
|
||||
protected void AddSystemCallback(MessageCallbackItem callbackItem)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||
SystemCallbacks.Add(callbackItem);
|
||||
lock (SystemCallbacks)
|
||||
SystemCallbacks.Add(callbackItem);
|
||||
}
|
||||
protected bool RemoveSystemCallback(MessageCallbackItem callbackItem)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||
return SystemCallbacks.Remove(callbackItem);
|
||||
lock (SystemCallbacks)
|
||||
return SystemCallbacks.Remove(callbackItem);
|
||||
}
|
||||
private void RunCallbacks(ReceivedMessage message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message, nameof(message));
|
||||
if (disposedValue)
|
||||
if (disposedValue)
|
||||
return;
|
||||
SystemCallbacks.ForEach(c => c.TryCall(this, message));
|
||||
UserCallbacks.ForEach(c => c.TryCall(this, message));
|
||||
lock (SystemCallbacks)
|
||||
SystemCallbacks.ForEach(c => c.TryCall(this, message));
|
||||
lock (UserCallbacks)
|
||||
UserCallbacks.ForEach(c => c.TryCall(this, message));
|
||||
}
|
||||
|
||||
#region Dispose
|
||||
@@ -306,6 +361,7 @@ namespace TwitchLogger.IRC
|
||||
TokenSource.Dispose();
|
||||
Client?.Dispose();
|
||||
_HeartbeatTimer?.Dispose();
|
||||
_Stream?.Dispose();
|
||||
}
|
||||
disposedValue = true;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC
|
||||
namespace TwitchIrcClient.IRC
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents the "command" of an IRC message.
|
||||
/// </summary>
|
||||
public enum IrcMessageType
|
||||
{
|
||||
//twitch standard messages
|
||||
@@ -174,12 +177,26 @@ namespace TwitchLogger.IRC
|
||||
}
|
||||
public static class IrcMessageTypeHelper
|
||||
{
|
||||
//parses a string that is either a numeric code or the command name
|
||||
/// <summary>
|
||||
/// Parses a string that is either a numeric code or the command name.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>
|
||||
/// The value range 000-999 is reserved for numeric commands, and will
|
||||
/// be converted to a numeric string when forming a message.
|
||||
/// </remarks>
|
||||
public static IrcMessageType Parse(string s)
|
||||
{
|
||||
if (int.TryParse(s, out int result))
|
||||
return (IrcMessageType)result;
|
||||
return Enum.Parse<IrcMessageType>(s);
|
||||
}
|
||||
public static string ToCommand(this IrcMessageType type)
|
||||
{
|
||||
if ((int)type >= 0 && (int)type < 1000)
|
||||
return $"{(int)type,3}";
|
||||
return type.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC
|
||||
namespace TwitchIrcClient.IRC
|
||||
{
|
||||
/// <summary>
|
||||
/// Holds key-value pairs of tags. Tag names are case-sensitive and DO NOT parse
|
||||
|
||||
@@ -5,7 +5,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class ClearChat : ReceivedMessage
|
||||
{
|
||||
@@ -30,13 +30,13 @@ namespace TwitchLogger.IRC.Messages
|
||||
/// The ID of the user that was banned or put in a timeout.
|
||||
/// </summary>
|
||||
public string TargetUserId => TryGetTag("target-user-id");
|
||||
public DateTime? TmiSentTime
|
||||
public DateTime? Timestamp
|
||||
{ get
|
||||
{
|
||||
string s = TryGetTag("tmi-sent-ts");
|
||||
if (!double.TryParse(s, out double d))
|
||||
return null;
|
||||
return DateTime.UnixEpoch.AddSeconds(d);
|
||||
return DateTime.UnixEpoch.AddMilliseconds(d);
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
@@ -52,12 +52,12 @@ namespace TwitchLogger.IRC.Messages
|
||||
/// <summary>
|
||||
/// The name of the channel that either was cleared or banned the user
|
||||
/// </summary>
|
||||
public string Channel => Parameters.First();
|
||||
public string Channel => Parameters.First().TrimStart('#');
|
||||
/// <summary>
|
||||
/// The username of the banned user, or "" if message is a
|
||||
/// channel clear.
|
||||
/// </summary>
|
||||
public string User => Parameters.ElementAtOrDefault(2) ?? "";
|
||||
public string User => Parameters.ElementAtOrDefault(1) ?? "";
|
||||
public ClearChat(ReceivedMessage message) : base(message)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.CLEARCHAT,
|
||||
|
||||
@@ -5,7 +5,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates that a message was deleted.
|
||||
@@ -28,15 +28,17 @@ namespace TwitchLogger.IRC.Messages
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public DateTime? TmiSentTime
|
||||
public DateTime? Timestamp
|
||||
{ get
|
||||
{
|
||||
string s = TryGetTag("tmi-sent-ts");
|
||||
if (!double.TryParse(s, out double d))
|
||||
return null;
|
||||
return DateTime.UnixEpoch.AddSeconds(d);
|
||||
return DateTime.UnixEpoch.AddSeconds(d / 1000);
|
||||
}
|
||||
}
|
||||
public string Channel => Parameters.FirstOrDefault("").TrimStart('#');
|
||||
public string Message => Parameters.LastOrDefault("");
|
||||
public ClearMsg(ReceivedMessage message) : base(message)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.CLEARMSG,
|
||||
|
||||
13
TwitchIrcClient/IRC/Messages/Emote.cs
Normal file
13
TwitchIrcClient/IRC/Messages/Emote.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,12 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class Join : ReceivedMessage
|
||||
{
|
||||
public string Username => Prefix?.Split('!', 2).First() ?? "";
|
||||
public string ChannelName => Parameters.Single().TrimStart('#');
|
||||
public Join(ReceivedMessage message) : base(message)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.JOIN,
|
||||
|
||||
@@ -5,14 +5,14 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class NamReply : ReceivedMessage
|
||||
{
|
||||
public IEnumerable<string> Users =>
|
||||
Parameters.Last().Split(' ', StringSplitOptions.TrimEntries
|
||||
| StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
public string ChannelName => Parameters.TakeLast(2).First().TrimStart('#');
|
||||
public NamReply(ReceivedMessage message) : base(message)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.RPL_NAMREPLY,
|
||||
|
||||
@@ -6,7 +6,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class Notice : ReceivedMessage
|
||||
{
|
||||
@@ -15,16 +15,6 @@ namespace TwitchLogger.IRC.Messages
|
||||
/// </summary>
|
||||
public NoticeId? MessageId => Enum.TryParse(TryGetTag("msg-id"), out NoticeId value)
|
||||
? value : null;
|
||||
//{ get
|
||||
// {
|
||||
// string spaced = TryGetTag("msg-id").Replace('_', ' ');
|
||||
// string title = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(spaced);
|
||||
// string pascal = title.Replace(" ", "");
|
||||
// if (!Enum.TryParse(pascal, out NoticeId value))
|
||||
// return null;
|
||||
// return value;
|
||||
// }
|
||||
//}
|
||||
public string TargetUserId => TryGetTag("target-user-id");
|
||||
|
||||
public Notice(ReceivedMessage message) : base(message)
|
||||
@@ -170,166 +160,4 @@ namespace TwitchLogger.IRC.Messages
|
||||
usage_me,
|
||||
usage_mod,
|
||||
}
|
||||
//public enum NoticeId
|
||||
//{
|
||||
// AlreadyBanned,
|
||||
// AlreadyEmoteOnlyOff,
|
||||
// AlreadyEmoteOnlyOn,
|
||||
// AlreadyFollowersOff,
|
||||
// AlreadyFollowersOn,
|
||||
// AlreadyR9KOff,
|
||||
// AlreadyR9KOn,
|
||||
// AlreadySlowOff,
|
||||
// AlreadySlowOn,
|
||||
// AlreadySubsOff,
|
||||
// AlreadySubsOn,
|
||||
// AutohostReceive,
|
||||
// BadBanAdmin,
|
||||
// BadBanAnon,
|
||||
// BadBanBroadcaster,
|
||||
// BadBanMod,
|
||||
// BadBanSelf,
|
||||
// BadBanStaff,
|
||||
// BadCommercialError,
|
||||
// BadDeleteMessageBroadcaster,
|
||||
// BadDeleteMessageMod,
|
||||
// BadHostError,
|
||||
// BadHostHosting,
|
||||
// BadHostRateExceeded,
|
||||
// BadHostRejected,
|
||||
// BadHostSelf,
|
||||
// BadModBanned,
|
||||
// BadModMod,
|
||||
// BadSlowDuration,
|
||||
// BadTimeoutAdmin,
|
||||
// BadTimeoutAnon,
|
||||
// BadTimeoutBroadcaster,
|
||||
// BadTimeoutDuration,
|
||||
// BadTimeoutMod,
|
||||
// BadTimeoutSelf,
|
||||
// BadTimeoutStaff,
|
||||
// BadUnbanNoBan,
|
||||
// BadUnhostError,
|
||||
// BadUnmodMod,
|
||||
// BadVipGranteeBanned,
|
||||
// BadVipGranteeAlreadyVip,
|
||||
// BadVipMaxVipsReached,
|
||||
// BadVipAchievementIncomplete,
|
||||
// BadUnvipGranteeNotVip,
|
||||
// BanSuccess,
|
||||
// CmdsAvailable,
|
||||
// ColorChanged,
|
||||
// CommercialSuccess,
|
||||
// DeleteMessageSuccess,
|
||||
// DeleteStaffMessageSuccess,
|
||||
// EmoteOnlyOff,
|
||||
// EmoteOnlyOn,
|
||||
// FollowersOff,
|
||||
// FollowersOn,
|
||||
// FollowersOnZero,
|
||||
// HostOff,
|
||||
// HostOn,
|
||||
// HostReceive,
|
||||
// HostReceiveNoCount,
|
||||
// HostTargetWentOffline,
|
||||
// HostsRemaining,
|
||||
// InvalidUser,
|
||||
// ModSuccess,
|
||||
// MsgBanned,
|
||||
// MsgBadCharacters,
|
||||
// MsgChannelBlocked,
|
||||
// MsgChannelSuspended,
|
||||
// MsgDuplicate,
|
||||
// MsgEmoteonly,
|
||||
// MsgFollowersonly,
|
||||
// MsgFollowersonlyFollowed,
|
||||
// MsgFollowersonlyZero,
|
||||
// MsgR9K,
|
||||
// MsgRatelimit,
|
||||
// MsgRejected,
|
||||
// MsgRejectedMandatory,
|
||||
// MsgRequiresVerifiedPhoneNumber,
|
||||
// MsgSlowmode,
|
||||
// MsgSubsonly,
|
||||
// MsgSuspended,
|
||||
// MsgTimedout,
|
||||
// MsgVerifiedEmail,
|
||||
// NoHelp,
|
||||
// NoMods,
|
||||
// NoVips,
|
||||
// NotHosting,
|
||||
// NoPermission,
|
||||
// R9KOff,
|
||||
// R9KOn,
|
||||
// RaidErrorAlreadyRaiding,
|
||||
// RaidErrorForbidden,
|
||||
// RaidErrorSelf,
|
||||
// RaidErrorTooManyViewers,
|
||||
// RaidErrorUnexpected,
|
||||
// RaidNoticeMature,
|
||||
// RaidNoticeRestrictedChat,
|
||||
// RoomMods,
|
||||
// SlowOff,
|
||||
// SlowOn,
|
||||
// SubsOff,
|
||||
// SubsOn,
|
||||
// TimeoutNoTimeout,
|
||||
// TimeoutSuccess,
|
||||
// TosBan,
|
||||
// TurboOnlyColor,
|
||||
// UnavailableCommand,
|
||||
// UnbanSuccess,
|
||||
// UnmodSuccess,
|
||||
// UnraidErrorNoActiveRaid,
|
||||
// UnraidErrorUnexpected,
|
||||
// UnraidSuccess,
|
||||
// UnrecognizedCmd,
|
||||
// UntimeoutBanned,
|
||||
// UntimeoutSuccess,
|
||||
// UnvipSuccess,
|
||||
// UsageBan,
|
||||
// UsageClear,
|
||||
// UsageColor,
|
||||
// UsageCommercial,
|
||||
// UsageDisconnect,
|
||||
// UsageDelete,
|
||||
// UsageEmoteOnlyOff,
|
||||
// UsageEmoteOnlyOn,
|
||||
// UsageFollowersOff,
|
||||
// UsageFollowersOn,
|
||||
// UsageHelp,
|
||||
// UsageHost,
|
||||
// UsageMarker,
|
||||
// UsageMe,
|
||||
// UsageMod,
|
||||
// UsageMods,
|
||||
// UsageR9KOff,
|
||||
// UsageR9KOn,
|
||||
// UsageRaid,
|
||||
// UsageSlowOff,
|
||||
// UsageSlowOn,
|
||||
// UsageSubsOff,
|
||||
// UsageSubsOn,
|
||||
// UsageTimeout,
|
||||
// UsageUnban,
|
||||
// UsageUnhost,
|
||||
// UsageUnmod,
|
||||
// UsageUnraid,
|
||||
// UsageUntimeout,
|
||||
// UsageUnvip,
|
||||
// UsageUser,
|
||||
// UsageVip,
|
||||
// UsageVips,
|
||||
// UsageWhisper,
|
||||
// VipSuccess,
|
||||
// VipsSuccess,
|
||||
// WhisperBanned,
|
||||
// WhisperBannedRecipient,
|
||||
// WhisperInvalidLogin,
|
||||
// WhisperInvalidSelf,
|
||||
// WhisperLimitPerMin,
|
||||
// WhisperLimitPerSec,
|
||||
// WhisperRestricted,
|
||||
// WhisperRestrictedRecipient,
|
||||
//}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class Part : ReceivedMessage
|
||||
{
|
||||
public string Username => Prefix?.Split('!', 2).First() ?? "";
|
||||
public string ChannelName => Parameters.Single().TrimStart('#');
|
||||
public Part(ReceivedMessage message) : base(message)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.PART,
|
||||
|
||||
@@ -7,7 +7,7 @@ using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
@@ -18,8 +18,8 @@ namespace TwitchLogger.IRC.Messages
|
||||
/// List of chat badges. Most badges have only 1 version, but some badges like
|
||||
/// subscriber badges offer different versions of the badge depending on how
|
||||
/// long the user has subscribed. To get the badge, use the Get Global Chat
|
||||
/// Badges and Get Channel Chat Badges APIs.Match the badge to the set-id field’s
|
||||
/// value in the response.Then, match the version to the id field in the list of versions.
|
||||
/// Badges and Get Channel Chat Badges APIs. Match the badge to the set-id 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
|
||||
@@ -79,6 +79,26 @@ namespace TwitchLogger.IRC.Messages
|
||||
return value ?? "";
|
||||
}
|
||||
}
|
||||
public IEnumerable<Emote> Emotes
|
||||
{ get
|
||||
{
|
||||
var tag = TryGetTag("emotes");
|
||||
foreach (var emote in tag.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var split = emote.Split(':', 2);
|
||||
Debug.Assert(split.Length == 2);
|
||||
var name = split[0];
|
||||
foreach (var indeces in split[1].Split(','))
|
||||
{
|
||||
var split2 = indeces.Split('-');
|
||||
if (!int.TryParse(split2[0], out int start) ||
|
||||
!int.TryParse(split2[1], out int end))
|
||||
throw new InvalidDataException();
|
||||
yield return new Emote(name, start, end - start + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// An ID that uniquely identifies the message.
|
||||
/// </summary>
|
||||
@@ -109,6 +129,15 @@ namespace TwitchLogger.IRC.Messages
|
||||
return value == "1";
|
||||
}
|
||||
}
|
||||
public DateTime Timestamp
|
||||
{ get
|
||||
{
|
||||
var s = TryGetTag("tmi-sent-ts");
|
||||
if (!double.TryParse(s, out double result))
|
||||
throw new InvalidDataException();
|
||||
return DateTime.UnixEpoch.AddSeconds(result / 1000);
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// A Boolean value that indicates whether the user has site-wide commercial
|
||||
/// free mode enabled
|
||||
@@ -157,6 +186,55 @@ namespace TwitchLogger.IRC.Messages
|
||||
/// A Boolean value that determines whether the user that sent the chat is a VIP.
|
||||
/// </summary>
|
||||
public bool Vip => MessageTags.ContainsKey("vip");
|
||||
/// <summary>
|
||||
/// The level of the Hype Chat. Proper values are 1-10, or 0 if not a Hype Chat.
|
||||
/// </summary>
|
||||
public int HypeChatLevel
|
||||
{ get
|
||||
{
|
||||
var value = TryGetTag("pinned-chat-paid-level");
|
||||
switch (value.ToUpper())
|
||||
{
|
||||
case "ONE":
|
||||
return 1;
|
||||
case "TWO":
|
||||
return 2;
|
||||
case "THREE":
|
||||
return 3;
|
||||
case "FOUR":
|
||||
return 4;
|
||||
case "FIVE":
|
||||
return 5;
|
||||
case "SIX":
|
||||
return 6;
|
||||
case "SEVEN":
|
||||
return 7;
|
||||
case "EIGHT":
|
||||
return 8;
|
||||
case "NINE":
|
||||
return 9;
|
||||
case "TEN":
|
||||
return 10;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// The ISO 4217 alphabetic currency code the user has sent the Hype Chat in.
|
||||
/// </summary>
|
||||
public string HypeChatCurrency => TryGetTag("pinned-chat-paid-currency");
|
||||
public decimal? HypeChatValue
|
||||
{ get
|
||||
{
|
||||
var numeric = TryGetTag("pinned-chat-paid-amount");
|
||||
var exp = TryGetTag("pinned-chat-paid-exponent");
|
||||
if (int.TryParse(numeric, out int d_numeric) && int.TryParse(exp, out int d_exp))
|
||||
return d_numeric / ((decimal)Math.Pow(10, d_exp));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public bool FirstMessage => TryGetTag("first-msg") == "1";
|
||||
public string ChatMessage => Parameters.Last();
|
||||
public Privmsg(ReceivedMessage message) : base(message)
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ using System.Reflection.Metadata.Ecma335;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
@@ -82,36 +82,35 @@ namespace TwitchLogger.IRC.Messages
|
||||
//is stripped
|
||||
if (s.StartsWith(':'))
|
||||
{
|
||||
message.Parameters.Add(s.Substring(1));
|
||||
message.Parameters.Add(s[1..]);
|
||||
}
|
||||
else
|
||||
{
|
||||
var spl_final = s.Split(" :", 2);
|
||||
var spl_initial = spl_final[0].Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var spl_initial = spl_final[0].Split(' ', StringSplitOptions.RemoveEmptyEntries
|
||||
| StringSplitOptions.TrimEntries);
|
||||
message.Parameters.AddRange(spl_initial);
|
||||
if (spl_final.Length >= 2)
|
||||
message.Parameters.Add(spl_final[1]);
|
||||
}
|
||||
}
|
||||
switch (message.MessageType)
|
||||
return message.MessageType switch
|
||||
{
|
||||
case IrcMessageType.PRIVMSG:
|
||||
return new Privmsg(message);
|
||||
case IrcMessageType.CLEARCHAT:
|
||||
return new ClearChat(message);
|
||||
case IrcMessageType.CLEARMSG:
|
||||
return new ClearMsg(message);
|
||||
case IrcMessageType.NOTICE:
|
||||
return new Notice(message);
|
||||
case IrcMessageType.JOIN:
|
||||
return new Join(message);
|
||||
case IrcMessageType.PART:
|
||||
return new Part(message);
|
||||
case IrcMessageType.RPL_NAMREPLY:
|
||||
return new NamReply(message);
|
||||
default:
|
||||
return message;
|
||||
}
|
||||
IrcMessageType.CLEARCHAT => new ClearChat(message),
|
||||
IrcMessageType.CLEARMSG => new ClearMsg(message),
|
||||
IrcMessageType.JOIN => new Join(message),
|
||||
IrcMessageType.GLOBALUSERSTATE => new GlobalUserState(message),
|
||||
IrcMessageType.HOSTTARGET => new HostTarget(message),
|
||||
IrcMessageType.NOTICE => new Notice(message),
|
||||
IrcMessageType.PART => new Part(message),
|
||||
IrcMessageType.PRIVMSG => new Privmsg(message),
|
||||
IrcMessageType.ROOMSTATE => new Roomstate(message),
|
||||
IrcMessageType.RPL_NAMREPLY => new NamReply(message),
|
||||
IrcMessageType.USERNOTICE => new UserNotice(message),
|
||||
IrcMessageType.USERSTATE => new UserState(message),
|
||||
IrcMessageType.WHISPER => new Whisper(message),
|
||||
_ => message,
|
||||
};
|
||||
}
|
||||
/// <summary>
|
||||
/// Tries to get the value of the tag.
|
||||
|
||||
@@ -6,8 +6,8 @@ using System.Linq;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using TwitchLogger.IRC;
|
||||
using TwitchLogger.IRC.Messages;
|
||||
using TwitchIrcClient.IRC;
|
||||
using TwitchIrcClient.IRC.Messages;
|
||||
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
@@ -86,6 +86,10 @@ namespace TwitchIrcClient.IRC.Messages
|
||||
throw new InvalidDataException($"tag \"subs-only\" does not have a proper value: {value}");
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string ChannelName => Parameters.Last().TrimStart('#');
|
||||
public Roomstate(ReceivedMessage other) : base(other)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.ROOMSTATE,
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.WebSockets;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Linq;
|
||||
using static System.Net.Mime.MediaTypeNames;
|
||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class UserNotice : ReceivedMessage
|
||||
{
|
||||
@@ -16,7 +23,7 @@ namespace TwitchLogger.IRC.Messages
|
||||
/// List of chat badges. Most badges have only 1 version, but some badges like
|
||||
/// subscriber badges offer different versions of the badge depending on how
|
||||
/// long the user has subscribed. To get the badge, use the Get Global Chat
|
||||
/// Badges and Get Channel Chat Badges APIs.Match the badge to the set-id 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.
|
||||
/// </summary>
|
||||
public List<Badge> Badges
|
||||
@@ -52,6 +59,8 @@ namespace TwitchLogger.IRC.Messages
|
||||
return System.Drawing.Color.FromArgb(r, g, b);
|
||||
}
|
||||
}
|
||||
public string Channel => Parameters.FirstOrDefault("").TrimStart('#');
|
||||
public string Message => Parameters.ElementAtOrDefault(1) ?? "";
|
||||
/// <summary>
|
||||
/// The user’s display name. This tag may be empty if it is never set.
|
||||
/// </summary>
|
||||
@@ -63,12 +72,20 @@ namespace TwitchLogger.IRC.Messages
|
||||
return value ?? "";
|
||||
}
|
||||
}
|
||||
public IEnumerable<Emote> Emotes
|
||||
{ get
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// An ID that uniquely identifies the message.
|
||||
/// </summary>
|
||||
public string Id => TryGetTag("id");
|
||||
public UserNoticeType? UserNoticeType => Enum.TryParse(TryGetTag("msg-id"), out UserNoticeType type)
|
||||
? type : null;
|
||||
public string Login => TryGetTag("login");
|
||||
/// <summary>
|
||||
/// Whether the user is a moderator in this channel
|
||||
/// </summary>
|
||||
@@ -118,24 +135,160 @@ namespace TwitchLogger.IRC.Messages
|
||||
return value ?? "";
|
||||
}
|
||||
}
|
||||
public UserType UserType
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string SystemMessage => TryGetTag("system-msg");
|
||||
/// <summary>
|
||||
/// When the Twitch IRC server received the message
|
||||
/// </summary>
|
||||
public DateTime Timestamp
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("user-type", out string? value))
|
||||
return UserType.Normal;
|
||||
switch (value)
|
||||
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 "admin":
|
||||
return UserType.Admin;
|
||||
case "global_mod":
|
||||
return UserType.GlobalMod;
|
||||
case "staff":
|
||||
return UserType.Staff;
|
||||
case "PRIME":
|
||||
return SubType.Prime;
|
||||
case "1000":
|
||||
return SubType.T1;
|
||||
case "2000":
|
||||
return SubType.T2;
|
||||
case "3000":
|
||||
return SubType.T3;
|
||||
default:
|
||||
return UserType.Normal;
|
||||
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
|
||||
{ get
|
||||
{
|
||||
var value = TryGetTag("user-type");
|
||||
return value.ToUpper() switch
|
||||
{
|
||||
"ADMIN" => UserType.Admin,
|
||||
"GLOBAL_MOD" => UserType.GlobalMod,
|
||||
"STAFF" => UserType.Staff,
|
||||
"" => UserType.Normal,
|
||||
_ => UserType.Normal,
|
||||
};
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.ritual"/> notices.
|
||||
/// The name of the ritual being celebrated.
|
||||
/// </summary>
|
||||
public RitualType RitualType => Enum.TryParse(TryGetTag("msg-param-ritual-name"),
|
||||
out RitualType rt) ? rt : RitualType.None;
|
||||
//TODO possibly deprecate and add an int version in the future if all tiers are numeric
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.bitsbadgetier"/> notices.
|
||||
/// The tier of the Bits badge the user just earned. For example, 100, 1000, or 10000.
|
||||
/// </summary>
|
||||
public string Threshold => TryGetTag("msg-param-threshold");
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.subgift"/> notices.
|
||||
/// The number of months gifted as part of a single, multi-month gift.
|
||||
/// </summary>
|
||||
public int GiftMonths => int.TryParse(TryGetTag("msg-param-gift-months"),
|
||||
out int value) ? value : 0;
|
||||
public UserNotice(ReceivedMessage message) : base(message)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.USERNOTICE,
|
||||
@@ -145,16 +298,31 @@ namespace TwitchLogger.IRC.Messages
|
||||
}
|
||||
public enum UserNoticeType
|
||||
{
|
||||
sub,
|
||||
resub,
|
||||
subgift,
|
||||
submysterygift,
|
||||
giftpaidupgrade,
|
||||
rewardgift,
|
||||
anongiftpaidupgrade,
|
||||
raid,
|
||||
unraid,
|
||||
ritual,
|
||||
bitsbadgetier,
|
||||
sub = 0,
|
||||
resub = 1,
|
||||
subgift = 2,
|
||||
submysterygift = 3,
|
||||
giftpaidupgrade = 4,
|
||||
rewardgift = 5,
|
||||
anongiftpaidupgrade = 6,
|
||||
raid = 7,
|
||||
unraid = 8,
|
||||
ritual = 9,
|
||||
bitsbadgetier = 10,
|
||||
}
|
||||
public enum RitualType
|
||||
{
|
||||
new_chatter = 0,
|
||||
|
||||
None = int.MinValue,
|
||||
}
|
||||
public enum SubType
|
||||
{
|
||||
Prime = 0,
|
||||
T1 = 1,
|
||||
T2 = 2,
|
||||
T3 = 3,
|
||||
|
||||
None = int.MinValue,
|
||||
}
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC
|
||||
namespace TwitchIrcClient.IRC
|
||||
{
|
||||
/// <summary>
|
||||
/// Prevents sending too many messages in a time period. A single rate limiter can
|
||||
@@ -29,28 +29,35 @@ namespace TwitchLogger.IRC
|
||||
Timer.Start();
|
||||
}
|
||||
|
||||
public void WaitForAvailable(CancellationToken? token = null)
|
||||
public bool WaitForAvailable(CancellationToken? token = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (token is CancellationToken actualToken)
|
||||
Semaphore.Wait(actualToken);
|
||||
else
|
||||
Semaphore.Wait();
|
||||
lock (Semaphore)
|
||||
{
|
||||
if (token is CancellationToken actualToken)
|
||||
Semaphore.Wait(actualToken);
|
||||
else
|
||||
Semaphore.Wait();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
//caller is responsible for checking whether connection is cancelled before trying to send
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public bool WaitForAvailable(TimeSpan timeout, CancellationToken? token = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (token is CancellationToken actualToken)
|
||||
return Semaphore.Wait(timeout, actualToken);
|
||||
else
|
||||
return Semaphore.Wait(timeout);
|
||||
lock (Semaphore)
|
||||
{
|
||||
if (token is CancellationToken actualToken)
|
||||
return Semaphore.Wait(timeout, actualToken);
|
||||
else
|
||||
return Semaphore.Wait(timeout);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -61,10 +68,13 @@ namespace TwitchLogger.IRC
|
||||
{
|
||||
try
|
||||
{
|
||||
if (token is CancellationToken actualToken)
|
||||
return Semaphore.Wait(millis, actualToken);
|
||||
else
|
||||
return Semaphore.Wait(millis);
|
||||
lock (Semaphore)
|
||||
{
|
||||
if (token is CancellationToken actualToken)
|
||||
return Semaphore.Wait(millis, actualToken);
|
||||
else
|
||||
return Semaphore.Wait(millis);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -76,30 +86,29 @@ namespace TwitchLogger.IRC
|
||||
{
|
||||
try
|
||||
{
|
||||
Semaphore.Release(MessageLimit);
|
||||
}
|
||||
catch (SemaphoreFullException)
|
||||
{
|
||||
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
|
||||
lock (Semaphore)
|
||||
{
|
||||
var count = MessageLimit - Semaphore.CurrentCount;
|
||||
if (count > 0)
|
||||
Semaphore.Release(count);
|
||||
}
|
||||
}
|
||||
catch (SemaphoreFullException) { }
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
|
||||
#region RateLimiter Dispose
|
||||
private bool disposedValue;
|
||||
//https://stackoverflow.com/questions/8927878/what-is-the-correct-way-of-adding-thread-safety-to-an-idisposable-object
|
||||
private int _disposedCount;
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!disposedValue)
|
||||
if (Interlocked.Increment(ref _disposedCount) == 1)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
Semaphore?.Dispose();
|
||||
Timer?.Dispose();
|
||||
}
|
||||
disposedValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC
|
||||
namespace TwitchIrcClient.IRC
|
||||
{
|
||||
public enum UserType
|
||||
{
|
||||
|
||||
@@ -1 +1,60 @@
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection.Metadata;
|
||||
using System.Security.AccessControl;
|
||||
using System.Threading.Channels;
|
||||
using TwitchIrcClient.IRC;
|
||||
using TwitchIrcClient.IRC.Messages;
|
||||
|
||||
RateLimiter limiter = new(20, 30);
|
||||
bool ssl = true;
|
||||
async Task<IrcConnection> CreateConnection(string channel)
|
||||
{
|
||||
IrcConnection connection = ssl
|
||||
? connection = new IrcConnection("irc.chat.twitch.tv", 6697, limiter, true, true)
|
||||
: connection = new IrcConnection("irc.chat.twitch.tv", 6667, limiter, true, false);
|
||||
connection.AddCallback(new MessageCallbackItem(
|
||||
(o, m) =>
|
||||
{
|
||||
if (m is Privmsg priv)
|
||||
{
|
||||
if (priv.Bits > 0)
|
||||
lock (Console.Out)
|
||||
Console.WriteLine($"{priv.DisplayName}: {priv.Bits}{Environment.NewLine}");
|
||||
}
|
||||
else
|
||||
throw new ArgumentException("Received an unrequested message type", nameof(m));
|
||||
}, [IrcMessageType.PRIVMSG]));
|
||||
connection.onUserChange += (object? o, UserChangeEventArgs args) =>
|
||||
{
|
||||
lock (Console.Out)
|
||||
{
|
||||
var resetColor = Console.BackgroundColor;
|
||||
Console.BackgroundColor = ConsoleColor.DarkGreen;
|
||||
Console.WriteLine(string.Join(", ", args.Joined.Order()));
|
||||
Console.BackgroundColor = ConsoleColor.DarkRed;
|
||||
Console.WriteLine(string.Join(", ", args.Left.Order()));
|
||||
Console.BackgroundColor = resetColor;
|
||||
Console.WriteLine();
|
||||
}
|
||||
};
|
||||
if (!await connection.ConnectAsync())
|
||||
{
|
||||
Console.WriteLine("failed to connect");
|
||||
Environment.Exit(-1);
|
||||
}
|
||||
connection.Authenticate(null, null);
|
||||
connection.SendLine("CAP REQ :twitch.tv/commands twitch.tv/membership twitch.tv/tags");
|
||||
connection.JoinChannel(channel);
|
||||
return connection;
|
||||
}
|
||||
Console.Write("Channel: ");
|
||||
var channelName = Console.ReadLine();
|
||||
ArgumentNullException.ThrowIfNullOrWhiteSpace(channelName, nameof(channelName));
|
||||
var connection = await CreateConnection(channelName);
|
||||
while (true)
|
||||
{
|
||||
//all the work happens in other threads
|
||||
//specifically the threadpool used by Task.Run for
|
||||
//the tasks owned by the IrcConnection
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
544
TwitchIrcClientTests/ParserTest.cs
Normal file
544
TwitchIrcClientTests/ParserTest.cs
Normal file
@@ -0,0 +1,544 @@
|
||||
using System.Drawing;
|
||||
using System;
|
||||
using TwitchIrcClient.IRC;
|
||||
using TwitchIrcClient.IRC.Messages;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace TwitchIrcClientTests
|
||||
{
|
||||
[TestClass]
|
||||
public class ParserTest
|
||||
{
|
||||
[TestMethod]
|
||||
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 = ReceivedMessage.Parse(ROOMSTATE);
|
||||
Assert.AreEqual(IrcMessageType.ROOMSTATE, _roomstate.MessageType);
|
||||
if (_roomstate is Roomstate roomstate)
|
||||
{
|
||||
Assert.AreEqual("channelname", roomstate.ChannelName);
|
||||
Assert.IsTrue(roomstate.MessageTags.TryGetValue("emote-only", out string emoteOnly));
|
||||
Assert.AreEqual("0", emoteOnly);
|
||||
Assert.IsFalse(roomstate.EmoteOnly);
|
||||
Assert.IsTrue(roomstate.MessageTags.TryGetValue("followers-only", out string followersOnly));
|
||||
Assert.AreEqual("-1", followersOnly);
|
||||
Assert.AreEqual(-1, roomstate.FollowersOnly);
|
||||
Assert.IsTrue(roomstate.MessageTags.TryGetValue("r9k", out string r9k));
|
||||
Assert.AreEqual("0", r9k);
|
||||
Assert.IsFalse(roomstate.UniqueMode);
|
||||
Assert.IsTrue(roomstate.MessageTags.TryGetValue("room-id", out string roomId));
|
||||
Assert.AreEqual("321654987", roomId);
|
||||
Assert.AreEqual("321654987", roomstate.RoomId);
|
||||
Assert.IsTrue(roomstate.MessageTags.TryGetValue("slow", out string slow));
|
||||
Assert.AreEqual("0", slow);
|
||||
Assert.AreEqual(0, roomstate.Slow);
|
||||
Assert.IsTrue(roomstate.MessageTags.TryGetValue("subs-only", out string subsOnly));
|
||||
Assert.AreEqual("0", subsOnly);
|
||||
Assert.AreEqual(false, roomstate.SubsOnly);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
[TestMethod]
|
||||
public void TestNamreply()
|
||||
{
|
||||
var NAMREPLY = ":justinfan7550.tmi.twitch.tv 353 justinfan7550 = #channelname :user1 user2 user3 user4 user5";
|
||||
var _namReply = ReceivedMessage.Parse(NAMREPLY);
|
||||
Assert.AreEqual(IrcMessageType.RPL_NAMREPLY, _namReply.MessageType);
|
||||
if (_namReply is NamReply namReply)
|
||||
{
|
||||
Assert.AreEqual("channelname", namReply.ChannelName);
|
||||
Assert.IsTrue("user1 user2 user3 user4 user5".Split().Order()
|
||||
.SequenceEqual(namReply.Users.Order()));
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
[TestMethod]
|
||||
public void TestJoin()
|
||||
{
|
||||
var JOIN = ":newuser!newuser@newuser.tmi.twitch.tv JOIN #channelname";
|
||||
var _join = ReceivedMessage.Parse(JOIN);
|
||||
Assert.AreEqual(IrcMessageType.JOIN, _join.MessageType);
|
||||
if (_join is Join join)
|
||||
{
|
||||
Assert.AreEqual("channelname", join.ChannelName);
|
||||
Assert.AreEqual("newuser", join.Username);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
[TestMethod]
|
||||
public void TestPart()
|
||||
{
|
||||
var PART = ":leavinguser!leavinguser@leavinguser.tmi.twitch.tv PART #channelname";
|
||||
var _part = ReceivedMessage.Parse(PART);
|
||||
Assert.AreEqual(IrcMessageType.PART, _part.MessageType);
|
||||
if (_part is Part part)
|
||||
{
|
||||
Assert.AreEqual("channelname", part.ChannelName);
|
||||
Assert.AreEqual("leavinguser", part.Username);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
[TestMethod]
|
||||
public void TestPrivmsg()
|
||||
{
|
||||
var PRIVMSG = "@badge-info=subscriber/1;badges=subscriber/0;client-nonce=202e32113a3768963eded865e051fc5b;color=#AAAAFF;" +
|
||||
"display-name=ChattingUser;emotes=;first-msg=0;flags=;id=24fe75a1-06a5-4078-a31f-cf615107b2a2;mod=0;returning-chatter=0;" +
|
||||
"room-id=321654987;subscriber=1;tmi-sent-ts=1710920497332;turbo=0;user-id=01234567;user-type= " +
|
||||
":chattinguser!chattinguser@chattinguser.tmi.twitch.tv PRIVMSG #channelname :This is a test chat message";
|
||||
var CHEER = "@badge-info=subscriber/9;badges=subscriber/9,twitch-recap-2023/1;bits=100;color=#FF0000;display-name=CheeringUser;" +
|
||||
//I haven't fixed this emote tag after rewriting the message
|
||||
"emotes=emotesv2_44a39d65e08f43adac871a80e9b96d85:17-24;first-msg=1;flags=;id=5eab1319-5d46-4c55-be29-33c2f834e42e;mod=0;" +
|
||||
"returning-chatter=0;room-id=321654987;subscriber=0;tmi-sent-ts=1710920826069;turbo=1;user-id=012345678;user-type=;vip " +
|
||||
":cheeringuser!cheeringuser@cheeringuser.tmi.twitch.tv PRIVMSG #channelname :This includes a cheer Cheer100";
|
||||
var ESCAPE = @"@escaped=\:\s\\\r\n\a\b\c PRIVMSG #channelname :message";
|
||||
var EMOTES = @"@badge-info=subscriber/4;badges=subscriber/3;client-nonce=2cc8bb73f5d946b22ec2905c8ccdee7a;color=#1E90FF;" +
|
||||
@"display-name=Ikatono;emote-only=1;emotes=emotesv2_4f3ee26e385b46aa88d5f45307489939:0-12,14-26/emotesv2_9046ad54f76f42389edb4cc828b1b057" +
|
||||
@":28-35,37-44;first-msg=0;flags=;id=08424675-217f-44bc-b9c0-24e2e2dd5f33;mod=0;returning-chatter=0;room-id=230151386;" +
|
||||
@"subscriber=1;tmi-sent-ts=1711136008625;turbo=0;user-id=24866530;user-type= :ikatono!ikatono@ikatono.tmi.twitch.tv " +
|
||||
@"PRIVMSG #bajiru_en :bajiBUFFERING bajiBUFFERING bajiBONK bajiBONK";
|
||||
|
||||
var _priv = ReceivedMessage.Parse(PRIVMSG);
|
||||
Assert.AreEqual(IrcMessageType.PRIVMSG, _priv.MessageType);
|
||||
if (_priv is Privmsg priv)
|
||||
{
|
||||
Assert.AreEqual("This is a test chat message", priv.ChatMessage);
|
||||
Assert.AreEqual(0, priv.Bits);
|
||||
Assert.AreEqual("ChattingUser", priv.DisplayName);
|
||||
Assert.AreEqual(Color.FromArgb(170, 170, 255), priv.Color);
|
||||
Assert.AreEqual("24fe75a1-06a5-4078-a31f-cf615107b2a2", priv.Id);
|
||||
Assert.IsFalse(priv.FirstMessage);
|
||||
Assert.IsFalse(priv.Moderator);
|
||||
Assert.AreEqual("321654987", priv.RoomId);
|
||||
Assert.IsTrue(priv.Subscriber);
|
||||
Assert.IsFalse(priv.Turbo);
|
||||
Assert.AreEqual("01234567", priv.UserId);
|
||||
Assert.AreEqual(UserType.Normal, priv.UserType);
|
||||
Assert.IsFalse(priv.Vip);
|
||||
Assert.AreEqual(new DateTime(2024, 3, 20, 7, 41, 37, 332, DateTimeKind.Utc), priv.Timestamp);
|
||||
Assert.IsTrue(priv.Badges.SequenceEqual([new Badge("subscriber", "0")]));
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
|
||||
var _cheer = ReceivedMessage.Parse(CHEER);
|
||||
Assert.AreEqual(IrcMessageType.PRIVMSG, _cheer.MessageType);
|
||||
if (_cheer is Privmsg cheer)
|
||||
{
|
||||
Assert.AreEqual("This includes a cheer Cheer100", cheer.ChatMessage);
|
||||
Assert.AreEqual(100, cheer.Bits);
|
||||
Assert.AreEqual("CheeringUser", cheer.DisplayName);
|
||||
Assert.AreEqual(Color.FromArgb(255, 0, 0), cheer.Color);
|
||||
Assert.AreEqual("5eab1319-5d46-4c55-be29-33c2f834e42e", cheer.Id);
|
||||
Assert.IsTrue(cheer.FirstMessage);
|
||||
Assert.IsFalse(cheer.Moderator);
|
||||
Assert.AreEqual("321654987", cheer.RoomId);
|
||||
Assert.IsFalse(cheer.Subscriber);
|
||||
Assert.IsTrue(cheer.Turbo);
|
||||
Assert.AreEqual("012345678", cheer.UserId);
|
||||
Assert.AreEqual(UserType.Normal, cheer.UserType);
|
||||
Assert.IsTrue(cheer.Vip);
|
||||
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
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
TwitchIrcClientTests/TwitchIrcClientTests.csproj
Normal file
27
TwitchIrcClientTests/TwitchIrcClientTests.csproj
Normal file
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="3.1.1" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TwitchIrcClient\TwitchIrcClient.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user