Compare commits

...

6 Commits

46 changed files with 2693 additions and 388 deletions

View File

@@ -0,0 +1,145 @@
using Microsoft.VisualBasic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http.Json;
using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using TwitchIrcClient.ApiClient.Messages;
using TwitchIrcClient.Authentication;
using TwitchIrcClient.EventSub;
namespace TwitchIrcClient.ApiClient
{
public class ApiClient : IDisposable
{
private readonly HttpClient Client = new();
private readonly CancellationTokenSource TokenSource = new();
public CancellationToken CancellationToken => TokenSource.Token;
public string? ClientId { get; set; }
public async Task<EventSubResponse?> CreateWebsocketSubscriptionAsync(EventSubWebsocketClient eswClient,
UserAccessAuthentication auth, string type, string version,
IDictionary<string, string> condition)
{
var req = new EventSubRequest(type, version, condition,
ApiTransport.MakeForWebsocket(
eswClient.SessionId ?? throw new InvalidOperationException(
"no session id, did websocket connection fail?")));
using var content = JsonContent.Create(req);
content.Headers.Add("Authorization", $"Bearer: {auth.Token}");
content.Headers.Add("Client-Id", auth.ClientId);
using var resp = await Client.PostAsync("https://api.twitch.tv/helix/eventsub/subscriptions",
content, CancellationToken);
if (!resp.IsSuccessStatusCode)
return null;
var j_resp = JsonSerializer.Deserialize<EventSubResponse>(await resp.Content.ReadAsStringAsync());
if (j_resp is null)
return null;
eswClient.TotalCost = j_resp.TotalCost;
eswClient.TotalSubscriptions = j_resp.Total;
eswClient.MaximumCost = j_resp.MaxTotalCost;
return j_resp;
}
public async Task<bool> DeleteWebSocketSubscriptionAsync(UserAccessAuthentication auth, string id)
{
using var req = new HttpRequestMessage();
req.RequestUri = new Uri($"https://api.twitch.tv/helix/eventsub/subscriptions?id={id}");
req.Headers.Add("Authorization", $"Bearer: {auth.Token}");
req.Headers.Add("Client-Id", auth.ClientId);
var resp = await Client.SendAsync(req);
return resp.IsSuccessStatusCode;
}
public async IAsyncEnumerable<EventSubResponseItem> GetWebsocketSubscriptionsAsync(
UserAccessAuthentication auth, EventSubSubscriptionStatus? status = null,
string? type = null, string? userId = null)
{
var attrs = new Dictionary<string, string>();
if (status is EventSubSubscriptionStatus _status)
attrs["status"] = EventSubSubscriptionStatusConverter.Convert(_status);
if (type is string _type)
attrs["type"] = _type;
if (userId is string _userId)
attrs["status"] = _userId;
if (attrs.Count >= 2)
throw new ArgumentException("cannot set more that 1 filter parameter");
while (true)
{
using var req = new HttpRequestMessage();
req.RequestUri = new Uri("https://api.twitch.tv/helix/eventsub/subscriptions?"
+ string.Join(',', attrs.Select(p => $"{p.Key}={p.Value}")));
req.Headers.Add("Authorization", $"Bearer {auth.Token}");
req.Headers.Add("Client-Id", auth.ClientId);
using var resp = await Client.SendAsync(req, CancellationToken);
if (CancellationToken.IsCancellationRequested || resp is null)
yield break;
if (!resp.IsSuccessStatusCode)
yield break;
var esslr = JsonSerializer.Deserialize<EventSubSubscriptionListResponse>(
await resp.Content.ReadAsStringAsync());
if (esslr is null)
yield break;
foreach (var item in esslr.Data)
yield return item;
if (esslr.Pagination is EventSubSubscriptionListResponsePagination pagination)
attrs["cursor"] = pagination.Cursor;
else
yield break;
}
}
public async Task<DcfCodeMessage?> GetDcfTokenAsync(string clientId,
IEnumerable<string> scopes, AuthorizationCallback callback)
{
ArgumentNullException.ThrowIfNull(callback, nameof(callback));
ArgumentNullException.ThrowIfNullOrWhiteSpace(clientId, nameof(clientId));
using var req = new HttpRequestMessage();
req.Content = new FormUrlEncodedContent([
new ("client_id", clientId),
new ("scopes", string.Join(' ', scopes)),
]);
req.RequestUri = new Uri("https://id.twitch.tv/oauth2/device");
using var resp = await Client.SendAsync(req, CancellationToken);
if (resp is null)
return null;
var dcm = JsonSerializer.Deserialize<DcfCodeMessage>(await resp.Content.ReadAsStringAsync());
if (dcm is null)
return null;
using TcpListener listener = new();
var auth_task = callback.Invoke(dcm.VerificationUri);
if (auth_task is null)
return null;
var success = await auth_task;
}
#region IDisposable
private bool disposedValue;
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
TokenSource.Cancel();
if (disposing)
{
Client.Dispose();
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
#endregion //IDisposable
}
/// <summary>
/// Callback for user to authorize the app
/// </summary>
/// <param name="url"></param>
/// <returns>true if successful</returns>
public delegate Task<bool> AuthorizationCallback(string url);
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using TwitchIrcClient.EventSub.Messages;
namespace TwitchIrcClient.ApiClient.Messages
{
public record class ApiTransport
{
[JsonRequired]
[JsonConverter(typeof(TwitchTransportTypeConverter))]
[JsonPropertyName("method")]
public TwitchTransportType Method { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("callback")]
public string? Callback { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("secret")]
public string? Secret { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("session_id")]
public string? SessionId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("conduit_id")]
public string? ConduitId { get; set; }
[JsonConstructor]
public ApiTransport(TwitchTransportType method)
{
Method = method;
}
public static ApiTransport MakeForWebhook(string callback, string secret)
=> new(TwitchTransportType.Webhook)
{
Callback = callback,
Secret = secret,
};
public static ApiTransport MakeForWebsocket(string sessionId)
=> new(TwitchTransportType.Websocket)
{
SessionId = sessionId,
};
public static ApiTransport MakeForConduit(string conduitId)
=> new(TwitchTransportType.Conduit)
{
ConduitId = conduitId,
};
}
}

View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using TwitchIrcClient.EventSub.Messages;
namespace TwitchIrcClient.ApiClient.Messages
{
public record class EventSubQueryRequest
{
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(EventSubSubscriptionStatusConverter))]
[JsonPropertyName("status")]
public EventSubSubscriptionStatus? Status { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("type")]
public string? Type { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("user_id")]
public string? UserId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("after")]
public string? After { get; set; }
}
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace TwitchIrcClient.ApiClient.Messages
{
public record class EventSubRequest
{
[JsonRequired]
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonRequired]
[JsonPropertyName("version")]
public string Version { get; set; }
[JsonRequired]
[JsonPropertyName("condition")]
public Dictionary<string, string> Condition;
[JsonRequired]
[JsonPropertyName("transport")]
public ApiTransport Transport { get; set; }
[JsonConstructor]
public EventSubRequest(string type, string version,
IEnumerable<KeyValuePair<string, string>> condition,
ApiTransport transport)
{
Type = type;
Version = version;
Condition = condition.ToDictionary();
Transport = transport;
}
}
}

View File

@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace TwitchIrcClient.ApiClient.Messages
{
public record class EventSubResponseItem
{
[JsonRequired]
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonRequired]
[JsonPropertyName("status")]
public string Status { get; set; }
[JsonRequired]
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonRequired]
[JsonPropertyName("version")]
public string Version { get; set; }
[JsonRequired]
[JsonPropertyName("condition")]
public Dictionary<string, string> Condition { get; set; }
[JsonRequired]
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
[JsonRequired]
[JsonPropertyName("transport")]
public Dictionary<string, string> Transport { get; set; }
[JsonRequired]
[JsonPropertyName("cost")]
public int Cost { get; set; }
}
public record class EventSubResponse
{
[JsonRequired]
[JsonPropertyName("data")]
public EventSubResponseItem[] Data { get; set; }
[JsonRequired]
[JsonPropertyName("total")]
public int Total { get; set; }
[JsonRequired]
[JsonPropertyName("total_cost")]
public int TotalCost { get; set; }
[JsonRequired]
[JsonPropertyName("max_total_cost")]
public int MaxTotalCost { get; set; }
}
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace TwitchIrcClient.ApiClient.Messages
{
public record class EventSubSubscriptionListResponsePagination
{
[JsonRequired]
[JsonPropertyName("cursor")]
public string Cursor { get; set; }
}
public record class EventSubSubscriptionListResponse
{
[JsonRequired]
[JsonPropertyName("data")]
public EventSubResponseItem[] Data { get; set; }
[JsonRequired]
[JsonPropertyName("total")]
public int Total { get; set; }
[JsonRequired]
[JsonPropertyName("total_cost")]
public int TotalCost { get; set; }
[JsonRequired]
[JsonPropertyName("max_total_cost")]
public int MaxTotalCost { get; set; }
[JsonPropertyName("pagination")]
public EventSubSubscriptionListResponsePagination? Pagination { get; set; }
}
}

View File

@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace TwitchIrcClient.ApiClient.Messages
{
public enum EventSubSubscriptionStatus
{
Enabled = 0,
WebhookCallbackVerificationPending = 1,
WebhookCallbackVerificationFailed = 2,
NotificationFailuresExceeded = 3,
AuthorizationRevoked = 4,
ModeratorRemoved = 5,
UserRemoved = 6,
VersionRemoved = 7,
BetaMaintenance = 8,
WebsocketDisconnected = 9,
WebsocketFailedPingPong = 10,
WebsocketReceivedInboundTraffic = 11,
WebsocketConnectionUnused = 12,
WebsocketInternalError = 13,
WebsocketNetworkTimeout = 14,
WebsocketNetworkError = 15,
}
public class EventSubSubscriptionStatusConverter : JsonConverter<EventSubSubscriptionStatus>
{
private static readonly IList<KeyValuePair<EventSubSubscriptionStatus, string>> ConversionList =
[
new (EventSubSubscriptionStatus.Enabled, "enabled"),
new (EventSubSubscriptionStatus.WebhookCallbackVerificationPending, "webhook_callback_verification_pending"),
new (EventSubSubscriptionStatus.WebhookCallbackVerificationFailed, "webhook_callback_verification_failed"),
new (EventSubSubscriptionStatus.NotificationFailuresExceeded, "notification_failures_exceeded"),
new (EventSubSubscriptionStatus.AuthorizationRevoked, "authorization_revoked"),
new (EventSubSubscriptionStatus.ModeratorRemoved, "moderator_removed"),
new (EventSubSubscriptionStatus.UserRemoved, "user_removed"),
new (EventSubSubscriptionStatus.VersionRemoved, "version_removed"),
new (EventSubSubscriptionStatus.BetaMaintenance, "beta_maintenance"),
new (EventSubSubscriptionStatus.WebsocketDisconnected, "websocket_disconnected"),
new (EventSubSubscriptionStatus.WebsocketFailedPingPong, "websocket_failed_ping_pong"),
new (EventSubSubscriptionStatus.WebsocketReceivedInboundTraffic, "websocket_received_inbound_traffic"),
new (EventSubSubscriptionStatus.WebsocketConnectionUnused, "websocket_connection_unused"),
new (EventSubSubscriptionStatus.WebsocketInternalError, "websocket_internal_error"),
new (EventSubSubscriptionStatus.WebsocketNetworkTimeout, "websocket_network_timeout"),
new (EventSubSubscriptionStatus.WebsocketNetworkError, "websocket_network_error"),
];
private static readonly IList<KeyValuePair<string, EventSubSubscriptionStatus>> InverseConversionList =
ConversionList.Select<KeyValuePair<EventSubSubscriptionStatus, string>,
KeyValuePair<string, EventSubSubscriptionStatus>>(p => new(p.Value, p.Key)).ToList();
public static string Convert(EventSubSubscriptionStatus status)
=> ConversionList.First(p => p.Key == status).Value;
public static EventSubSubscriptionStatus Convert(string status)
=> InverseConversionList.First(p => p.Key == status).Value;
public override EventSubSubscriptionStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> Convert(reader.GetString());
public override void Write(Utf8JsonWriter writer, EventSubSubscriptionStatus value, JsonSerializerOptions options)
=> writer.WriteStringValue(Convert(value));
}
}

View File

@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace TwitchIrcClient.Authentication
{
public record class DcfCodeMessage
{
[JsonRequired]
[JsonPropertyName("device_code")]
public string DeviceCode { get; set; }
[JsonRequired]
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
[JsonRequired]
[JsonPropertyName("interval")]
public int Interval { get; set; }
[JsonRequired]
[JsonPropertyName("user_code")]
public string UserCode { get; set; }
[JsonRequired]
[JsonPropertyName("verification_uri")]
public string VerificationUri { get; set; }
}
}

View File

@@ -0,0 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace TwitchIrcClient.Authentication
{
public record class UserAccessAuthentication(string Token, string ClientId);
}

View File

@@ -0,0 +1,134 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Net.WebSockets;
using System.Text;
using System.Threading.Tasks;
using TwitchIrcClient.IRC.Messages;
using TwitchIrcClient.IRC;
using TwitchIrcClient.EventSub.Messages;
namespace TwitchIrcClient.EventSub
{
public class EventSubWebsocketClient : IDisposable
{
private readonly ClientWebSocket Socket = new();
private readonly CancellationTokenSource TokenSource = new();
public CancellationToken CancelToken => TokenSource.Token;
public readonly HttpClient Client = new();
public string? SessionId { get; private set; }
public string? ReconnectUrl { get; private set; }
public int? TotalSubscriptions { get; internal set; }
public int? TotalCost { get; internal set; }
public int? MaximumCost { get; internal set; }
public async Task<bool> ConnectAsync()
{
const string url = "wss://eventsub.wss.twitch.tv/ws?keepalive_timeout_seconds=600";
await Socket.ConnectAsync(new Uri(url), CancelToken);
if (CancelToken.IsCancellationRequested)
return false;
if (Socket.State != WebSocketState.Open)
return false;
System_ReceivedWelcome += (sender, e) =>
{
if (sender is EventSubWebsocketClient esc)
{
esc.SessionId = e.Welcome.Payload.Session.Id;
esc.ReconnectUrl = e.Welcome.Payload.Session.ReconnectUrl;
}
};
_ = Task.Run(HandleIncomingMessages, CancelToken);
return true;
}
private async Task HandleIncomingMessages()
{
var arr = new byte[8 * 1024];
var buffer = new List<byte>();
Task? prevTask = null;
while (true)
{
var resp = await Socket.ReceiveAsync(arr, CancelToken);
if (CancelToken.IsCancellationRequested)
return;
buffer.AddRange(arr.Take(resp.Count));
if (resp.EndOfMessage)
{
var str = Encoding.UTF8.GetString(buffer.ToArray());
//events get their own task so future messages aren't delayed
//use ContinueWith to preserve otder of incoming messages
prevTask = prevTask?.IsCompleted ?? true
? Task.Run(() => DoIncomingMessage(str), CancelToken)
: prevTask.ContinueWith((_, _) => DoIncomingMessage(str), CancelToken);
buffer.Clear();
}
}
}
private event EventHandler<EventSubKeepaliveEventArgs>? System_ReceivedKeepalive;
private event EventHandler<EventSubNotificationEventArgs>? System_ReceivedNotification;
private event EventHandler<EventSubReconnectEventArgs>? System_ReceivedReconnect;
private event EventHandler<EventSubRevocationEventArgs>? System_ReceivedRevocation;
private event EventHandler<EventSubWelcomeEventArgs>? System_ReceivedWelcome;
public event EventHandler<EventSubKeepaliveEventArgs>? ReceivedKeepalive;
public event EventHandler<EventSubNotificationEventArgs>? ReceivedNotification;
public event EventHandler<EventSubReconnectEventArgs>? ReceivedReconnect;
public event EventHandler<EventSubRevocationEventArgs>? ReceivedRevocation;
public event EventHandler<EventSubWelcomeEventArgs>? ReceivedWelcome;
private void DoIncomingMessage(string message)
{
var esm = EventSubMessage.Parse(message);
switch (esm)
{
case EventSubKeepalive keepalive:
System_ReceivedKeepalive?.Invoke(this, new EventSubKeepaliveEventArgs(keepalive));
ReceivedKeepalive?.Invoke(this, new EventSubKeepaliveEventArgs(keepalive));
break;
case EventSubNotification notification:
System_ReceivedNotification?.Invoke(this, new EventSubNotificationEventArgs(notification));
ReceivedNotification?.Invoke(this, new EventSubNotificationEventArgs(notification));
break;
case EventSubReconnect reconnect:
System_ReceivedReconnect?.Invoke(this, new EventSubReconnectEventArgs(reconnect));
ReceivedReconnect?.Invoke(this, new EventSubReconnectEventArgs(reconnect));
break;
case EventSubRevocation revocation:
System_ReceivedRevocation?.Invoke(this, new EventSubRevocationEventArgs(revocation));
ReceivedRevocation?.Invoke(this, new EventSubRevocationEventArgs(revocation));
break;
case EventSubWelcome welcome:
System_ReceivedWelcome?.Invoke(this, new EventSubWelcomeEventArgs(welcome));
ReceivedWelcome?.Invoke(this, new EventSubWelcomeEventArgs(welcome));
break;
default:
throw new InvalidDataException("invalid message type");
}
}
#region dispose
private bool disposedValue;
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
TokenSource.Cancel();
if (disposing)
{
Socket?.Dispose();
Client?.Dispose();
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
#endregion //dispose
}
}

View File

@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace TwitchIrcClient.EventSub.Messages
{
public record class Emote
{
[JsonRequired]
[JsonPropertyName("begin")]
public int Begin { get; set; }
[JsonRequired]
[JsonPropertyName("end")]
public int End { get; set; }
[JsonRequired]
[JsonPropertyName("id")]
public string Id { get; set; }
public Emote(int begin, int end, string id)
{
Begin = begin;
End = end;
Id = id;
}
}
}

View File

@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace TwitchIrcClient.EventSub.Messages
{
public class EventSubKeepalive : EventSubMessage
{
public override EventSubMessageType MessageType => EventSubMessageType.Keepalive;
[JsonRequired]
[JsonPropertyName("metadata")]
public EventSubMessageBaseMetadata Metadata { get; set; }
[JsonRequired]
[JsonPropertyName("payload")]
public JsonObject Payload { get; set; }
}
}

View File

@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Threading.Tasks;
namespace TwitchIrcClient.EventSub.Messages
{
public class EventSubMessageBaseMetadata
{
[JsonRequired]
[JsonPropertyName("message_id")]
public string MessageId { get; set; }
[JsonRequired]
[JsonPropertyName("message_type")]
public string MessageType { get; set; }
[JsonRequired]
[JsonPropertyName("message_timestamp")]
public DateTime MessageTime { get; set; }
}
public abstract class EventSubMessage
{
[JsonIgnore]
public abstract EventSubMessageType MessageType { get; }
public static EventSubMessage Parse(string json)
{
var node = JsonNode.Parse(json);
if (!(node?["metadata"]?["message_type"]?.AsValue().TryGetValue(out string? value) ?? false))
throw new ArgumentException("invalid json", nameof(json));
return value switch
{
"session_welcome" => JsonSerializer.Deserialize<EventSubWelcome>(node),
"session_keepalive" => JsonSerializer.Deserialize<EventSubKeepalive>(node),
"notification" => JsonSerializer.Deserialize<EventSubNotification>(node),
"session_reconnect" => JsonSerializer.Deserialize<EventSubReconnect>(node),
"revocation" => JsonSerializer.Deserialize<EventSubRevocation>(node),
_ => (EventSubMessage?)null,
} ?? throw new ArgumentException("invalid json", nameof(json));
}
}
public class EventSubNotificationEventArgs(EventSubNotification notification) : EventArgs
{
public EventSubNotification Notification = notification;
}
public class EventSubKeepaliveEventArgs(EventSubKeepalive keepalive) : EventArgs
{
public EventSubKeepalive Keepalive = keepalive;
}
public class EventSubReconnectEventArgs(EventSubReconnect reconnect) : EventArgs
{
public EventSubReconnect Reconnect = reconnect;
}
public class EventSubRevocationEventArgs(EventSubRevocation revocation) : EventArgs
{
public EventSubRevocation Revocation = revocation;
}
public class EventSubWelcomeEventArgs(EventSubWelcome welcome) : EventArgs
{
public EventSubWelcome Welcome = welcome;
}
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace TwitchIrcClient.EventSub.Messages
{
public enum EventSubMessageType
{
Welcome = 0,
Keepalive = 1,
Notification = 2,
Reconnect = 3,
Revocation = 4,
}
internal static class EventSubMessageTypeHelper
{
public static EventSubMessageType Parse(string s)
{
ArgumentException.ThrowIfNullOrWhiteSpace(s);
return s.ToLower() switch
{
"session_welcome" => EventSubMessageType.Welcome,
"session_keepalive" => EventSubMessageType.Keepalive,
"notification" => EventSubMessageType.Notification,
"session_reconnect" => EventSubMessageType.Reconnect,
"revocation" => EventSubMessageType.Revocation,
_ => throw new ArgumentException("invalid string", nameof(s)),
};
}
}
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace TwitchIrcClient.EventSub.Messages
{
public class EventSubNotificationMetadata : EventSubMessageBaseMetadata
{
[JsonRequired]
[JsonPropertyName("subscription_type")]
public string SubscriptionType { get; set; }
[JsonRequired]
[JsonPropertyName("subscription_version")]
public string SubscriptionVersion { get; set; }
}
public class EventSubNotificationTransport
{
[JsonRequired]
[JsonPropertyName("method")]
public string Method { get; set; }
[JsonRequired]
[JsonPropertyName("session_id")]
public string SessionId { get; set; }
}
public class EventSubNotificationSubscription
{
[JsonRequired]
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonRequired]
[JsonPropertyName("status")]
public string Status { get; set; }
[JsonRequired]
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonRequired]
[JsonPropertyName("version")]
public string Version { get; set; }
[JsonRequired]
[JsonPropertyName("cost")]
public int Cost { get; set; }
[JsonRequired]
[JsonPropertyName("condition")]
public object Condition { get; set; }
[JsonRequired]
[JsonPropertyName("transport")]
public EventSubNotificationTransport Transport { get; set; }
[JsonRequired]
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
}
public class EventSubNotificationPayload
{
[JsonRequired]
[JsonPropertyName("subscription")]
public EventSubNotificationSubscription Subscription { get; set; }
[JsonRequired]
[JsonPropertyName("event")]
public JsonObject Event { get; set; }
}
public class EventSubNotification : EventSubMessage
{
[JsonIgnore]
public override EventSubMessageType MessageType => EventSubMessageType.Notification;
[JsonRequired]
[JsonPropertyName("metadata")]
public EventSubNotificationMetadata Metadata { get; set; }
[JsonRequired]
[JsonPropertyName("payload")]
public EventSubNotificationPayload Payload { get; set; }
}
}

View File

@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using TwitchIrcClient.IRC.Messages;
using TwitchIrcClient.IRC;
namespace TwitchIrcClient.EventSub.Messages
{
public delegate void EventSubMessageCallback(EventSubWebsocketClient origin, EventSubNotification message);
public readonly record struct EventSubNotificationCallbackItem(
EventSubMessageCallback Callback,
IReadOnlyCollection<string>? CallbackTypes)
{
public bool TryCall(EventSubWebsocketClient origin, EventSubNotification message)
{
if (CallbackTypes?.Contains(message.Metadata.MessageType) ?? true)
{
Callback(origin, message);
return true;
}
return false;
}
}
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace TwitchIrcClient.EventSub.Messages
{
public class EventSubReconnectPayloadSession
{
[JsonRequired]
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonRequired]
[JsonPropertyName("status")]
public string Status { get; set; }
[JsonRequired]
[JsonPropertyName("keepalive_timeout_seconds")]
public int? KeepaliveTimeoutSeconds { get; set; }
[JsonRequired]
[JsonPropertyName("reconnect_url")]
public string ReconnectUrl { get; set; }
[JsonRequired]
[JsonPropertyName("connected_at")]
public DateTime ConnectedAt { get; set; }
}
public class EventSubReconnectPayload
{
[JsonRequired]
[JsonPropertyName("session")]
public EventSubReconnectPayloadSession Session { get; set; }
}
public class EventSubReconnect : EventSubMessage
{
public override EventSubMessageType MessageType => EventSubMessageType.Reconnect;
[JsonRequired]
[JsonPropertyName("metadata")]
public EventSubMessageBaseMetadata Metadata { get; set; }
[JsonRequired]
[JsonPropertyName("payload")]
public EventSubReconnectPayload Payload { get; set; }
}
}

View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace TwitchIrcClient.EventSub.Messages
{
public class EventSubRevocationPayload
{
[JsonRequired]
[JsonPropertyName("subscription")]
public EventSubNotificationSubscription Subscription { get; set; }
}
public class EventSubRevocation : EventSubMessage
{
public override EventSubMessageType MessageType => EventSubMessageType.Revocation;
[JsonRequired]
[JsonPropertyName("metadata")]
public EventSubNotificationMetadata Metadata { get; set; }
[JsonRequired]
[JsonPropertyName("payload")]
public EventSubRevocationPayload Payload { get; set; }
}
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace TwitchIrcClient.EventSub.Messages
{
//this will fail for "Channel Moderate Event" because of the complicated "Condition" field
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public record class EventSubSubscriptionRequest
{
[JsonRequired]
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonRequired]
[JsonPropertyName("version")]
public string Version { get; set; }
[JsonRequired]
[JsonPropertyName("condition")]
public Dictionary<string,string> Condition { get; set; }
[JsonRequired]
[JsonPropertyName("transport")]
public Dictionary<string, string> Transport { get; set; }
public EventSubSubscriptionRequest(string type, string version,
Dictionary<string, string> condition,
Dictionary<string, string> transport)
{
Type = type;
Version = version;
Condition = condition;
Transport = transport;
}
}
}

View File

@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace TwitchIrcClient.EventSub.Messages
{
public record class EventSubTransport
{
/// <summary>
/// The transport method. Possible values are:
/// webhook
/// websocket
/// </summary>
[JsonRequired]
[JsonConverter(typeof(TwitchTransportTypeConverter))]
[JsonPropertyName("method")]
public TwitchTransportType Method { get; set; }
/// <summary>
/// The callback URL where the notifications are sent. The URL must use the HTTPS protocol and port 443.
/// See <see href="https://dev.twitch.tv/docs/eventsub/handling-webhook-events#processing-an-event"/>.
/// Specify this field only if <see cref="Method"/> is set to <see cref="TwitchTransportType.Webhook"/>.
/// NOTE: Redirects are not followed.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("callback")]
public string? Callback { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("secret")]
public string? Secret { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("session_id")]
public string? SessionId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("connected_at")]
public DateTime? ConnectedAt { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("disconnected_at")]
public DateTime? DisconnectedAt { get; set; }
public EventSubTransport()
{
}
private EventSubTransport(TwitchTransportType method,
string? callback, string? secret, string? sessionId,
DateTime? connectedAt, DateTime? disconnectedAt)
{
Method = method;
Callback = callback;
Secret = secret;
SessionId = sessionId;
ConnectedAt = connectedAt;
DisconnectedAt = disconnectedAt;
}
public static EventSubTransport MakeWebhook(string callback, string secret)
=> new EventSubTransport(TwitchTransportType.Webhook,
callback, secret, null, null, null);
public static EventSubTransport MakeWebsocket(string sessionId, DateTime? connectedAt,
DateTime? disconnectedAt) => new EventSubTransport(TwitchTransportType.Websocket,
null, null, sessionId, connectedAt, disconnectedAt);
}
public enum TwitchTransportType
{
Webhook = 0,
Websocket = 1,
Conduit = 2,
}
public class TwitchTransportTypeConverter : JsonConverter<TwitchTransportType>
{
public override TwitchTransportType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> reader.GetString() switch
{
"webhook" => TwitchTransportType.Webhook,
"websocket" => TwitchTransportType.Websocket,
"conduit" => TwitchTransportType.Conduit,
_ => throw new JsonException(),
};
public override void Write(Utf8JsonWriter writer, TwitchTransportType value, JsonSerializerOptions options)
{
writer.WriteStringValue(value switch
{
TwitchTransportType.Webhook => "webhook",
TwitchTransportType.Websocket => "websocket",
TwitchTransportType.Conduit => "conduit",
_ => throw new InvalidEnumArgumentException(nameof(value), (int)value, typeof(TwitchTransportType)),
});
}
}
}

