From a0ac6e69c3c11bcb9323eeb53d14d19e488acb83 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 6 Apr 2024 18:28:49 -0500 Subject: [PATCH] staging work then abandoning the project, dealing with Twitch APIs isn't worth the effor --- TwitchIrcClient/ApiClient/ApiClient.cs | 145 ++++++++++++++++++ .../ApiClient/Messages/ApiTransport.cs | 53 +++++++ .../Messages/EventSubQueryRequest.cs | 27 ++++ .../ApiClient/Messages/EventSubRequest.cs | 36 +++++ .../ApiClient/Messages/EventSubResponse.cs | 52 +++++++ .../EventSubSubscriptionListResponse.cs | 33 ++++ .../Messages/EventSubSubscriptionStatus.cs | 65 ++++++++ .../Authentication/DcfCodeMessage.cs | 28 ++++ .../UserAccessAuthentication.cs | 10 ++ .../EventSub/EventSubWebsocketClient.cs | 134 ++++++++++++++++ TwitchIrcClient/EventSub/Messages/Emote.cs | 28 ++++ .../EventSub/Messages/EventSubKeepalive.cs | 21 +++ .../EventSub/Messages/EventSubMessage.cs | 65 ++++++++ .../EventSub/Messages/EventSubMessageType.cs | 33 ++++ .../EventSub/Messages/EventSubNotification.cs | 77 ++++++++++ .../EventSubNotificationCallbackItem.cs | 26 ++++ .../EventSub/Messages/EventSubReconnect.cs | 45 ++++++ .../EventSub/Messages/EventSubRevocation.cs | 27 ++++ .../Messages/EventSubSubscriptionRequest.cs | 37 +++++ .../EventSub/Messages/EventSubTransport.cs | 95 ++++++++++++ .../EventSub/Messages/EventSubWelcome.cs | 21 +++ .../EventSubKeepalive.json | 8 + ...entSubNotification_AutomodMessageHold.json | 62 ++++++++ .../EventSubReconnect.json | 16 ++ .../EventSubRevocation_ChannelFollow.json | 26 ++++ .../EventSubExampleJson/EventSubWelcome.json | 16 ++ TwitchIrcClientTests/EventSubJsonTest.cs | 106 +++++++++++++ .../{ParserTest.cs => IrcParserTest.cs} | 2 +- .../TwitchIrcClientTests.csproj | 18 +++ 29 files changed, 1311 insertions(+), 1 deletion(-) create mode 100644 TwitchIrcClient/ApiClient/ApiClient.cs create mode 100644 TwitchIrcClient/ApiClient/Messages/ApiTransport.cs create mode 100644 TwitchIrcClient/ApiClient/Messages/EventSubQueryRequest.cs create mode 100644 TwitchIrcClient/ApiClient/Messages/EventSubRequest.cs create mode 100644 TwitchIrcClient/ApiClient/Messages/EventSubResponse.cs create mode 100644 TwitchIrcClient/ApiClient/Messages/EventSubSubscriptionListResponse.cs create mode 100644 TwitchIrcClient/ApiClient/Messages/EventSubSubscriptionStatus.cs create mode 100644 TwitchIrcClient/Authentication/DcfCodeMessage.cs create mode 100644 TwitchIrcClient/Authentication/UserAccessAuthentication.cs create mode 100644 TwitchIrcClient/EventSub/EventSubWebsocketClient.cs create mode 100644 TwitchIrcClient/EventSub/Messages/Emote.cs create mode 100644 TwitchIrcClient/EventSub/Messages/EventSubKeepalive.cs create mode 100644 TwitchIrcClient/EventSub/Messages/EventSubMessage.cs create mode 100644 TwitchIrcClient/EventSub/Messages/EventSubMessageType.cs create mode 100644 TwitchIrcClient/EventSub/Messages/EventSubNotification.cs create mode 100644 TwitchIrcClient/EventSub/Messages/EventSubNotificationCallbackItem.cs create mode 100644 TwitchIrcClient/EventSub/Messages/EventSubReconnect.cs create mode 100644 TwitchIrcClient/EventSub/Messages/EventSubRevocation.cs create mode 100644 TwitchIrcClient/EventSub/Messages/EventSubSubscriptionRequest.cs create mode 100644 TwitchIrcClient/EventSub/Messages/EventSubTransport.cs create mode 100644 TwitchIrcClient/EventSub/Messages/EventSubWelcome.cs create mode 100644 TwitchIrcClientTests/EventSubExampleJson/EventSubKeepalive.json create mode 100644 TwitchIrcClientTests/EventSubExampleJson/EventSubNotification_AutomodMessageHold.json create mode 100644 TwitchIrcClientTests/EventSubExampleJson/EventSubReconnect.json create mode 100644 TwitchIrcClientTests/EventSubExampleJson/EventSubRevocation_ChannelFollow.json create mode 100644 TwitchIrcClientTests/EventSubExampleJson/EventSubWelcome.json create mode 100644 TwitchIrcClientTests/EventSubJsonTest.cs rename TwitchIrcClientTests/{ParserTest.cs => IrcParserTest.cs} (99%) diff --git a/TwitchIrcClient/ApiClient/ApiClient.cs b/TwitchIrcClient/ApiClient/ApiClient.cs new file mode 100644 index 0000000..8440876 --- /dev/null +++ b/TwitchIrcClient/ApiClient/ApiClient.cs @@ -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 CreateWebsocketSubscriptionAsync(EventSubWebsocketClient eswClient, + UserAccessAuthentication auth, string type, string version, + IDictionary 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(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 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 GetWebsocketSubscriptionsAsync( + UserAccessAuthentication auth, EventSubSubscriptionStatus? status = null, + string? type = null, string? userId = null) + { + var attrs = new Dictionary(); + 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( + 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 GetDcfTokenAsync(string clientId, + IEnumerable 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(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 + } + /// + /// Callback for user to authorize the app + /// + /// + /// true if successful + public delegate Task AuthorizationCallback(string url); +} diff --git a/TwitchIrcClient/ApiClient/Messages/ApiTransport.cs b/TwitchIrcClient/ApiClient/Messages/ApiTransport.cs new file mode 100644 index 0000000..9c44247 --- /dev/null +++ b/TwitchIrcClient/ApiClient/Messages/ApiTransport.cs @@ -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, + }; + } +} diff --git a/TwitchIrcClient/ApiClient/Messages/EventSubQueryRequest.cs b/TwitchIrcClient/ApiClient/Messages/EventSubQueryRequest.cs new file mode 100644 index 0000000..a60e37f --- /dev/null +++ b/TwitchIrcClient/ApiClient/Messages/EventSubQueryRequest.cs @@ -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; } + } +} diff --git a/TwitchIrcClient/ApiClient/Messages/EventSubRequest.cs b/TwitchIrcClient/ApiClient/Messages/EventSubRequest.cs new file mode 100644 index 0000000..65787aa --- /dev/null +++ b/TwitchIrcClient/ApiClient/Messages/EventSubRequest.cs @@ -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 Condition; + [JsonRequired] + [JsonPropertyName("transport")] + public ApiTransport Transport { get; set; } + + [JsonConstructor] + public EventSubRequest(string type, string version, + IEnumerable> condition, + ApiTransport transport) + { + Type = type; + Version = version; + Condition = condition.ToDictionary(); + Transport = transport; + } + } +} diff --git a/TwitchIrcClient/ApiClient/Messages/EventSubResponse.cs b/TwitchIrcClient/ApiClient/Messages/EventSubResponse.cs new file mode 100644 index 0000000..7a51007 --- /dev/null +++ b/TwitchIrcClient/ApiClient/Messages/EventSubResponse.cs @@ -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 Condition { get; set; } + [JsonRequired] + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } + [JsonRequired] + [JsonPropertyName("transport")] + public Dictionary 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; } + } +} diff --git a/TwitchIrcClient/ApiClient/Messages/EventSubSubscriptionListResponse.cs b/TwitchIrcClient/ApiClient/Messages/EventSubSubscriptionListResponse.cs new file mode 100644 index 0000000..647ce75 --- /dev/null +++ b/TwitchIrcClient/ApiClient/Messages/EventSubSubscriptionListResponse.cs @@ -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; } + } +} diff --git a/TwitchIrcClient/ApiClient/Messages/EventSubSubscriptionStatus.cs b/TwitchIrcClient/ApiClient/Messages/EventSubSubscriptionStatus.cs new file mode 100644 index 0000000..9a26ce1 --- /dev/null +++ b/TwitchIrcClient/ApiClient/Messages/EventSubSubscriptionStatus.cs @@ -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 + { + private static readonly IList> 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> InverseConversionList = + ConversionList.Select, + KeyValuePair>(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)); + } +} diff --git a/TwitchIrcClient/Authentication/DcfCodeMessage.cs b/TwitchIrcClient/Authentication/DcfCodeMessage.cs new file mode 100644 index 0000000..e12ddb1 --- /dev/null +++ b/TwitchIrcClient/Authentication/DcfCodeMessage.cs @@ -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; } + } +} diff --git a/TwitchIrcClient/Authentication/UserAccessAuthentication.cs b/TwitchIrcClient/Authentication/UserAccessAuthentication.cs new file mode 100644 index 0000000..1f4ed56 --- /dev/null +++ b/TwitchIrcClient/Authentication/UserAccessAuthentication.cs @@ -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); +} diff --git a/TwitchIrcClient/EventSub/EventSubWebsocketClient.cs b/TwitchIrcClient/EventSub/EventSubWebsocketClient.cs new file mode 100644 index 0000000..3657d51 --- /dev/null +++ b/TwitchIrcClient/EventSub/EventSubWebsocketClient.cs @@ -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 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(); + 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? System_ReceivedKeepalive; + private event EventHandler? System_ReceivedNotification; + private event EventHandler? System_ReceivedReconnect; + private event EventHandler? System_ReceivedRevocation; + private event EventHandler? System_ReceivedWelcome; + + public event EventHandler? ReceivedKeepalive; + public event EventHandler? ReceivedNotification; + public event EventHandler? ReceivedReconnect; + public event EventHandler? ReceivedRevocation; + public event EventHandler? 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 + } +} diff --git a/TwitchIrcClient/EventSub/Messages/Emote.cs b/TwitchIrcClient/EventSub/Messages/Emote.cs new file mode 100644 index 0000000..54e0739 --- /dev/null +++ b/TwitchIrcClient/EventSub/Messages/Emote.cs @@ -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; + } + } +} diff --git a/TwitchIrcClient/EventSub/Messages/EventSubKeepalive.cs b/TwitchIrcClient/EventSub/Messages/EventSubKeepalive.cs new file mode 100644 index 0000000..58a10f8 --- /dev/null +++ b/TwitchIrcClient/EventSub/Messages/EventSubKeepalive.cs @@ -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; } + } +} diff --git a/TwitchIrcClient/EventSub/Messages/EventSubMessage.cs b/TwitchIrcClient/EventSub/Messages/EventSubMessage.cs new file mode 100644 index 0000000..738b94b --- /dev/null +++ b/TwitchIrcClient/EventSub/Messages/EventSubMessage.cs @@ -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(node), + "session_keepalive" => JsonSerializer.Deserialize(node), + "notification" => JsonSerializer.Deserialize(node), + "session_reconnect" => JsonSerializer.Deserialize(node), + "revocation" => JsonSerializer.Deserialize(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; + } +} diff --git a/TwitchIrcClient/EventSub/Messages/EventSubMessageType.cs b/TwitchIrcClient/EventSub/Messages/EventSubMessageType.cs new file mode 100644 index 0000000..85bd54b --- /dev/null +++ b/TwitchIrcClient/EventSub/Messages/EventSubMessageType.cs @@ -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)), + }; + } + } +} diff --git a/TwitchIrcClient/EventSub/Messages/EventSubNotification.cs b/TwitchIrcClient/EventSub/Messages/EventSubNotification.cs new file mode 100644 index 0000000..58f7814 --- /dev/null +++ b/TwitchIrcClient/EventSub/Messages/EventSubNotification.cs @@ -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; } + } +} diff --git a/TwitchIrcClient/EventSub/Messages/EventSubNotificationCallbackItem.cs b/TwitchIrcClient/EventSub/Messages/EventSubNotificationCallbackItem.cs new file mode 100644 index 0000000..ff00567 --- /dev/null +++ b/TwitchIrcClient/EventSub/Messages/EventSubNotificationCallbackItem.cs @@ -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? CallbackTypes) + { + public bool TryCall(EventSubWebsocketClient origin, EventSubNotification message) + { + if (CallbackTypes?.Contains(message.Metadata.MessageType) ?? true) + { + Callback(origin, message); + return true; + } + return false; + } + } +} diff --git a/TwitchIrcClient/EventSub/Messages/EventSubReconnect.cs b/TwitchIrcClient/EventSub/Messages/EventSubReconnect.cs new file mode 100644 index 0000000..fef0421 --- /dev/null +++ b/TwitchIrcClient/EventSub/Messages/EventSubReconnect.cs @@ -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; } + } +} diff --git a/TwitchIrcClient/EventSub/Messages/EventSubRevocation.cs b/TwitchIrcClient/EventSub/Messages/EventSubRevocation.cs new file mode 100644 index 0000000..c74afbd --- /dev/null +++ b/TwitchIrcClient/EventSub/Messages/EventSubRevocation.cs @@ -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; } + } +} diff --git a/TwitchIrcClient/EventSub/Messages/EventSubSubscriptionRequest.cs b/TwitchIrcClient/EventSub/Messages/EventSubSubscriptionRequest.cs new file mode 100644 index 0000000..dd43d08 --- /dev/null +++ b/TwitchIrcClient/EventSub/Messages/EventSubSubscriptionRequest.cs @@ -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 Condition { get; set; } + [JsonRequired] + [JsonPropertyName("transport")] + public Dictionary Transport { get; set; } + public EventSubSubscriptionRequest(string type, string version, + Dictionary condition, + Dictionary transport) + { + Type = type; + Version = version; + Condition = condition; + Transport = transport; + } + } +} diff --git a/TwitchIrcClient/EventSub/Messages/EventSubTransport.cs b/TwitchIrcClient/EventSub/Messages/EventSubTransport.cs new file mode 100644 index 0000000..fd4296d --- /dev/null +++ b/TwitchIrcClient/EventSub/Messages/EventSubTransport.cs @@ -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 + { + /// + /// The transport method. Possible values are: + /// webhook + /// websocket + /// + [JsonRequired] + [JsonConverter(typeof(TwitchTransportTypeConverter))] + [JsonPropertyName("method")] + public TwitchTransportType Method { get; set; } + /// + /// The callback URL where the notifications are sent. The URL must use the HTTPS protocol and port 443. + /// See . + /// Specify this field only if is set to . + /// NOTE: Redirects are not followed. + /// + [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 + { + 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)), + }); + } + } +} diff --git a/TwitchIrcClient/EventSub/Messages/EventSubWelcome.cs b/TwitchIrcClient/EventSub/Messages/EventSubWelcome.cs new file mode 100644 index 0000000..1e1b0fa --- /dev/null +++ b/TwitchIrcClient/EventSub/Messages/EventSubWelcome.cs @@ -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; } + } +} diff --git a/TwitchIrcClientTests/EventSubExampleJson/EventSubKeepalive.json b/TwitchIrcClientTests/EventSubExampleJson/EventSubKeepalive.json new file mode 100644 index 0000000..ef28a21 --- /dev/null +++ b/TwitchIrcClientTests/EventSubExampleJson/EventSubKeepalive.json @@ -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": {} +} \ No newline at end of file diff --git a/TwitchIrcClientTests/EventSubExampleJson/EventSubNotification_AutomodMessageHold.json b/TwitchIrcClientTests/EventSubExampleJson/EventSubNotification_AutomodMessageHold.json new file mode 100644 index 0000000..4f94ec6 --- /dev/null +++ b/TwitchIrcClientTests/EventSubExampleJson/EventSubNotification_AutomodMessageHold.json @@ -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 + } + ] + } + } + } +} \ No newline at end of file diff --git a/TwitchIrcClientTests/EventSubExampleJson/EventSubReconnect.json b/TwitchIrcClientTests/EventSubExampleJson/EventSubReconnect.json new file mode 100644 index 0000000..a5b660e --- /dev/null +++ b/TwitchIrcClientTests/EventSubExampleJson/EventSubReconnect.json @@ -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" + } + } +} \ No newline at end of file diff --git a/TwitchIrcClientTests/EventSubExampleJson/EventSubRevocation_ChannelFollow.json b/TwitchIrcClientTests/EventSubExampleJson/EventSubRevocation_ChannelFollow.json new file mode 100644 index 0000000..dd0c8bf --- /dev/null +++ b/TwitchIrcClientTests/EventSubExampleJson/EventSubRevocation_ChannelFollow.json @@ -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" + } + } +} \ No newline at end of file diff --git a/TwitchIrcClientTests/EventSubExampleJson/EventSubWelcome.json b/TwitchIrcClientTests/EventSubExampleJson/EventSubWelcome.json new file mode 100644 index 0000000..377cb87 --- /dev/null +++ b/TwitchIrcClientTests/EventSubExampleJson/EventSubWelcome.json @@ -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 + } + } +} \ No newline at end of file diff --git a/TwitchIrcClientTests/EventSubJsonTest.cs b/TwitchIrcClientTests/EventSubJsonTest.cs new file mode 100644 index 0000000..2c63133 --- /dev/null +++ b/TwitchIrcClientTests/EventSubJsonTest.cs @@ -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(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(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(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(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(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()); + } + } +} diff --git a/TwitchIrcClientTests/ParserTest.cs b/TwitchIrcClientTests/IrcParserTest.cs similarity index 99% rename from TwitchIrcClientTests/ParserTest.cs rename to TwitchIrcClientTests/IrcParserTest.cs index e672b58..19f2019 100644 --- a/TwitchIrcClientTests/ParserTest.cs +++ b/TwitchIrcClientTests/IrcParserTest.cs @@ -7,7 +7,7 @@ using System.Diagnostics; namespace TwitchIrcClientTests { [TestClass] - public class ParserTest + public class IrcParserTest { [TestMethod] public void TestRoomstate() diff --git a/TwitchIrcClientTests/TwitchIrcClientTests.csproj b/TwitchIrcClientTests/TwitchIrcClientTests.csproj index 6c89627..df0b5e9 100644 --- a/TwitchIrcClientTests/TwitchIrcClientTests.csproj +++ b/TwitchIrcClientTests/TwitchIrcClientTests.csproj @@ -24,4 +24,22 @@ + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + +