staging work then abandoning the project, dealing with Twitch APIs isn't worth the effor

This commit is contained in:
Cameron
2024-04-06 18:28:49 -05:00
parent 19e71a5afd
commit a0ac6e69c3
29 changed files with 1311 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ using System.Diagnostics;
namespace TwitchIrcClientTests namespace TwitchIrcClientTests
{ {
[TestClass] [TestClass]
public class ParserTest public class IrcParserTest
{ {
[TestMethod] [TestMethod]
public void TestRoomstate() public void TestRoomstate()

View File

@@ -24,4 +24,22 @@
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" /> <Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
</ItemGroup> </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> </Project>