View File

@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace TwitchIrcClient.EventSub.Messages
{
public class EventSubWelcome : EventSubMessage
{
public override EventSubMessageType MessageType => EventSubMessageType.Welcome;
[JsonRequired]
[JsonPropertyName("metadata")]
public EventSubMessageBaseMetadata Metadata { get; set; }
[JsonRequired]
[JsonPropertyName("payload")]
public EventSubReconnectPayload Payload { get; set; }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace TwitchIrcClient.IRC.Messages
{
public class GlobalUserState : ReceivedMessage
{
/// <summary>
/// Contains metadata related to the chat badges in the badges tag.
/// Currently, this tag contains metadata only for subscriber badges,
/// to indicate the number of months the user has been a subscriber.
/// </summary>
public IEnumerable<string> BadgeInfo => TryGetTag("badge-info").Split(',');
/// <summary>
/// List of chat badges. Most badges have only 1 version, but some badges like
/// subscriber badges offer different versions of the badge depending on how
/// long the user has subscribed. To get the badge, use the Get Global Chat
/// Badges and Get Channel Chat Badges APIs. Match the badge to the set-id fields
/// value in the response. Then, match the version to the id field in the list of versions.
/// </summary>
public List<Badge> Badges
{ get
{
if (!MessageTags.TryGetValue("badges", out string? value))
return [];
if (value == null)
return [];
List<Badge> badges = [];
foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
var spl = item.Split('/', 2);
badges.Add(new Badge(spl[0], spl[1]));
}
return badges;
}
}
/// <summary>
/// The color of the users name in the chat room. This is a hexadecimal
/// RGB color code in the form, #<RGB>. This tag may be empty if it is never set.
/// </summary>
public Color? Color
{ get
{
//should have format "#RRGGBB"
if (!MessageTags.TryGetValue("color", out string? value))
return null;
if (value.Length < 7)
return null;
int r = Convert.ToInt32(value.Substring(1, 2), 16);
int g = Convert.ToInt32(value.Substring(3, 2), 16);
int b = Convert.ToInt32(value.Substring(5, 2), 16);
return System.Drawing.Color.FromArgb(r, g, b);
}
}
/// <summary>
/// The users display name. This tag may be empty if it is never set.
/// </summary>
public string DisplayName
{ get
{
if (!MessageTags.TryGetValue("display-name", out string? value))
return "";
return value ?? "";
}
}
/// <summary>
/// A comma-delimited list of IDs that identify the emote sets that the user has
/// access to. Is always set to at least zero (0). To access the emotes in the set,
/// use the Get Emote Sets API.
/// </summary>
/// <see href="https://dev.twitch.tv/docs/api/reference#get-emote-sets"/>
public IEnumerable<int> EmoteSets
{ get
{
var value = TryGetTag("emote-sets");
foreach (var s in value.Split(','))
{
if (int.TryParse(s, out int num))
yield return num;
else
throw new InvalidDataException();
}
}
}
/// <summary>
/// A Boolean value that indicates whether the user has site-wide commercial
/// free mode enabled
/// </summary>
public bool Turbo
{ get
{
if (!MessageTags.TryGetValue("turbo", out string? value))
return false;
return value == "1";
}
}
public string UserId => TryGetTag("user-id");
/// <summary>
/// The type of the user. Assumes a normal user if this is not provided or is invalid.
/// </summary>
public UserType UserType
{ get
{
if (!MessageTags.TryGetValue("user-type", out string? value))
return UserType.Normal;
switch (value)
{
case "admin":
return UserType.Admin;
case "global_mod":
return UserType.GlobalMod;
case "staff":
return UserType.Staff;
default:
return UserType.Normal;
}
}
}
public GlobalUserState(ReceivedMessage other) : base(other)
{
Debug.Assert(MessageType == IrcMessageType.GLOBALUSERSTATE,
$"{nameof(GlobalUserState)} must have type {IrcMessageType.GLOBALUSERSTATE}" +
$" but has {MessageType}");
}
}
}

View File

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

View File

@@ -160,166 +160,4 @@ namespace TwitchIrcClient.IRC.Messages
usage_me,
usage_mod,
}
//public enum NoticeId
//{
// AlreadyBanned,
// AlreadyEmoteOnlyOff,
// AlreadyEmoteOnlyOn,
// AlreadyFollowersOff,
// AlreadyFollowersOn,
// AlreadyR9KOff,
// AlreadyR9KOn,
// AlreadySlowOff,
// AlreadySlowOn,
// AlreadySubsOff,
// AlreadySubsOn,
// AutohostReceive,
// BadBanAdmin,
// BadBanAnon,
// BadBanBroadcaster,
// BadBanMod,
// BadBanSelf,
// BadBanStaff,
// BadCommercialError,
// BadDeleteMessageBroadcaster,
// BadDeleteMessageMod,
// BadHostError,
// BadHostHosting,
// BadHostRateExceeded,
// BadHostRejected,
// BadHostSelf,
// BadModBanned,
// BadModMod,
// BadSlowDuration,
// BadTimeoutAdmin,
// BadTimeoutAnon,
// BadTimeoutBroadcaster,
// BadTimeoutDuration,
// BadTimeoutMod,
// BadTimeoutSelf,
// BadTimeoutStaff,
// BadUnbanNoBan,
// BadUnhostError,
// BadUnmodMod,
// BadVipGranteeBanned,
// BadVipGranteeAlreadyVip,
// BadVipMaxVipsReached,
// BadVipAchievementIncomplete,
// BadUnvipGranteeNotVip,
// BanSuccess,
// CmdsAvailable,
// ColorChanged,
// CommercialSuccess,
// DeleteMessageSuccess,
// DeleteStaffMessageSuccess,
// EmoteOnlyOff,
// EmoteOnlyOn,
// FollowersOff,
// FollowersOn,
// FollowersOnZero,
// HostOff,
// HostOn,
// HostReceive,
// HostReceiveNoCount,
// HostTargetWentOffline,
// HostsRemaining,
// InvalidUser,
// ModSuccess,
// MsgBanned,
// MsgBadCharacters,
// MsgChannelBlocked,
// MsgChannelSuspended,
// MsgDuplicate,
// MsgEmoteonly,
// MsgFollowersonly,
// MsgFollowersonlyFollowed,
// MsgFollowersonlyZero,
// MsgR9K,
// MsgRatelimit,
// MsgRejected,
// MsgRejectedMandatory,
// MsgRequiresVerifiedPhoneNumber,
// MsgSlowmode,
// MsgSubsonly,
// MsgSuspended,
// MsgTimedout,
// MsgVerifiedEmail,
// NoHelp,
// NoMods,
// NoVips,
// NotHosting,
// NoPermission,
// R9KOff,
// R9KOn,
// RaidErrorAlreadyRaiding,
// RaidErrorForbidden,
// RaidErrorSelf,
// RaidErrorTooManyViewers,
// RaidErrorUnexpected,
// RaidNoticeMature,
// RaidNoticeRestrictedChat,
// RoomMods,
// SlowOff,
// SlowOn,
// SubsOff,
// SubsOn,
// TimeoutNoTimeout,
// TimeoutSuccess,
// TosBan,
// TurboOnlyColor,
// UnavailableCommand,
// UnbanSuccess,
// UnmodSuccess,
// UnraidErrorNoActiveRaid,
// UnraidErrorUnexpected,
// UnraidSuccess,
// UnrecognizedCmd,
// UntimeoutBanned,
// UntimeoutSuccess,
// UnvipSuccess,
// UsageBan,
// UsageClear,
// UsageColor,
// UsageCommercial,
// UsageDisconnect,
// UsageDelete,
// UsageEmoteOnlyOff,
// UsageEmoteOnlyOn,
// UsageFollowersOff,
// UsageFollowersOn,
// UsageHelp,
// UsageHost,
// UsageMarker,
// UsageMe,
// UsageMod,
// UsageMods,
// UsageR9KOff,
// UsageR9KOn,
// UsageRaid,
// UsageSlowOff,
// UsageSlowOn,
// UsageSubsOff,
// UsageSubsOn,
// UsageTimeout,
// UsageUnban,
// UsageUnhost,
// UsageUnmod,
// UsageUnraid,
// UsageUntimeout,
// UsageUnvip,
// UsageUser,
// UsageVip,
// UsageVips,
// UsageWhisper,
// VipSuccess,
// VipsSuccess,
// WhisperBanned,
// WhisperBannedRecipient,
// WhisperInvalidLogin,
// WhisperInvalidSelf,
// WhisperLimitPerMin,
// WhisperLimitPerSec,
// WhisperRestricted,
// WhisperRestrictedRecipient,
//}
}

View File

@@ -14,6 +14,27 @@ namespace TwitchIrcClient.IRC.Messages
/// </summary>
public class Privmsg : ReceivedMessage
{
/// <summary>
/// Contains metadata related to the chat badges in the badges tag.
/// According to Twitch's documentation this should only include info about
/// subscription length, but it also contains prediction info and who knows what else.
/// </summary>
public IEnumerable<string> BadgeInfo => TryGetTag("badge-info").Split(',');
/// <summary>
/// Contains the total number of months the user has subscribed, even if they aren't
/// subscribed currently.
/// </summary>
public int SubscriptionLength
{ get
{
//TODO redo this, functional style clearly didn't work here
if (int.TryParse((BadgeInfo.FirstOrDefault(
b => b.StartsWith("SUBSCRIBER", StringComparison.CurrentCultureIgnoreCase)) ?? "")
.Split("/", 2).ElementAtOrDefault(1) ?? "", out int value))
return value;
return 0;
}
}
/// <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
@@ -79,6 +100,26 @@ namespace TwitchIrcClient.IRC.Messages
return value ?? "";
}
}
public IEnumerable<Emote> Emotes
{ get
{
var tag = TryGetTag("emotes");
foreach (var emote in tag.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
var split = emote.Split(':', 2);
Debug.Assert(split.Length == 2);
var name = split[0];
foreach (var indeces in split[1].Split(','))
{
var split2 = indeces.Split('-');
if (!int.TryParse(split2[0], out int start) ||
!int.TryParse(split2[1], out int end))
throw new InvalidDataException();
yield return new Emote(name, start, end - start + 1);
}
}
}
}
/// <summary>
/// An ID that uniquely identifies the message.
/// </summary>
@@ -166,6 +207,54 @@ namespace TwitchIrcClient.IRC.Messages
/// A Boolean value that determines whether the user that sent the chat is a VIP.
/// </summary>
public bool Vip => MessageTags.ContainsKey("vip");
/// <summary>
/// The level of the Hype Chat. Proper values are 1-10, or 0 if not a Hype Chat.
/// </summary>
public int HypeChatLevel
{ get
{
var value = TryGetTag("pinned-chat-paid-level");
switch (value.ToUpper())
{
case "ONE":
return 1;
case "TWO":
return 2;
case "THREE":
return 3;
case "FOUR":
return 4;
case "FIVE":
return 5;
case "SIX":
return 6;
case "SEVEN":
return 7;
case "EIGHT":
return 8;
case "NINE":
return 9;
case "TEN":
return 10;
default:
return 0;
}
}
}
/// <summary>
/// The ISO 4217 alphabetic currency code the user has sent the Hype Chat in.
/// </summary>
public string HypeChatCurrency => TryGetTag("pinned-chat-paid-currency");
public decimal? HypeChatValue
{ get
{
var numeric = TryGetTag("pinned-chat-paid-amount");
var exp = TryGetTag("pinned-chat-paid-exponent");
if (int.TryParse(numeric, out int d_numeric) && int.TryParse(exp, out int d_exp))
return d_numeric / ((decimal)Math.Pow(10, d_exp));
return null;
}
}
public bool FirstMessage => TryGetTag("first-msg") == "1";
public string ChatMessage => Parameters.Last();
public Privmsg(ReceivedMessage message) : base(message)

