mirror of
https://github.com/Ikatono/TwitchIrcClient.git
synced 2025-10-29 04:56:12 -05:00
Compare commits
2 Commits
PubSub-Fai
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ac6e69c3 | ||
|
|
19e71a5afd |
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; }
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using TwitchIrcClient.IRC.Messages;
|
||||
using TwitchIrcClient.IRC.Messages;
|
||||
|
||||
namespace TwitchIrcClient.IRC
|
||||
{
|
||||
|
||||
@@ -14,6 +14,27 @@ namespace TwitchIrcClient.IRC.Messages
|
||||
/// </summary>
|
||||
public class Privmsg : ReceivedMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Contains metadata related to the chat badges in the badges tag.
|
||||
/// According to Twitch's documentation this should only include info about
|
||||
/// subscription length, but it also contains prediction info and who knows what else.
|
||||
/// </summary>
|
||||
public IEnumerable<string> BadgeInfo => TryGetTag("badge-info").Split(',');
|
||||
/// <summary>
|
||||
/// Contains the total number of months the user has subscribed, even if they aren't
|
||||
/// subscribed currently.
|
||||
/// </summary>
|
||||
public int SubscriptionLength
|
||||
{ get
|
||||
{
|
||||
//TODO redo this, functional style clearly didn't work here
|
||||
if (int.TryParse((BadgeInfo.FirstOrDefault(
|
||||
b => b.StartsWith("SUBSCRIBER", StringComparison.CurrentCultureIgnoreCase)) ?? "")
|
||||
.Split("/", 2).ElementAtOrDefault(1) ?? "", out int value))
|
||||
return value;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// List of chat badges. Most badges have only 1 version, but some badges like
|
||||
/// subscriber badges offer different versions of the badge depending on how
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace TwitchIrcClient.PubSub.Message
|
||||
{
|
||||
public class PubSubMessage : IDictionary<string, JsonNode?>
|
||||
{
|
||||
private readonly JsonObject Node;
|
||||
public string TypeString
|
||||
{
|
||||
get => (Node["type"] ?? throw new InvalidDataException()).ToJsonString();
|
||||
set
|
||||
{
|
||||
Node["type"] = value;
|
||||
}
|
||||
}
|
||||
//PING and PONG messages don't seem to have any data member
|
||||
public string? DataString =>
|
||||
Node["data"]?.ToJsonString();
|
||||
public string? Nonce
|
||||
{
|
||||
get => Node["nonce"]?.ToJsonString();
|
||||
set
|
||||
{
|
||||
Node["nonce"] = value;
|
||||
}
|
||||
}
|
||||
private PubSubMessage(JsonObject node)
|
||||
{
|
||||
Node = node;
|
||||
}
|
||||
public PubSubMessage() : this(new JsonObject())
|
||||
{
|
||||
|
||||
}
|
||||
public string Serialize()
|
||||
{
|
||||
return Node.ToJsonString();
|
||||
}
|
||||
public static PubSubMessage Parse(string s)
|
||||
{
|
||||
var obj = JsonNode.Parse(s)
|
||||
?? throw new InvalidDataException();
|
||||
var psm = new PubSubMessage(obj as JsonObject
|
||||
?? throw new InvalidOperationException());
|
||||
return psm;
|
||||
}
|
||||
public static PubSubMessage PING()
|
||||
{
|
||||
return new PubSubMessage
|
||||
{
|
||||
["type"] = "PING",
|
||||
};
|
||||
}
|
||||
|
||||
#region IDictionary<string, JsonNode?>
|
||||
public ICollection<string> Keys => ((IDictionary<string, JsonNode?>)Node).Keys;
|
||||
|
||||
public ICollection<JsonNode?> Values => ((IDictionary<string, JsonNode?>)Node).Values;
|
||||
|
||||
public int Count => Node.Count;
|
||||
|
||||
public bool IsReadOnly => ((ICollection<KeyValuePair<string, JsonNode?>>)Node).IsReadOnly;
|
||||
|
||||
public JsonNode? this[string key] { get => ((IDictionary<string, JsonNode?>)Node)[key]; set => ((IDictionary<string, JsonNode?>)Node)[key] = value; }
|
||||
|
||||
|
||||
|
||||
public void Add(string key, JsonNode? value)
|
||||
{
|
||||
Node.Add(key, value);
|
||||
}
|
||||
|
||||
public bool ContainsKey(string key)
|
||||
{
|
||||
return Node.ContainsKey(key);
|
||||
}
|
||||
|
||||
public bool Remove(string key)
|
||||
{
|
||||
return Node.Remove(key);
|
||||
}
|
||||
|
||||
public bool TryGetValue(string key, [MaybeNullWhen(false)] out JsonNode? value)
|
||||
{
|
||||
return ((IDictionary<string, JsonNode?>)Node).TryGetValue(key, out value);
|
||||
}
|
||||
|
||||
public void Add(KeyValuePair<string, JsonNode?> item)
|
||||
{
|
||||
Node.Add(item);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Node.Clear();
|
||||
}
|
||||
|
||||
public bool Contains(KeyValuePair<string, JsonNode?> item)
|
||||
{
|
||||
return ((ICollection<KeyValuePair<string, JsonNode?>>)Node).Contains(item);
|
||||
}
|
||||
|
||||
public void CopyTo(KeyValuePair<string, JsonNode?>[] array, int arrayIndex)
|
||||
{
|
||||
((ICollection<KeyValuePair<string, JsonNode?>>)Node).CopyTo(array, arrayIndex);
|
||||
}
|
||||
|
||||
public bool Remove(KeyValuePair<string, JsonNode?> item)
|
||||
{
|
||||
return ((ICollection<KeyValuePair<string, JsonNode?>>)Node).Remove(item);
|
||||
}
|
||||
|
||||
public IEnumerator<KeyValuePair<string, JsonNode?>> GetEnumerator()
|
||||
{
|
||||
return Node.GetEnumerator();
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
return ((IEnumerable)Node).GetEnumerator();
|
||||
}
|
||||
#endregion //IDictionary<string, JsonNode?>
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using TwitchIrcClient.PubSub.Message;
|
||||
|
||||
namespace TwitchIrcClient.PubSub
|
||||
{
|
||||
public delegate void PubSubCallback(PubSubMessage message, PubSubConnection connection);
|
||||
public record struct PubSubCallbackItem(PubSubCallback Callback, IList<string>? Types)
|
||||
{
|
||||
public readonly bool MaybeRunCallback(PubSubMessage message, PubSubConnection connection)
|
||||
{
|
||||
if (Types is null || Types.Contains(message.TypeString))
|
||||
{
|
||||
Callback?.Invoke(message, connection);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.WebSockets;
|
||||
using System.Reflection.Metadata.Ecma335;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using TwitchIrcClient.PubSub.Message;
|
||||
|
||||
namespace TwitchIrcClient.PubSub
|
||||
{
|
||||
public sealed class PubSubConnection : IDisposable
|
||||
{
|
||||
//private TcpClient Client = new();
|
||||
//private SslStream SslStream;
|
||||
//private WebSocket Socket;
|
||||
private ClientWebSocket Socket = new();
|
||||
private CancellationTokenSource TokenSource = new();
|
||||
private string? ClientId;
|
||||
private string? AuthToken;
|
||||
private DateTime? AuthExpiration;
|
||||
|
||||
public string RefreshToken { get; private set; }
|
||||
|
||||
public PubSubConnection()
|
||||
{
|
||||
|
||||
}
|
||||
//this needs to be locked for thread-safety
|
||||
public async Task SendMessageAsync(string message)
|
||||
{
|
||||
await Socket.SendAsync(Encoding.UTF8.GetBytes(message), WebSocketMessageType.Text,
|
||||
WebSocketMessageFlags.EndOfMessage | WebSocketMessageFlags.DisableCompression,
|
||||
TokenSource.Token);
|
||||
}
|
||||
public async Task SendMessageAsync(PubSubMessage message)
|
||||
{
|
||||
await SendMessageAsync(message.Serialize());
|
||||
}
|
||||
public async Task<bool> ConnectAsync()
|
||||
{
|
||||
const string url = "wss://pubsub-edge.twitch.tv";
|
||||
await Socket.ConnectAsync(new Uri(url), TokenSource.Token);
|
||||
if (Socket.State != WebSocketState.Open)
|
||||
return false;
|
||||
_ = Task.Run(HandlePings, TokenSource.Token);
|
||||
_ = Task.Run(HandleIncomingMessages, TokenSource.Token);
|
||||
return true;
|
||||
}
|
||||
public async Task<bool> GetImplicitTokenAsync(string clientId, string clientSecret,
|
||||
IEnumerable<string> scopes)
|
||||
{
|
||||
const int PORT = 17563;
|
||||
using var listener = new TcpListener(System.Net.IPAddress.Any, PORT);
|
||||
listener.Start();
|
||||
//using var client = new HttpClient();
|
||||
var scopeString = string.Join(' ', scopes);
|
||||
var stateNonce = MakeNonce();
|
||||
var url = $"https://id.twitch.tv/oauth2/authorize" +
|
||||
$"?response_type=code" +
|
||||
$"&client_id={HttpUtility.UrlEncode(clientId)}" +
|
||||
$"&redirect_uri=http://localhost:{PORT}" +
|
||||
$"&scope={HttpUtility.UrlEncode(scopeString)}" +
|
||||
$"&state={stateNonce}";
|
||||
var startInfo = new ProcessStartInfo()
|
||||
{
|
||||
//FileName = "explorer",
|
||||
//Arguments = url,
|
||||
FileName = url,
|
||||
UseShellExecute = true,
|
||||
};
|
||||
Process.Start(startInfo);
|
||||
//Console.WriteLine(url);
|
||||
using var socket = await listener.AcceptSocketAsync(TokenSource.Token);
|
||||
var arr = new byte[2048];
|
||||
var buffer = new ArraySegment<byte>(arr);
|
||||
var count = await socket.ReceiveAsync(buffer, TokenSource.Token);
|
||||
var http204 = "HTTP/1.1 204 No Content\r\n\r\n";
|
||||
var sentCount = await socket.SendAsync(Encoding.UTF8.GetBytes(http204));
|
||||
var resp = Encoding.UTF8.GetString(arr, 0, count);
|
||||
var dict =
|
||||
//get the first line of HTTP response
|
||||
HttpUtility.UrlDecode(resp.Split("\r\n").First()
|
||||
//extract location component (trim leading /?)
|
||||
.Split(' ').ElementAt(1)[2..])
|
||||
//make a dictionary
|
||||
.Split('&').Select(s =>
|
||||
{
|
||||
var p = s.Split('=');
|
||||
return new KeyValuePair<string, string>(p[0], p[1]);
|
||||
}).ToDictionary();
|
||||
if (dict["state"] != stateNonce)
|
||||
return false;
|
||||
var payload = DictToBody(new Dictionary<string,string>
|
||||
{
|
||||
["client_id"] = clientId,
|
||||
["client_secret"] = clientSecret,
|
||||
["code"] = dict["code"],
|
||||
["grant_type"] = "authorization_code",
|
||||
["redirect_uri"] = $"http://localhost:{PORT}",
|
||||
});
|
||||
var client = new HttpClient();
|
||||
var startTime = DateTime.Now;
|
||||
var httpResp = await client.PostAsync("https://id.twitch.tv/oauth2/token",
|
||||
new StringContent(payload, new MediaTypeHeaderValue("application/x-www-form-urlencoded")));
|
||||
if (httpResp is null)
|
||||
return false;
|
||||
if (httpResp.Content is null)
|
||||
return false;
|
||||
if (!httpResp.IsSuccessStatusCode)
|
||||
return false;
|
||||
var respStr = await httpResp.Content.ReadAsStringAsync();
|
||||
var json = JsonNode.Parse(respStr);
|
||||
string authToken;
|
||||
double expiresIn;
|
||||
string refreshToken;
|
||||
if ((json?["access_token"]?.AsValue().TryGetValue(out authToken) ?? false)
|
||||
&& (json?["expires_in"]?.AsValue().TryGetValue(out expiresIn) ?? false)
|
||||
&& (json?["refresh_token"]?.AsValue().TryGetValue(out refreshToken) ?? false))
|
||||
{
|
||||
AuthToken = authToken;
|
||||
RefreshToken = refreshToken;
|
||||
AuthExpiration = startTime.AddSeconds(expiresIn);
|
||||
ClientId = clientId;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
private static string DictToBody(IEnumerable<KeyValuePair<string,string>> dict)
|
||||
{
|
||||
return string.Join('&', dict.Select(p =>
|
||||
HttpUtility.UrlEncode(p.Key) + '=' + HttpUtility.UrlEncode(p.Value)));
|
||||
}
|
||||
public async Task<bool> GetDcfTokenAsync(string clientId, IEnumerable<string> scopes)
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
var scopeString = string.Join(',', scopes);
|
||||
var startTime = DateTime.Now;
|
||||
var resp = await client.PostAsync("https://id.twitch.tv/oauth2/device",
|
||||
new StringContent($"client_id={clientId}&scopes={scopeString}",
|
||||
MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded")));
|
||||
if (resp is null)
|
||||
return false;
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return false;
|
||||
if (resp.Content is null)
|
||||
return false;
|
||||
var contentString = await resp.Content.ReadAsStringAsync();
|
||||
var json = JsonNode.Parse(contentString);
|
||||
if (json is null)
|
||||
return false;
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
public async Task<bool> GetTokenAsync(string clientId, string clientSecret)
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
var startTime = DateTime.Now;
|
||||
var resp = await client.PostAsync($"https://id.twitch.tv/oauth2/token" +
|
||||
$"?client_id={clientId}&client_secret={clientSecret}" +
|
||||
$"&grant_type=client_credentials", null);
|
||||
if (resp is null)
|
||||
return false;
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return false;
|
||||
if (resp.Content is null)
|
||||
return false;
|
||||
var json = JsonNode.Parse(await resp.Content.ReadAsStringAsync());
|
||||
if (json is null)
|
||||
return false;
|
||||
var authToken = json["access_token"]?.GetValue<string>();
|
||||
var expiresIn = json["expires_in"]?.GetValue<double>();
|
||||
if (authToken is string token && expiresIn is double expires)
|
||||
{
|
||||
ClientId = clientId;
|
||||
AuthToken = token;
|
||||
AuthExpiration = startTime.AddSeconds(expires);
|
||||
}
|
||||
else
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
public async Task SubscribeAsync(IEnumerable<string> topics)
|
||||
{
|
||||
var psm = new PubSubMessage
|
||||
{
|
||||
["type"] = "LISTEN",
|
||||
["data"] = new JsonObject
|
||||
{
|
||||
//TODO there's probably a cleaner way to do this
|
||||
["topics"] = new JsonArray(topics.Select(t => (JsonValue)t).ToArray()),
|
||||
["auth_token"] = AuthToken,
|
||||
},
|
||||
["nonce"] = MakeNonce(),
|
||||
};
|
||||
await SendMessageAsync(psm);
|
||||
}
|
||||
//TODO change or dupe this to get multiple at once
|
||||
public async Task<string?> GetChannelIdFromNameAsync(string channelName)
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {AuthToken}");
|
||||
client.DefaultRequestHeaders.Add("Client-Id", ClientId);
|
||||
var resp = await client.GetAsync($"https://api.twitch.tv/helix/users?login={channelName}");
|
||||
if (resp is null)
|
||||
return null;
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return null;
|
||||
if (resp.Content is null)
|
||||
return null;
|
||||
var json = JsonNode.Parse(await resp.Content.ReadAsStringAsync());
|
||||
if (json is null)
|
||||
return null;
|
||||
var arr = json["data"];
|
||||
if (arr is null)
|
||||
return null;
|
||||
JsonArray jarr;
|
||||
try
|
||||
{
|
||||
jarr = arr.AsArray();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var item = jarr.SingleOrDefault();
|
||||
if (item is null)
|
||||
return null;
|
||||
return item["id"]?.ToString();
|
||||
}
|
||||
private static string MakeNonce(int length = 16)
|
||||
{
|
||||
var buffer = new byte[length * 2];
|
||||
Random.Shared.NextBytes(buffer);
|
||||
return Convert.ToHexString(buffer);
|
||||
}
|
||||
private AutoResetEvent PingResetEvent = new(false);
|
||||
private async Task HandlePings()
|
||||
{
|
||||
//send ping every <5 minutes
|
||||
//wait until pong or >10 seconds
|
||||
//raise error if necessary
|
||||
AddSystemCallback(new PubSubCallbackItem(
|
||||
(m, s) =>
|
||||
{
|
||||
s.PingResetEvent.Set();
|
||||
}, ["PONG"]));
|
||||
while (true)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(4 * Jitter(0.05)));
|
||||
await SendMessageAsync(PubSubMessage.PING());
|
||||
await Task.Delay(TimeSpan.FromSeconds(10));
|
||||
if (!PingResetEvent.WaitOne(0))
|
||||
{
|
||||
//timeout
|
||||
}
|
||||
}
|
||||
}
|
||||
private async void HandleIncomingMessages()
|
||||
{
|
||||
string s = "";
|
||||
while (true)
|
||||
{
|
||||
var buffer = new ArraySegment<byte>(new byte[4096]);
|
||||
var result = await Socket.ReceiveAsync(buffer, TokenSource.Token);
|
||||
s += Encoding.UTF8.GetString(buffer.Take(result.Count).ToArray());
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
IncomingMessage(PubSubMessage.Parse(s));
|
||||
s = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
private void IncomingMessage(PubSubMessage message)
|
||||
{
|
||||
RunCallbacks(message);
|
||||
}
|
||||
private void RunCallbacks(PubSubMessage message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
if (disposedValue)
|
||||
return;
|
||||
lock (SystemCallbacks)
|
||||
SystemCallbacks.ForEach(c => c.MaybeRunCallback(message, this));
|
||||
lock (UserCallbacks)
|
||||
UserCallbacks.ForEach(c => c.MaybeRunCallback(message, this));
|
||||
}
|
||||
private readonly List<PubSubCallbackItem> UserCallbacks = [];
|
||||
public void AddCallback(PubSubCallbackItem callback)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||
lock (UserCallbacks)
|
||||
UserCallbacks.Add(callback);
|
||||
}
|
||||
public bool RemoveCallback(PubSubCallbackItem callback)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||
lock (UserCallbacks)
|
||||
return UserCallbacks.Remove(callback);
|
||||
}
|
||||
private readonly List<PubSubCallbackItem> SystemCallbacks = [];
|
||||
private void AddSystemCallback(PubSubCallbackItem callback)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||
lock (SystemCallbacks)
|
||||
SystemCallbacks.Add(callback);
|
||||
}
|
||||
private bool RemoveSystemCallback(PubSubCallbackItem callback)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||
lock (SystemCallbacks)
|
||||
return SystemCallbacks.Remove(callback);
|
||||
}
|
||||
/// <summary>
|
||||
/// produces a number between -limit and limit
|
||||
/// </summary>
|
||||
/// <param name="limit"></param>
|
||||
/// <returns></returns>
|
||||
private static double Jitter(double limit)
|
||||
{
|
||||
return (Random.Shared.NextDouble() - 0.5) * 2 * limit;
|
||||
}
|
||||
#region Dispose
|
||||
private bool disposedValue;
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (!disposedValue)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
//Client?.Dispose();
|
||||
//SslStream?.Dispose();
|
||||
Socket?.Dispose();
|
||||
}
|
||||
disposedValue = true;
|
||||
}
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
#endregion //Dispose
|
||||
}
|
||||
}
|
||||
@@ -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