mirror of
https://github.com/Ikatono/TwitchIrcClient.git
synced 2025-10-29 04:56:12 -05:00
staging work then abandoning the project, dealing with Twitch APIs isn't worth the effor
This commit is contained in:
145
TwitchIrcClient/ApiClient/ApiClient.cs
Normal file
145
TwitchIrcClient/ApiClient/ApiClient.cs
Normal 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);
|
||||
}
|
||||
53
TwitchIrcClient/ApiClient/Messages/ApiTransport.cs
Normal file
53
TwitchIrcClient/ApiClient/Messages/ApiTransport.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
27
TwitchIrcClient/ApiClient/Messages/EventSubQueryRequest.cs
Normal file
27
TwitchIrcClient/ApiClient/Messages/EventSubQueryRequest.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
36
TwitchIrcClient/ApiClient/Messages/EventSubRequest.cs
Normal file
36
TwitchIrcClient/ApiClient/Messages/EventSubRequest.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
52
TwitchIrcClient/ApiClient/Messages/EventSubResponse.cs
Normal file
52
TwitchIrcClient/ApiClient/Messages/EventSubResponse.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
28
TwitchIrcClient/Authentication/DcfCodeMessage.cs
Normal file
28
TwitchIrcClient/Authentication/DcfCodeMessage.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
10
TwitchIrcClient/Authentication/UserAccessAuthentication.cs
Normal file
10
TwitchIrcClient/Authentication/UserAccessAuthentication.cs
Normal 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);
|
||||
}
|
||||
134
TwitchIrcClient/EventSub/EventSubWebsocketClient.cs
Normal file
134
TwitchIrcClient/EventSub/EventSubWebsocketClient.cs
Normal 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
|
||||
}
|
||||
}
|
||||
28
TwitchIrcClient/EventSub/Messages/Emote.cs
Normal file
28
TwitchIrcClient/EventSub/Messages/Emote.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
TwitchIrcClient/EventSub/Messages/EventSubKeepalive.cs
Normal file
21
TwitchIrcClient/EventSub/Messages/EventSubKeepalive.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
65
TwitchIrcClient/EventSub/Messages/EventSubMessage.cs
Normal file
65
TwitchIrcClient/EventSub/Messages/EventSubMessage.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
33
TwitchIrcClient/EventSub/Messages/EventSubMessageType.cs
Normal file
33
TwitchIrcClient/EventSub/Messages/EventSubMessageType.cs
Normal 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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
77
TwitchIrcClient/EventSub/Messages/EventSubNotification.cs
Normal file
77
TwitchIrcClient/EventSub/Messages/EventSubNotification.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
TwitchIrcClient/EventSub/Messages/EventSubReconnect.cs
Normal file
45
TwitchIrcClient/EventSub/Messages/EventSubReconnect.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
27
TwitchIrcClient/EventSub/Messages/EventSubRevocation.cs
Normal file
27
TwitchIrcClient/EventSub/Messages/EventSubRevocation.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
95
TwitchIrcClient/EventSub/Messages/EventSubTransport.cs
Normal file
95
TwitchIrcClient/EventSub/Messages/EventSubTransport.cs
Normal 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)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
21
TwitchIrcClient/EventSub/Messages/EventSubWelcome.cs
Normal file
21
TwitchIrcClient/EventSub/Messages/EventSubWelcome.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
106
TwitchIrcClientTests/EventSubJsonTest.cs
Normal file
106
TwitchIrcClientTests/EventSubJsonTest.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ using System.Diagnostics;
|
||||
namespace TwitchIrcClientTests
|
||||
{
|
||||
[TestClass]
|
||||
public class ParserTest
|
||||
public class IrcParserTest
|
||||
{
|
||||
[TestMethod]
|
||||
public void TestRoomstate()
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user