View File

@@ -94,27 +94,23 @@ namespace TwitchIrcClient.IRC.Messages
message.Parameters.Add(spl_final[1]);
}
}
switch (message.MessageType)
return message.MessageType switch
{
case IrcMessageType.PRIVMSG:
return new Privmsg(message);
case IrcMessageType.CLEARCHAT:
return new ClearChat(message);
case IrcMessageType.CLEARMSG:
return new ClearMsg(message);
case IrcMessageType.NOTICE:
return new Notice(message);
case IrcMessageType.JOIN:
return new Join(message);
case IrcMessageType.PART:
return new Part(message);
case IrcMessageType.RPL_NAMREPLY:
return new NamReply(message);
case IrcMessageType.ROOMSTATE:
return new Roomstate(message);
default:
return message;
}
IrcMessageType.CLEARCHAT => new ClearChat(message),
IrcMessageType.CLEARMSG => new ClearMsg(message),
IrcMessageType.JOIN => new Join(message),
IrcMessageType.GLOBALUSERSTATE => new GlobalUserState(message),
IrcMessageType.HOSTTARGET => new HostTarget(message),
IrcMessageType.NOTICE => new Notice(message),
IrcMessageType.PART => new Part(message),
IrcMessageType.PRIVMSG => new Privmsg(message),
IrcMessageType.ROOMSTATE => new Roomstate(message),
IrcMessageType.RPL_NAMREPLY => new NamReply(message),
IrcMessageType.USERNOTICE => new UserNotice(message),
IrcMessageType.USERSTATE => new UserState(message),
IrcMessageType.WHISPER => new Whisper(message),
_ => message,
};
}
/// <summary>
/// Tries to get the value of the tag.

View File

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

View File

@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace TwitchIrcClient.IRC.Messages
{
public class UserState : ReceivedMessage
{
public string Channel => Parameters.FirstOrDefault("").TrimStart('#');
/// <summary>
/// The color of the users name in the chat room. This is a hexadecimal
/// RGB color code in the form, #<RGB>. This tag may be empty if it is never set.
/// </summary>
public Color? Color
{ get
{
//should have format "#RRGGBB"
if (!MessageTags.TryGetValue("color", out string? value))
return null;
if (value.Length < 7)
return null;
int r = Convert.ToInt32(value.Substring(1, 2), 16);
int g = Convert.ToInt32(value.Substring(3, 2), 16);
int b = Convert.ToInt32(value.Substring(5, 2), 16);
return System.Drawing.Color.FromArgb(r, g, b);
}
}
/// <summary>
/// The users display name, escaped as described in the IRCv3 spec.
/// </summary>
public string DisplayName => TryGetTag("display-name");
/// <summary>
/// A comma-delimited list of IDs that identify the emote sets that the user has
/// access to. Is always set to at least zero (0). To access the emotes in the set,
/// use the Get Emote Sets API.
/// </summary>
/// <see href="https://dev.twitch.tv/docs/api/reference#get-emote-sets"/>
public IEnumerable<int> EmoteSets
{ get
{
var value = TryGetTag("emote-sets");
foreach (var s in value.Split(','))
{
if (int.TryParse(s, out int num))
yield return num;
else
throw new InvalidDataException();
}
}
}
/// <summary>
/// If a privmsg was sent, an ID that uniquely identifies the message.
/// </summary>
public string Id => TryGetTag("id");
/// <summary>
/// A Boolean value that determines whether the user is a moderator.
/// </summary>
public bool Moderator
{ get
{
if (!MessageTags.TryGetValue("mod", out string? value))
return false;
return value == "1";
}
}
/// <summary>
/// Whether the user is subscribed to the channel
/// </summary>
public bool Subscriber
{ get
{
if (!MessageTags.TryGetValue("subscriber", out string? value))
return false;
return value == "1";
}
}
/// <summary>
/// A Boolean value that indicates whether the user has site-wide commercial
/// free mode enabled
/// </summary>
public bool Turbo
{ get
{
if (!MessageTags.TryGetValue("turbo", out string? value))
return false;
return value == "1";
}
}
public UserType UserType
{ get
{
if (!MessageTags.TryGetValue("user-type", out string? value))
return UserType.Normal;
switch (value)
{
case "admin":
return UserType.Admin;
case "global_mod":
return UserType.GlobalMod;
case "staff":
return UserType.Staff;
default:
return UserType.Normal;
}
}
}
public UserState(ReceivedMessage other) : base(other)
{
Debug.Assert(MessageType == IrcMessageType.USERSTATE,
$"{nameof(UserState)} must have type {IrcMessageType.USERSTATE}" +
$" but has {MessageType}");
}
}
}

View File

@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace TwitchIrcClient.IRC.Messages
{
public class Whisper : ReceivedMessage
{
/// <summary>
/// List of chat badges. Most badges have only 1 version, but some badges like
/// subscriber badges offer different versions of the badge depending on how
/// long the user has subscribed. To get the badge, use the Get Global Chat
/// Badges and Get Channel Chat Badges APIs. Match the badge to the set-id fields
/// value in the response.Then, match the version to the id field in the list of versions.
/// </summary>
public List<Badge> Badges
{ get
{
if (!MessageTags.TryGetValue("badges", out string? value))
return [];
if (value == null)
return [];
List<Badge> badges = [];
foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
var spl = item.Split('/', 2);
badges.Add(new Badge(spl[0], spl[1]));
}
return badges;
}
}
/// <summary>
/// The color of the users name in the chat room. This is a hexadecimal
/// RGB color code in the form, #<RGB>. This tag may be empty if it is never set.
/// </summary>
public Color? Color
{ get
{
//should have format "#RRGGBB"
if (!MessageTags.TryGetValue("color", out string? value))
return null;
if (value.Length < 7)
return null;
int r = Convert.ToInt32(value.Substring(1, 2), 16);
int g = Convert.ToInt32(value.Substring(3, 2), 16);
int b = Convert.ToInt32(value.Substring(5, 2), 16);
return System.Drawing.Color.FromArgb(r, g, b);
}
}
/// <summary>
/// The users display name. This tag may be empty if it is never set.
/// </summary>
public string DisplayName
{ get
{
if (!MessageTags.TryGetValue("display-name", out string? value))
return "";
return value ?? "";
}
}
public IEnumerable<Emote> Emotes
{ get
{
var tag = TryGetTag("emotes");
foreach (var emote in tag.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
var split = emote.Split(':', 2);
Debug.Assert(split.Length == 2);
var name = split[0];
foreach (var indeces in split[1].Split(','))
{
var split2 = indeces.Split('-');
if (!int.TryParse(split2[0], out int start) ||
!int.TryParse(split2[1], out int end))
throw new InvalidDataException();
yield return new Emote(name, start, end - start + 1);
}
}
}
}
/// <summary>
/// An ID that uniquely identifies the whisper message.
/// </summary>
public string MessageId => TryGetTag("message-id");
/// <summary>
/// An ID that uniquely identifies the whisper thread.
/// The ID is in the form, <smaller-value-user-id>_<larger-value-user-id>.
/// </summary>
public string ThreadId => TryGetTag("thread-id");
/// <summary>
/// A Boolean value that indicates whether the user has site-wide commercial
/// free mode enabled
/// </summary>
public bool Turbo
{ get
{
if (!MessageTags.TryGetValue("turbo", out string? value))
return false;
return value == "1";
}
}
/// <summary>
/// The ID of the user sending the whisper message.
/// </summary>
public string UserId => TryGetTag("user-id");
public string Message => Parameters.LastOrDefault("");
/// <summary>
/// The type of the user. Assumes a normal user if this is not provided or is invalid.
/// </summary>
public UserType UserType
{ get
{
if (!MessageTags.TryGetValue("user-type", out string? value))
return UserType.Normal;
switch (value)
{
case "admin":
return UserType.Admin;
case "global_mod":
return UserType.GlobalMod;
case "staff":
return UserType.Staff;
default:
return UserType.Normal;
}
}
}
public Whisper(ReceivedMessage other) : base(other)
{
Debug.Assert(MessageType == IrcMessageType.WHISPER,
$"{nameof(Whisper)} must have type {IrcMessageType.WHISPER}" +
$" but has {MessageType}");
}
}
}

View File

@@ -88,7 +88,9 @@ namespace TwitchIrcClient.IRC
{
lock (Semaphore)
{
Semaphore.Release(MessageLimit - Semaphore.CurrentCount);
var count = MessageLimit - Semaphore.CurrentCount;
if (count > 0)
Semaphore.Release(count);
}
}
catch (SemaphoreFullException) { }
@@ -96,17 +98,17 @@ namespace TwitchIrcClient.IRC
}
#region RateLimiter Dispose
private bool disposedValue;
//https://stackoverflow.com/questions/8927878/what-is-the-correct-way-of-adding-thread-safety-to-an-idisposable-object
private int _disposedCount;
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
if (Interlocked.Increment(ref _disposedCount) == 1)
{
if (disposing)
{
Semaphore?.Dispose();
Timer?.Dispose();
}
disposedValue = true;
}
}

View File

@@ -9,11 +9,9 @@ RateLimiter limiter = new(20, 30);
bool ssl = true;
async Task<IrcConnection> CreateConnection(string channel)
{
IrcConnection connection;
if (ssl)
connection = new IrcConnection("irc.chat.twitch.tv", 6697, limiter, true, true);
else
connection = new IrcConnection("irc.chat.twitch.tv", 6667, limiter, true, false);
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) =>
{
@@ -51,8 +49,8 @@ async Task<IrcConnection> CreateConnection(string channel)
}
Console.Write("Channel: ");
var channelName = Console.ReadLine();
ArgumentNullException.ThrowIfNull(channelName, nameof(Channel));
var connection = CreateConnection(channelName);
ArgumentNullException.ThrowIfNullOrWhiteSpace(channelName, nameof(channelName));
var connection = await CreateConnection(channelName);
while (true)
{
//all the work happens in other threads

View File

@@ -0,0 +1,8 @@
{
"metadata": {
"message_id": "84c1e79a-2a4b-4c13-ba0b-4312293e9308",
"message_type": "session_keepalive",
"message_timestamp": "2023-07-19T10:11:12.634234626Z"
},
"payload": {}
}

View File

@@ -0,0 +1,62 @@
{
"metadata": {
"message_id": "befa7b53-d79d-478f-86b9-120f112b044e",
"message_type": "notification",
"message_timestamp": "2022-11-16T10:11:12.464757833Z",
"subscription_type": "automod.message.hold",
"subscription_version": "1"
},
"payload": {
"subscription": {
"id": "f1c2a387-161a-49f9-a165-0f21d7a4e1c4",
"type": "automod.message.hold",
"version": "beta",
"status": "enabled",
"cost": 0,
"condition": {
"broadcaster_user_id": "1337",
"moderator_user_id": "9001"
},
"transport": {
"method": "websocket",
"session_id": "123456789"
},
"created_at": "2023-04-11T10:11:12.123Z"
},
"event": {
"broadcaster_user_id": "1337",
"broadcaster_user_name": "blah",
"broadcaster_user_login": "blahblah",
"user_id": "456789012",
"user_name": "baduser",
"user_login": "baduserbla",
"message_id": "bad-message-id",
"message": "This is a bad message… ",
"level": 5,
"category": "aggressive",
"held_at": "2022-12-02T15:00:00.00Z",
"fragments": {
"emotes": [
{
"text": "badtextemote1",
"id": "emote-123",
"set-id": "set-emote-1"
},
{
"text": "badtextemote2",
"id": "emote-234",
"set-id": "set-emote-2"
}
],
"cheermotes": [
{
"text": "badtextcheermote1",
"amount": 1000,
"prefix": "prefix",
"tier": 1
}
]
}
}
}
}

View File

@@ -0,0 +1,16 @@
{
"metadata": {
"message_id": "84c1e79a-2a4b-4c13-ba0b-4312293e9308",
"message_type": "session_reconnect",
"message_timestamp": "2022-11-18T09:10:11.634234626Z"
},
"payload": {
"session": {
"id": "AQoQexAWVYKSTIu4ec_2VAxyuhAB",
"status": "reconnecting",
"keepalive_timeout_seconds": null,
"reconnect_url": "wss://eventsub.wss.twitch.tv?...",
"connected_at": "2022-11-16T10:11:12.634234626Z"
}
}
}

View File

@@ -0,0 +1,26 @@
{
"metadata": {
"message_id": "84c1e79a-2a4b-4c13-ba0b-4312293e9308",
"message_type": "revocation",
"message_timestamp": "2022-11-16T10:11:12.464757833Z",
"subscription_type": "channel.follow",
"subscription_version": "1"
},
"payload": {
"subscription": {
"id": "f1c2a387-161a-49f9-a165-0f21d7a4e1c4",
"status": "authorization_revoked",
"type": "channel.follow",
"version": "1",
"cost": 1,
"condition": {
"broadcaster_user_id": "12826"
},
"transport": {
"method": "websocket",
"session_id": "AQoQexAWVYKSTIu4ec_2VAxyuhAB"
},
"created_at": "2022-11-16T10:11:12.464757833Z"
}
}
}

View File

@@ -0,0 +1,16 @@
{
"metadata": {
"message_id": "96a3f3b5-5dec-4eed-908e-e11ee657416c",
"message_type": "session_welcome",
"message_timestamp": "2023-07-19T14:56:51.634234626Z"
},
"payload": {
"session": {
"id": "AQoQILE98gtqShGmLD7AM6yJThAB",
"status": "connected",
"connected_at": "2023-07-19T14:56:51.616329898Z",
"keepalive_timeout_seconds": 10,
"reconnect_url": null
}
}
}

View File

@@ -0,0 +1,106 @@
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Security;
using System.Text;
using System.Threading.Tasks;
using TwitchIrcClient.EventSub.Messages;
namespace TwitchIrcClientTests
{
[TestClass]
[DeploymentItem("EventSubExampleJson")]
public class EventSubJsonTest
{
[TestMethod]
public void TestEventSubNotification()
{
var text = File.ReadAllText("EventSubNotification_AutomodMessageHold.json");
var automodMessageHold = EventSubMessage.Parse(text);
Assert.IsInstanceOfType<EventSubNotification>(automodMessageHold);
var esn = (EventSubNotification)automodMessageHold;
Assert.AreEqual("befa7b53-d79d-478f-86b9-120f112b044e", esn.Metadata.MessageId);
Assert.AreEqual(EventSubMessageType.Notification, esn.MessageType);
//test accuracy to a millisecond
Assert.AreEqual(new DateTime(2022, 11, 16, 10, 11, 12, 464, 578, DateTimeKind.Utc).Ticks,
esn.Metadata.MessageTime.Ticks, 10000);
Assert.AreEqual("automod.message.hold", esn.Metadata.SubscriptionType);
Assert.AreEqual("1", esn.Metadata.SubscriptionVersion);
Assert.AreEqual("f1c2a387-161a-49f9-a165-0f21d7a4e1c4", esn.Payload.Subscription.Id);
Assert.AreEqual("automod.message.hold", esn.Payload.Subscription.Type);
Assert.AreEqual("beta", esn.Payload.Subscription.Version);
Assert.AreEqual("enabled", esn.Payload.Subscription.Status);
Assert.AreEqual(0, esn.Payload.Subscription.Cost);
Assert.AreEqual("websocket", esn.Payload.Subscription.Transport.Method);
Assert.AreEqual("123456789", esn.Payload.Subscription.Transport.SessionId);
}
[TestMethod]
public void TestEventSubWelcome()
{
var text = File.ReadAllText("EventSubWelcome.json");
var welcome = EventSubMessage.Parse(text);
Assert.IsInstanceOfType<EventSubWelcome>(welcome);
var esw = (EventSubWelcome)welcome;
Assert.AreEqual("96a3f3b5-5dec-4eed-908e-e11ee657416c", esw.Metadata.MessageId);
Assert.AreEqual(EventSubMessageType.Welcome, esw.MessageType);
Assert.AreEqual(new DateTime(2023, 7, 19, 14, 56, 51, 634, 234, DateTimeKind.Utc).Ticks,
esw.Metadata.MessageTime.Ticks, 10000);
}
[TestMethod]
public void TestEventSubRevocation()
{
var text = File.ReadAllText("EventSubRevocation_ChannelFollow.json");
var channelFollow = EventSubMessage.Parse(text);
Assert.IsInstanceOfType<EventSubRevocation>(channelFollow);
var esr = (EventSubRevocation)channelFollow;
Assert.AreEqual("84c1e79a-2a4b-4c13-ba0b-4312293e9308", esr.Metadata.MessageId);
Assert.AreEqual(EventSubMessageType.Revocation, esr.MessageType);
Assert.AreEqual(new DateTime(2022, 11, 16, 10, 11, 12, 464, 757, DateTimeKind.Utc).Ticks,
esr.Metadata.MessageTime.Ticks, 10000);
Assert.AreEqual("channel.follow", esr.Metadata.SubscriptionType);
Assert.AreEqual("1", esr.Metadata.SubscriptionVersion);
var sub = esr.Payload.Subscription;
Assert.AreEqual("f1c2a387-161a-49f9-a165-0f21d7a4e1c4", sub.Id);
Assert.AreEqual("authorization_revoked", sub.Status);
Assert.AreEqual("channel.follow", sub.Type);
Assert.AreEqual("1", sub.Version);
Assert.AreEqual(1, sub.Cost);
Assert.AreEqual("websocket", sub.Transport.Method);
Assert.AreEqual("AQoQexAWVYKSTIu4ec_2VAxyuhAB", sub.Transport.SessionId);
Assert.AreEqual(new DateTime(2022, 11, 16, 10, 11, 12, 464, 757, DateTimeKind.Utc).Ticks,
esr.Payload.Subscription.CreatedAt.Ticks, 10000);
}
[TestMethod]
public void TestEventSubReconnect()
{
var text = File.ReadAllText("EventSubReconnect.json");
var reconnect = EventSubMessage.Parse(text);
Assert.IsInstanceOfType<EventSubReconnect>(reconnect);
var esr = (EventSubReconnect)reconnect;
Assert.AreEqual("84c1e79a-2a4b-4c13-ba0b-4312293e9308", esr.Metadata.MessageId);
Assert.AreEqual(EventSubMessageType.Reconnect, esr.MessageType);
Assert.AreEqual(new DateTime(2022, 11, 18, 9, 10, 11, 634, 234, DateTimeKind.Utc).Ticks,
esr.Metadata.MessageTime.Ticks, 10000);
Assert.AreEqual("AQoQexAWVYKSTIu4ec_2VAxyuhAB", esr.Payload.Session.Id);
Assert.AreEqual("reconnecting", esr.Payload.Session.Status);
Assert.IsNull(esr.Payload.Session.KeepaliveTimeoutSeconds);
Assert.AreEqual("wss://eventsub.wss.twitch.tv?...", esr.Payload.Session.ReconnectUrl);
Assert.AreEqual(new DateTime(2022, 11, 16, 10, 11, 12, 634, 234, DateTimeKind.Utc).Ticks,
esr.Payload.Session.ConnectedAt.Ticks, 10000);
}
[TestMethod]
public void TestEventSubKeepalive()
{
var text = File.ReadAllText("EventSubKeepalive.json");
var keepalive = EventSubMessage.Parse(text);
Assert.IsInstanceOfType<EventSubKeepalive>(keepalive);
var esk = (EventSubKeepalive)keepalive;
Assert.AreEqual("84c1e79a-2a4b-4c13-ba0b-4312293e9308", esk.Metadata.MessageId);
Assert.AreEqual(EventSubMessageType.Keepalive, esk.MessageType);
Assert.AreEqual(new DateTime(2023, 7, 19, 10, 11, 12, 634, 234, DateTimeKind.Utc).Ticks,
esk.Metadata.MessageTime.Ticks, 10000);
Assert.IsFalse(esk.Payload.Any());
}
}
}

View 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 IrcParserTest
{
[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();
}
}
}
}

View File

@@ -1,146 +0,0 @@
using System.Drawing;
using System;
using TwitchIrcClient.IRC;
using TwitchIrcClient.IRC.Messages;
namespace TwitchIrcClientTests
{
[TestClass]
public class ParserTest
{
[TestMethod]
public void TestSimpleMessages()
{
var ROOMSTATE = "@emote-only=0;followers-only=-1;r9k=0;room-id=321654987;slow=0;subs-only=0 :tmi.twitch.tv ROOMSTATE #channelname";
var NAMREPLY = ":justinfan7550.tmi.twitch.tv 353 justinfan7550 = #channelname :user1 user2 user3 user4 user5";
var JOIN = ":newuser!newuser@newuser.tmi.twitch.tv JOIN #channelname";
var PART = ":leavinguser!leavinguser@leavinguser.tmi.twitch.tv PART #channelname";
var PRIVMSG = "@badge-info=subscriber/1;badges=subscriber/0;client-nonce=202e32113a3768963eded865e051fc5b;color=#AAAAFF;" +
"display-name=ChattingUser;emotes=;first-msg=0;flags=;id=24fe75a1-06a5-4078-a31f-cf615107b2a2;mod=0;returning-chatter=0;" +
"room-id=321654987;subscriber=1;tmi-sent-ts=1710920497332;turbo=0;user-id=01234567;user-type= " +
":chattinguser!chattinguser@chattinguser.tmi.twitch.tv PRIVMSG #channelname :This is a test chat message";
var CHEER = "@badge-info=subscriber/9;badges=subscriber/9,twitch-recap-2023/1;bits=100;color=#FF0000;display-name=CheeringUser;" +
//I haven't fixed this emote tag after rewriting the message
"emotes=emotesv2_44a39d65e08f43adac871a80e9b96d85:17-24;first-msg=1;flags=;id=5eab1319-5d46-4c55-be29-33c2f834e42e;mod=0;" +
"returning-chatter=0;room-id=321654987;subscriber=0;tmi-sent-ts=1710920826069;turbo=1;user-id=012345678;user-type=;vip " +
":cheeringuser!cheeringuser@cheeringuser.tmi.twitch.tv PRIVMSG #channelname :This includes a cheer Cheer100";
//var CLEARMSG = "";
//var CLEARROOM = "";
var _roomstate = ReceivedMessage.Parse(ROOMSTATE);
Assert.AreEqual(IrcMessageType.ROOMSTATE, _roomstate.MessageType);
if (_roomstate is Roomstate roomstate)
{
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();
}
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();
}
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();
}
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();
}
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);
}
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);
//test that timestamp is within 1 second
Assert.AreEqual(new DateTime(2024, 3, 20, 7, 47, 6, 069, DateTimeKind.Utc), cheer.Timestamp);
}
else
{
Assert.Fail();
}
}
}
}

View File

@@ -24,4 +24,22 @@
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
</ItemGroup>
<ItemGroup>
<None Update="EventSubExampleJson\EventSubKeepalive.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="EventSubExampleJson\EventSubNotification_AutomodMessageHold.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="EventSubExampleJson\EventSubReconnect.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="EventSubExampleJson\EventSubRevocation_ChannelFollow.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="EventSubExampleJson\EventSubWelcome.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>