mirror of
https://github.com/Ikatono/TwitchIrcClient.git
synced 2025-10-29 04:56:12 -05:00
Compare commits
16 Commits
331cf6cdc0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ac6e69c3 | ||
|
|
19e71a5afd | ||
|
|
4806e50736 | ||
|
|
1bf8afc68b | ||
|
|
81651a0e59 | ||
|
|
e9bffa4dea | ||
|
|
cceae30d5e | ||
|
|
917e90558d | ||
|
|
29b5b111b2 | ||
|
|
c2ad6b9a8e | ||
|
|
9dc86478a8 | ||
|
|
8302b2639b | ||
|
|
9a69404c66 | ||
|
|
7e089e1705 | ||
|
|
a1e5d9f533 | ||
|
|
8ee231d8e7 |
1
README.md
Normal file
1
README.md
Normal file
@@ -0,0 +1 @@
|
||||
Provides a light-weight client for Twitch chatrooms over IRC. Primarily focused on receiving messages rather than sending them, TwitchIrcClient automatically requests all messages and tags from Twitch and parses these into easy to use classes. Additionally, it provides an event-like interface to receive messages of a specific type, and quality of life features like user tracking and an event for batches of user updates. Future plans include better handling of outgoing messages, providing interfaces to more tags for features like Hype Chats, and a better way to read chat messages with emotes substituted.
|
||||
@@ -3,7 +3,12 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.9.34607.119
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TwitchIrcClient", "TwitchIrcClient\TwitchIrcClient.csproj", "{465639B4-4511-473A-ADC8-23B994E3C21C}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TwitchIrcClient", "TwitchIrcClient\TwitchIrcClient.csproj", "{465639B4-4511-473A-ADC8-23B994E3C21C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TwitchIrcClientTests", "TwitchIrcClientTests\TwitchIrcClientTests.csproj", "{D1047D1F-2B92-40B3-90FE-D16E4D631333}"
|
||||
ProjectSection(ProjectDependencies) = postProject
|
||||
{465639B4-4511-473A-ADC8-23B994E3C21C} = {465639B4-4511-473A-ADC8-23B994E3C21C}
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@@ -15,6 +20,10 @@ Global
|
||||
{465639B4-4511-473A-ADC8-23B994E3C21C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{465639B4-4511-473A-ADC8-23B994E3C21C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{465639B4-4511-473A-ADC8-23B994E3C21C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D1047D1F-2B92-40B3-90FE-D16E4D631333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D1047D1F-2B92-40B3-90FE-D16E4D631333}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1047D1F-2B92-40B3-90FE-D16E4D631333}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1047D1F-2B92-40B3-90FE-D16E4D631333}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC
|
||||
namespace TwitchIrcClient.IRC
|
||||
{
|
||||
public record struct Badge(string Name, string Version)
|
||||
{
|
||||
|
||||
@@ -3,15 +3,15 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using TwitchLogger.IRC.Messages;
|
||||
using TwitchIrcClient.IRC.Messages;
|
||||
|
||||
namespace TwitchLogger.IRC
|
||||
namespace TwitchIrcClient.IRC
|
||||
{
|
||||
//public class IrcMessageEventArgs(ReceivedMessage message) : EventArgs
|
||||
//{
|
||||
// public ReceivedMessage Message = message;
|
||||
//}
|
||||
public delegate void MessageCallback(ReceivedMessage message);
|
||||
public delegate void MessageCallback(IrcConnection origin, ReceivedMessage message);
|
||||
/// <summary>
|
||||
/// Callback to be run for received messages of specific types.
|
||||
/// </summary>
|
||||
@@ -21,11 +21,11 @@ namespace TwitchLogger.IRC
|
||||
MessageCallback Callback,
|
||||
IReadOnlyList<IrcMessageType>? CallbackTypes)
|
||||
{
|
||||
public bool TryCall(ReceivedMessage message)
|
||||
public bool TryCall(IrcConnection origin, ReceivedMessage message)
|
||||
{
|
||||
if (CallbackTypes?.Contains(message.MessageType) ?? true)
|
||||
{
|
||||
Callback(message);
|
||||
Callback(origin, message);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Reflection.Metadata;
|
||||
using System.Security.Cryptography;
|
||||
@@ -11,16 +12,13 @@ using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using TwitchLogger.IRC.Messages;
|
||||
using TwitchIrcClient.IRC.Messages;
|
||||
|
||||
namespace TwitchLogger.IRC
|
||||
namespace TwitchIrcClient.IRC
|
||||
{
|
||||
/// <summary>
|
||||
/// Connects to a single Twitch chat channel via limited IRC implementation.
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="url"></param>
|
||||
/// <param name="port"></param>
|
||||
public class IrcConnection : IDisposable
|
||||
{
|
||||
public static readonly string ENDL = "\r\n";
|
||||
@@ -28,6 +26,16 @@ namespace TwitchLogger.IRC
|
||||
public string Url { get; }
|
||||
public bool Connected { get; } = false;
|
||||
public bool TrackUsers { get; }
|
||||
public bool UsesSsl { get; }
|
||||
private Roomstate? _LastRoomstate;
|
||||
public Roomstate? LastRoomstate
|
||||
{ get
|
||||
{
|
||||
if (_LastRoomstate == null)
|
||||
return null;
|
||||
return new Roomstate(_LastRoomstate);
|
||||
}
|
||||
}
|
||||
//this seems to be the only concurrentcollection that allows
|
||||
//removing specific items
|
||||
protected ConcurrentDictionary<string, byte> UserCollection = new();
|
||||
@@ -42,7 +50,8 @@ namespace TwitchLogger.IRC
|
||||
public event EventHandler<UserChangeEventArgs>? onUserChange;
|
||||
|
||||
private TcpClient Client = new();
|
||||
private NetworkStream Stream => Client.GetStream();
|
||||
//private NetworkStream Stream => Client.GetStream();
|
||||
private Stream _Stream;
|
||||
private CancellationTokenSource TokenSource = new();
|
||||
//it looks like you can't get the Token after the Source is disposed
|
||||
protected CancellationToken Token;
|
||||
@@ -51,44 +60,48 @@ namespace TwitchLogger.IRC
|
||||
private Task? UserUpdateTask;
|
||||
|
||||
public IrcConnection(string url, int port,
|
||||
RateLimiter? limiter = null, bool trackUsers = false)
|
||||
RateLimiter? limiter = null, bool trackUsers = false, bool useSsl = false)
|
||||
{
|
||||
Url = url;
|
||||
Port = port;
|
||||
Limiter = limiter;
|
||||
TrackUsers = trackUsers;
|
||||
UsesSsl = useSsl;
|
||||
Token = TokenSource.Token;
|
||||
if (TrackUsers)
|
||||
{
|
||||
AddSystemCallback(new MessageCallbackItem(m =>
|
||||
AddSystemCallback(new MessageCallbackItem((o, m) =>
|
||||
{
|
||||
if (m is NamReply nr)
|
||||
foreach (var u in nr.Users)
|
||||
UserCollection.TryAdd(u, 0);
|
||||
o.UserCollection.TryAdd(u, 0);
|
||||
else
|
||||
throw new ArgumentException(null, nameof(m));
|
||||
}, [IrcMessageType.RPL_NAMREPLY]));
|
||||
AddSystemCallback(new MessageCallbackItem(m =>
|
||||
AddSystemCallback(new MessageCallbackItem((o, m) =>
|
||||
{
|
||||
if (m is Join j)
|
||||
{
|
||||
UserCollection.TryAdd(j.Username, 0);
|
||||
UserJoin(j);
|
||||
o.UserCollection.TryAdd(j.Username, 0);
|
||||
o.UserJoin(j);
|
||||
}
|
||||
else
|
||||
throw new ArgumentException(null, nameof(m));
|
||||
}, [IrcMessageType.JOIN]));
|
||||
AddSystemCallback(new MessageCallbackItem(m =>
|
||||
AddSystemCallback(new MessageCallbackItem((o, m) =>
|
||||
{
|
||||
if (m is Part j)
|
||||
{
|
||||
UserCollection.TryRemove(j.Username, out _);
|
||||
UserLeave(j);
|
||||
o.UserCollection.TryRemove(j.Username, out _);
|
||||
o.UserLeave(j);
|
||||
}
|
||||
else
|
||||
throw new ArgumentException(null, nameof(m));
|
||||
}, [IrcMessageType.PART]));
|
||||
}
|
||||
AddSystemCallback(new MessageCallbackItem(
|
||||
(o, m) => { o._LastRoomstate = new Roomstate(m); },
|
||||
[IrcMessageType.ROOMSTATE]));
|
||||
}
|
||||
|
||||
public async Task<bool> ConnectAsync()
|
||||
@@ -99,6 +112,16 @@ namespace TwitchLogger.IRC
|
||||
await Client.ConnectAsync(Url, Port);
|
||||
if (!Client.Connected)
|
||||
return false;
|
||||
if (UsesSsl)
|
||||
{
|
||||
var stream = new SslStream(Client.GetStream());
|
||||
await stream.AuthenticateAsClientAsync(Url);
|
||||
_Stream = stream;
|
||||
}
|
||||
else
|
||||
{
|
||||
_Stream = Client.GetStream();
|
||||
}
|
||||
ListenerTask = Task.Run(ListenForInput, Token);
|
||||
UserUpdateTask = Task.Run(UpdateUsers, Token);
|
||||
return true;
|
||||
@@ -112,21 +135,71 @@ namespace TwitchLogger.IRC
|
||||
Limiter?.WaitForAvailable(Token);
|
||||
if (Token.IsCancellationRequested)
|
||||
return;
|
||||
Stream.Write(new Span<byte>(Encoding.UTF8.GetBytes(line + ENDL)));
|
||||
var bytes = Encoding.UTF8.GetBytes(line + ENDL);
|
||||
_Stream.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
//TODO make this unit testable?
|
||||
/// <summary>
|
||||
/// Construct an IRC message from parts and sends it. Does little to no validation on inputs.
|
||||
/// </summary>
|
||||
/// <param name="command"></param>
|
||||
/// <param name="parameters"></param>
|
||||
/// <param name="tags"></param>
|
||||
/// <param name="prefix"></param>
|
||||
public void SendMessage(IrcMessageType command, IEnumerable<string>? parameters = null,
|
||||
Dictionary<string, string?>? tags = null, string? prefix = null)
|
||||
{
|
||||
var message = "";
|
||||
if (tags is not null && tags.Count != 0)
|
||||
{
|
||||
message = "@" + string.Join(';',
|
||||
tags.OrderBy(p => p.Key).Select(p => $"{p.Key}={EscapeTagValue(p.Value)}"))
|
||||
+ " ";
|
||||
}
|
||||
if (prefix is not null && !string.IsNullOrWhiteSpace(prefix))
|
||||
message += ":" + prefix + " ";
|
||||
message += command.ToCommand() + " ";
|
||||
if (parameters is not null && parameters.Any())
|
||||
{
|
||||
//if ((command == IrcMessageType.NICK || command == IrcMessageType.PASS)
|
||||
// && parameters.Count() == 1)
|
||||
if (false)
|
||||
{
|
||||
message += " " + parameters.Single();
|
||||
}
|
||||
else
|
||||
{
|
||||
message += string.Join(' ', parameters.SkipLast(1));
|
||||
message += " :" + parameters.Last();
|
||||
}
|
||||
}
|
||||
SendLine(message);
|
||||
}
|
||||
private static string EscapeTagValue(string? s)
|
||||
{
|
||||
if (s is null)
|
||||
return "";
|
||||
return string.Join("", s.Select(c => c switch
|
||||
{
|
||||
';' => @"\:",
|
||||
' ' => @"\s",
|
||||
'\\' => @"\\",
|
||||
'\r' => @"\r",
|
||||
'\n' => @"\n",
|
||||
char ch => ch.ToString(),
|
||||
}));
|
||||
}
|
||||
public void Authenticate(string? user, string? pass)
|
||||
{
|
||||
if (user == null)
|
||||
user = $"justinfan{Random.Shared.NextInt64(10000):D4}";
|
||||
if (pass == null)
|
||||
pass = "pass";
|
||||
SendLine($"NICK {user}");
|
||||
SendLine($"PASS {pass}");
|
||||
user ??= $"justinfan{Random.Shared.NextInt64(10000):D4}";
|
||||
pass ??= "pass";
|
||||
SendMessage(IrcMessageType.PASS, parameters: [pass]);
|
||||
SendMessage(IrcMessageType.NICK, parameters: [user]);
|
||||
}
|
||||
public void JoinChannel(string channel)
|
||||
{
|
||||
channel = channel.TrimStart('#');
|
||||
SendLine($"JOIN #{channel}");
|
||||
SendMessage(IrcMessageType.JOIN, ["#" + channel]);
|
||||
}
|
||||
private async void ListenForInput()
|
||||
{
|
||||
@@ -134,17 +207,17 @@ namespace TwitchLogger.IRC
|
||||
byte[] buffer = new byte[5 * 1024];
|
||||
while (!Token.IsCancellationRequested)
|
||||
{
|
||||
var bytesRead = await Stream.ReadAsync(buffer, 0, buffer.Length, Token);
|
||||
var bytesRead = await _Stream.ReadAsync(buffer, Token);
|
||||
if (bytesRead > 0)
|
||||
onDataReceived(buffer, bytesRead);
|
||||
if (!Stream.CanRead)
|
||||
if (!_Stream.CanRead)
|
||||
return;
|
||||
}
|
||||
Token.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
ConcurrentBag<string> _JoinedUsers = [];
|
||||
ConcurrentBag<string> _LeftUsers = [];
|
||||
private readonly ConcurrentBag<string> _JoinedUsers = [];
|
||||
private readonly ConcurrentBag<string> _LeftUsers = [];
|
||||
private void UserJoin(Join message)
|
||||
{
|
||||
_JoinedUsers.Add(message.Username);
|
||||
@@ -243,30 +316,36 @@ namespace TwitchLogger.IRC
|
||||
public void AddCallback(MessageCallbackItem callbackItem)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||
UserCallbacks.Add(callbackItem);
|
||||
lock (UserCallbacks)
|
||||
UserCallbacks.Add(callbackItem);
|
||||
}
|
||||
public bool RemoveCallback(MessageCallbackItem callbackItem)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||
return UserCallbacks.Remove(callbackItem);
|
||||
lock (UserCallbacks)
|
||||
return UserCallbacks.Remove(callbackItem);
|
||||
}
|
||||
protected void AddSystemCallback(MessageCallbackItem callbackItem)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||
SystemCallbacks.Add(callbackItem);
|
||||
lock (SystemCallbacks)
|
||||
SystemCallbacks.Add(callbackItem);
|
||||
}
|
||||
protected bool RemoveSystemCallback(MessageCallbackItem callbackItem)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposedValue, this);
|
||||
return SystemCallbacks.Remove(callbackItem);
|
||||
lock (SystemCallbacks)
|
||||
return SystemCallbacks.Remove(callbackItem);
|
||||
}
|
||||
private void RunCallbacks(ReceivedMessage message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message, nameof(message));
|
||||
if (disposedValue)
|
||||
if (disposedValue)
|
||||
return;
|
||||
SystemCallbacks.ForEach(c => c.TryCall(message));
|
||||
UserCallbacks.ForEach(c => c.TryCall(message));
|
||||
lock (SystemCallbacks)
|
||||
SystemCallbacks.ForEach(c => c.TryCall(this, message));
|
||||
lock (UserCallbacks)
|
||||
UserCallbacks.ForEach(c => c.TryCall(this, message));
|
||||
}
|
||||
|
||||
#region Dispose
|
||||
@@ -281,6 +360,7 @@ namespace TwitchLogger.IRC
|
||||
TokenSource.Dispose();
|
||||
Client?.Dispose();
|
||||
_HeartbeatTimer?.Dispose();
|
||||
_Stream?.Dispose();
|
||||
}
|
||||
disposedValue = true;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC
|
||||
namespace TwitchIrcClient.IRC
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents the "command" of an IRC message.
|
||||
/// </summary>
|
||||
public enum IrcMessageType
|
||||
{
|
||||
//twitch standard messages
|
||||
@@ -174,12 +177,26 @@ namespace TwitchLogger.IRC
|
||||
}
|
||||
public static class IrcMessageTypeHelper
|
||||
{
|
||||
//parses a string that is either a numeric code or the command name
|
||||
/// <summary>
|
||||
/// Parses a string that is either a numeric code or the command name.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>
|
||||
/// The value range 000-999 is reserved for numeric commands, and will
|
||||
/// be converted to a numeric string when forming a message.
|
||||
/// </remarks>
|
||||
public static IrcMessageType Parse(string s)
|
||||
{
|
||||
if (int.TryParse(s, out int result))
|
||||
return (IrcMessageType)result;
|
||||
return Enum.Parse<IrcMessageType>(s);
|
||||
}
|
||||
public static string ToCommand(this IrcMessageType type)
|
||||
{
|
||||
if ((int)type >= 0 && (int)type < 1000)
|
||||
return $"{(int)type,3}";
|
||||
return type.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC
|
||||
namespace TwitchIrcClient.IRC
|
||||
{
|
||||
/// <summary>
|
||||
/// Holds key-value pairs of tags. Tag names are case-sensitive and DO NOT parse
|
||||
|
||||
@@ -5,7 +5,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class ClearChat : ReceivedMessage
|
||||
{
|
||||
@@ -30,13 +30,13 @@ namespace TwitchLogger.IRC.Messages
|
||||
/// The ID of the user that was banned or put in a timeout.
|
||||
/// </summary>
|
||||
public string TargetUserId => TryGetTag("target-user-id");
|
||||
public DateTime? TmiSentTime
|
||||
public DateTime? Timestamp
|
||||
{ get
|
||||
{
|
||||
string s = TryGetTag("tmi-sent-ts");
|
||||
if (!double.TryParse(s, out double d))
|
||||
return null;
|
||||
return DateTime.UnixEpoch.AddSeconds(d);
|
||||
return DateTime.UnixEpoch.AddMilliseconds(d);
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
@@ -52,12 +52,12 @@ namespace TwitchLogger.IRC.Messages
|
||||
/// <summary>
|
||||
/// The name of the channel that either was cleared or banned the user
|
||||
/// </summary>
|
||||
public string Channel => Parameters.First();
|
||||
public string Channel => Parameters.First().TrimStart('#');
|
||||
/// <summary>
|
||||
/// The username of the banned user, or "" if message is a
|
||||
/// channel clear.
|
||||
/// </summary>
|
||||
public string User => Parameters.ElementAtOrDefault(2) ?? "";
|
||||
public string User => Parameters.ElementAtOrDefault(1) ?? "";
|
||||
public ClearChat(ReceivedMessage message) : base(message)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.CLEARCHAT,
|
||||
|
||||
@@ -5,7 +5,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates that a message was deleted.
|
||||
@@ -28,15 +28,17 @@ namespace TwitchLogger.IRC.Messages
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public DateTime? TmiSentTime
|
||||
public DateTime? Timestamp
|
||||
{ get
|
||||
{
|
||||
string s = TryGetTag("tmi-sent-ts");
|
||||
if (!double.TryParse(s, out double d))
|
||||
return null;
|
||||
return DateTime.UnixEpoch.AddSeconds(d);
|
||||
return DateTime.UnixEpoch.AddSeconds(d / 1000);
|
||||
}
|
||||
}
|
||||
public string Channel => Parameters.FirstOrDefault("").TrimStart('#');
|
||||
public string Message => Parameters.LastOrDefault("");
|
||||
public ClearMsg(ReceivedMessage message) : base(message)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.CLEARMSG,
|
||||
|
||||
13
TwitchIrcClient/IRC/Messages/Emote.cs
Normal file
13
TwitchIrcClient/IRC/Messages/Emote.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public record struct Emote(string Name, int Position, int Length)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
132
TwitchIrcClient/IRC/Messages/GlobalUserState.cs
Normal file
132
TwitchIrcClient/IRC/Messages/GlobalUserState.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class GlobalUserState : ReceivedMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Contains metadata related to the chat badges in the badges tag.
|
||||
/// Currently, this tag contains metadata only for subscriber badges,
|
||||
/// to indicate the number of months the user has been a subscriber.
|
||||
/// </summary>
|
||||
public IEnumerable<string> BadgeInfo => TryGetTag("badge-info").Split(',');
|
||||
/// <summary>
|
||||
/// List of chat badges. Most badges have only 1 version, but some badges like
|
||||
/// subscriber badges offer different versions of the badge depending on how
|
||||
/// long the user has subscribed. To get the badge, use the Get Global Chat
|
||||
/// Badges and Get Channel Chat Badges APIs. Match the badge to the set-id field’s
|
||||
/// value in the response. Then, match the version to the id field in the list of versions.
|
||||
/// </summary>
|
||||
public List<Badge> Badges
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("badges", out string? value))
|
||||
return [];
|
||||
if (value == null)
|
||||
return [];
|
||||
List<Badge> badges = [];
|
||||
foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var spl = item.Split('/', 2);
|
||||
badges.Add(new Badge(spl[0], spl[1]));
|
||||
}
|
||||
return badges;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// The color of the user’s name in the chat room. This is a hexadecimal
|
||||
/// RGB color code in the form, #<RGB>. This tag may be empty if it is never set.
|
||||
/// </summary>
|
||||
public Color? Color
|
||||
{ get
|
||||
{
|
||||
//should have format "#RRGGBB"
|
||||
if (!MessageTags.TryGetValue("color", out string? value))
|
||||
return null;
|
||||
if (value.Length < 7)
|
||||
return null;
|
||||
int r = Convert.ToInt32(value.Substring(1, 2), 16);
|
||||
int g = Convert.ToInt32(value.Substring(3, 2), 16);
|
||||
int b = Convert.ToInt32(value.Substring(5, 2), 16);
|
||||
return System.Drawing.Color.FromArgb(r, g, b);
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// The user’s display name. This tag may be empty if it is never set.
|
||||
/// </summary>
|
||||
public string DisplayName
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("display-name", out string? value))
|
||||
return "";
|
||||
return value ?? "";
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// A comma-delimited list of IDs that identify the emote sets that the user has
|
||||
/// access to. Is always set to at least zero (0). To access the emotes in the set,
|
||||
/// use the Get Emote Sets API.
|
||||
/// </summary>
|
||||
/// <see href="https://dev.twitch.tv/docs/api/reference#get-emote-sets"/>
|
||||
public IEnumerable<int> EmoteSets
|
||||
{ get
|
||||
{
|
||||
var value = TryGetTag("emote-sets");
|
||||
foreach (var s in value.Split(','))
|
||||
{
|
||||
if (int.TryParse(s, out int num))
|
||||
yield return num;
|
||||
else
|
||||
throw new InvalidDataException();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// A Boolean value that indicates whether the user has site-wide commercial
|
||||
/// free mode enabled
|
||||
/// </summary>
|
||||
public bool Turbo
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("turbo", out string? value))
|
||||
return false;
|
||||
return value == "1";
|
||||
}
|
||||
}
|
||||
public string UserId => TryGetTag("user-id");
|
||||
/// <summary>
|
||||
/// The type of the user. Assumes a normal user if this is not provided or is invalid.
|
||||
/// </summary>
|
||||
public UserType UserType
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("user-type", out string? value))
|
||||
return UserType.Normal;
|
||||
switch (value)
|
||||
{
|
||||
case "admin":
|
||||
return UserType.Admin;
|
||||
case "global_mod":
|
||||
return UserType.GlobalMod;
|
||||
case "staff":
|
||||
return UserType.Staff;
|
||||
default:
|
||||
return UserType.Normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
public GlobalUserState(ReceivedMessage other) : base(other)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.GLOBALUSERSTATE,
|
||||
$"{nameof(GlobalUserState)} must have type {IrcMessageType.GLOBALUSERSTATE}" +
|
||||
$" but has {MessageType}");
|
||||
}
|
||||
}
|
||||
}
|
||||
41
TwitchIrcClient/IRC/Messages/HostTarget.cs
Normal file
41
TwitchIrcClient/IRC/Messages/HostTarget.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class HostTarget : ReceivedMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// The channel that’s hosting the viewers.
|
||||
/// </summary>
|
||||
public string HostingChannel => Parameters.FirstOrDefault("").TrimStart('#');
|
||||
public string ChannelBeingHosted =>
|
||||
Parameters.Last().Split(' ').First().TrimStart('-');
|
||||
/// <summary>
|
||||
/// true if the channel is now hosting another channel, false if it stopped hosting
|
||||
/// </summary>
|
||||
public bool NowHosting => !Parameters.Last().StartsWith('-');
|
||||
public int NumberOfViewers
|
||||
{ get
|
||||
{
|
||||
var s = Parameters.LastOrDefault("");
|
||||
var s2 = s.Split(' ', StringSplitOptions.TrimEntries).LastOrDefault("");
|
||||
if (int.TryParse(s2, out int value))
|
||||
return value;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public HostTarget(ReceivedMessage other) : base(other)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.HOSTTARGET,
|
||||
$"{nameof(HostTarget)} must have type {IrcMessageType.HOSTTARGET}" +
|
||||
$" but has {MessageType}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,12 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class Join : ReceivedMessage
|
||||
{
|
||||
public string Username => Prefix?.Split('!', 2).First() ?? "";
|
||||
public string ChannelName => Parameters.Single().TrimStart('#');
|
||||
public Join(ReceivedMessage message) : base(message)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.JOIN,
|
||||
|
||||
@@ -5,14 +5,14 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class NamReply : ReceivedMessage
|
||||
{
|
||||
public IEnumerable<string> Users =>
|
||||
Parameters.Last().Split(' ', StringSplitOptions.TrimEntries
|
||||
| StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
public string ChannelName => Parameters.TakeLast(2).First().TrimStart('#');
|
||||
public NamReply(ReceivedMessage message) : base(message)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.RPL_NAMREPLY,
|
||||
|
||||
@@ -6,7 +6,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class Notice : ReceivedMessage
|
||||
{
|
||||
@@ -15,16 +15,6 @@ namespace TwitchLogger.IRC.Messages
|
||||
/// </summary>
|
||||
public NoticeId? MessageId => Enum.TryParse(TryGetTag("msg-id"), out NoticeId value)
|
||||
? value : null;
|
||||
//{ get
|
||||
// {
|
||||
// string spaced = TryGetTag("msg-id").Replace('_', ' ');
|
||||
// string title = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(spaced);
|
||||
// string pascal = title.Replace(" ", "");
|
||||
// if (!Enum.TryParse(pascal, out NoticeId value))
|
||||
// return null;
|
||||
// return value;
|
||||
// }
|
||||
//}
|
||||
public string TargetUserId => TryGetTag("target-user-id");
|
||||
|
||||
public Notice(ReceivedMessage message) : base(message)
|
||||
@@ -170,166 +160,4 @@ namespace TwitchLogger.IRC.Messages
|
||||
usage_me,
|
||||
usage_mod,
|
||||
}
|
||||
//public enum NoticeId
|
||||
//{
|
||||
// AlreadyBanned,
|
||||
// AlreadyEmoteOnlyOff,
|
||||
// AlreadyEmoteOnlyOn,
|
||||
// AlreadyFollowersOff,
|
||||
// AlreadyFollowersOn,
|
||||
// AlreadyR9KOff,
|
||||
// AlreadyR9KOn,
|
||||
// AlreadySlowOff,
|
||||
// AlreadySlowOn,
|
||||
// AlreadySubsOff,
|
||||
// AlreadySubsOn,
|
||||
// AutohostReceive,
|
||||
// BadBanAdmin,
|
||||
// BadBanAnon,
|
||||
// BadBanBroadcaster,
|
||||
// BadBanMod,
|
||||
// BadBanSelf,
|
||||
// BadBanStaff,
|
||||
// BadCommercialError,
|
||||
// BadDeleteMessageBroadcaster,
|
||||
// BadDeleteMessageMod,
|
||||
// BadHostError,
|
||||
// BadHostHosting,
|
||||
// BadHostRateExceeded,
|
||||
// BadHostRejected,
|
||||
// BadHostSelf,
|
||||
// BadModBanned,
|
||||
// BadModMod,
|
||||
// BadSlowDuration,
|
||||
// BadTimeoutAdmin,
|
||||
// BadTimeoutAnon,
|
||||
// BadTimeoutBroadcaster,
|
||||
// BadTimeoutDuration,
|
||||
// BadTimeoutMod,
|
||||
// BadTimeoutSelf,
|
||||
// BadTimeoutStaff,
|
||||
// BadUnbanNoBan,
|
||||
// BadUnhostError,
|
||||
// BadUnmodMod,
|
||||
// BadVipGranteeBanned,
|
||||
// BadVipGranteeAlreadyVip,
|
||||
// BadVipMaxVipsReached,
|
||||
// BadVipAchievementIncomplete,
|
||||
// BadUnvipGranteeNotVip,
|
||||
// BanSuccess,
|
||||
// CmdsAvailable,
|
||||
// ColorChanged,
|
||||
// CommercialSuccess,
|
||||
// DeleteMessageSuccess,
|
||||
// DeleteStaffMessageSuccess,
|
||||
// EmoteOnlyOff,
|
||||
// EmoteOnlyOn,
|
||||
// FollowersOff,
|
||||
// FollowersOn,
|
||||
// FollowersOnZero,
|
||||
// HostOff,
|
||||
// HostOn,
|
||||
// HostReceive,
|
||||
// HostReceiveNoCount,
|
||||
// HostTargetWentOffline,
|
||||
// HostsRemaining,
|
||||
// InvalidUser,
|
||||
// ModSuccess,
|
||||
// MsgBanned,
|
||||
// MsgBadCharacters,
|
||||
// MsgChannelBlocked,
|
||||
// MsgChannelSuspended,
|
||||
// MsgDuplicate,
|
||||
// MsgEmoteonly,
|
||||
// MsgFollowersonly,
|
||||
// MsgFollowersonlyFollowed,
|
||||
// MsgFollowersonlyZero,
|
||||
// MsgR9K,
|
||||
// MsgRatelimit,
|
||||
// MsgRejected,
|
||||
// MsgRejectedMandatory,
|
||||
// MsgRequiresVerifiedPhoneNumber,
|
||||
// MsgSlowmode,
|
||||
// MsgSubsonly,
|
||||
// MsgSuspended,
|
||||
// MsgTimedout,
|
||||
// MsgVerifiedEmail,
|
||||
// NoHelp,
|
||||
// NoMods,
|
||||
// NoVips,
|
||||
// NotHosting,
|
||||
// NoPermission,
|
||||
// R9KOff,
|
||||
// R9KOn,
|
||||
// RaidErrorAlreadyRaiding,
|
||||
// RaidErrorForbidden,
|
||||
// RaidErrorSelf,
|
||||
// RaidErrorTooManyViewers,
|
||||
// RaidErrorUnexpected,
|
||||
// RaidNoticeMature,
|
||||
// RaidNoticeRestrictedChat,
|
||||
// RoomMods,
|
||||
// SlowOff,
|
||||
// SlowOn,
|
||||
// SubsOff,
|
||||
// SubsOn,
|
||||
// TimeoutNoTimeout,
|
||||
// TimeoutSuccess,
|
||||
// TosBan,
|
||||
// TurboOnlyColor,
|
||||
// UnavailableCommand,
|
||||
// UnbanSuccess,
|
||||
// UnmodSuccess,
|
||||
// UnraidErrorNoActiveRaid,
|
||||
// UnraidErrorUnexpected,
|
||||
// UnraidSuccess,
|
||||
// UnrecognizedCmd,
|
||||
// UntimeoutBanned,
|
||||
// UntimeoutSuccess,
|
||||
// UnvipSuccess,
|
||||
// UsageBan,
|
||||
// UsageClear,
|
||||
// UsageColor,
|
||||
// UsageCommercial,
|
||||
// UsageDisconnect,
|
||||
// UsageDelete,
|
||||
// UsageEmoteOnlyOff,
|
||||
// UsageEmoteOnlyOn,
|
||||
// UsageFollowersOff,
|
||||
// UsageFollowersOn,
|
||||
// UsageHelp,
|
||||
// UsageHost,
|
||||
// UsageMarker,
|
||||
// UsageMe,
|
||||
// UsageMod,
|
||||
// UsageMods,
|
||||
// UsageR9KOff,
|
||||
// UsageR9KOn,
|
||||
// UsageRaid,
|
||||
// UsageSlowOff,
|
||||
// UsageSlowOn,
|
||||
// UsageSubsOff,
|
||||
// UsageSubsOn,
|
||||
// UsageTimeout,
|
||||
// UsageUnban,
|
||||
// UsageUnhost,
|
||||
// UsageUnmod,
|
||||
// UsageUnraid,
|
||||
// UsageUntimeout,
|
||||
// UsageUnvip,
|
||||
// UsageUser,
|
||||
// UsageVip,
|
||||
// UsageVips,
|
||||
// UsageWhisper,
|
||||
// VipSuccess,
|
||||
// VipsSuccess,
|
||||
// WhisperBanned,
|
||||
// WhisperBannedRecipient,
|
||||
// WhisperInvalidLogin,
|
||||
// WhisperInvalidSelf,
|
||||
// WhisperLimitPerMin,
|
||||
// WhisperLimitPerSec,
|
||||
// WhisperRestricted,
|
||||
// WhisperRestrictedRecipient,
|
||||
//}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class Part : ReceivedMessage
|
||||
{
|
||||
public string Username => Prefix?.Split('!', 2).First() ?? "";
|
||||
public string ChannelName => Parameters.Single().TrimStart('#');
|
||||
public Part(ReceivedMessage message) : base(message)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.PART,
|
||||
|
||||
@@ -7,19 +7,40 @@ using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </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
|
||||
/// long the user has subscribed. To get the badge, use the Get Global Chat
|
||||
/// Badges and Get Channel Chat Badges APIs.Match the badge to the set-id field’s
|
||||
/// value in the response.Then, match the version to the id field in the list of versions.
|
||||
/// Badges and Get Channel Chat Badges APIs. Match the badge to the set-id field’s
|
||||
/// value in the response. Then, match the version to the id field in the list of versions.
|
||||
/// </summary>
|
||||
public List<Badge> Badges
|
||||
{ get
|
||||
@@ -79,6 +100,26 @@ namespace TwitchLogger.IRC.Messages
|
||||
return value ?? "";
|
||||
}
|
||||
}
|
||||
public IEnumerable<Emote> Emotes
|
||||
{ get
|
||||
{
|
||||
var tag = TryGetTag("emotes");
|
||||
foreach (var emote in tag.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var split = emote.Split(':', 2);
|
||||
Debug.Assert(split.Length == 2);
|
||||
var name = split[0];
|
||||
foreach (var indeces in split[1].Split(','))
|
||||
{
|
||||
var split2 = indeces.Split('-');
|
||||
if (!int.TryParse(split2[0], out int start) ||
|
||||
!int.TryParse(split2[1], out int end))
|
||||
throw new InvalidDataException();
|
||||
yield return new Emote(name, start, end - start + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// An ID that uniquely identifies the message.
|
||||
/// </summary>
|
||||
@@ -109,6 +150,15 @@ namespace TwitchLogger.IRC.Messages
|
||||
return value == "1";
|
||||
}
|
||||
}
|
||||
public DateTime Timestamp
|
||||
{ get
|
||||
{
|
||||
var s = TryGetTag("tmi-sent-ts");
|
||||
if (!double.TryParse(s, out double result))
|
||||
throw new InvalidDataException();
|
||||
return DateTime.UnixEpoch.AddSeconds(result / 1000);
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// A Boolean value that indicates whether the user has site-wide commercial
|
||||
/// free mode enabled
|
||||
@@ -157,6 +207,55 @@ namespace TwitchLogger.IRC.Messages
|
||||
/// A Boolean value that determines whether the user that sent the chat is a VIP.
|
||||
/// </summary>
|
||||
public bool Vip => MessageTags.ContainsKey("vip");
|
||||
/// <summary>
|
||||
/// The level of the Hype Chat. Proper values are 1-10, or 0 if not a Hype Chat.
|
||||
/// </summary>
|
||||
public int HypeChatLevel
|
||||
{ get
|
||||
{
|
||||
var value = TryGetTag("pinned-chat-paid-level");
|
||||
switch (value.ToUpper())
|
||||
{
|
||||
case "ONE":
|
||||
return 1;
|
||||
case "TWO":
|
||||
return 2;
|
||||
case "THREE":
|
||||
return 3;
|
||||
case "FOUR":
|
||||
return 4;
|
||||
case "FIVE":
|
||||
return 5;
|
||||
case "SIX":
|
||||
return 6;
|
||||
case "SEVEN":
|
||||
return 7;
|
||||
case "EIGHT":
|
||||
return 8;
|
||||
case "NINE":
|
||||
return 9;
|
||||
case "TEN":
|
||||
return 10;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// The ISO 4217 alphabetic currency code the user has sent the Hype Chat in.
|
||||
/// </summary>
|
||||
public string HypeChatCurrency => TryGetTag("pinned-chat-paid-currency");
|
||||
public decimal? HypeChatValue
|
||||
{ get
|
||||
{
|
||||
var numeric = TryGetTag("pinned-chat-paid-amount");
|
||||
var exp = TryGetTag("pinned-chat-paid-exponent");
|
||||
if (int.TryParse(numeric, out int d_numeric) && int.TryParse(exp, out int d_exp))
|
||||
return d_numeric / ((decimal)Math.Pow(10, d_exp));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public bool FirstMessage => TryGetTag("first-msg") == "1";
|
||||
public string ChatMessage => Parameters.Last();
|
||||
public Privmsg(ReceivedMessage message) : base(message)
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ using System.Reflection.Metadata.Ecma335;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
@@ -82,36 +82,35 @@ namespace TwitchLogger.IRC.Messages
|
||||
//is stripped
|
||||
if (s.StartsWith(':'))
|
||||
{
|
||||
message.Parameters.Add(s.Substring(1));
|
||||
message.Parameters.Add(s[1..]);
|
||||
}
|
||||
else
|
||||
{
|
||||
var spl_final = s.Split(" :", 2);
|
||||
var spl_initial = spl_final[0].Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var spl_initial = spl_final[0].Split(' ', StringSplitOptions.RemoveEmptyEntries
|
||||
| StringSplitOptions.TrimEntries);
|
||||
message.Parameters.AddRange(spl_initial);
|
||||
if (spl_final.Length >= 2)
|
||||
message.Parameters.Add(spl_final[1]);
|
||||
}
|
||||
}
|
||||
switch (message.MessageType)
|
||||
return message.MessageType switch
|
||||
{
|
||||
case IrcMessageType.PRIVMSG:
|
||||
return new Privmsg(message);
|
||||
case IrcMessageType.CLEARCHAT:
|
||||
return new ClearChat(message);
|
||||
case IrcMessageType.CLEARMSG:
|
||||
return new ClearMsg(message);
|
||||
case IrcMessageType.NOTICE:
|
||||
return new Notice(message);
|
||||
case IrcMessageType.JOIN:
|
||||
return new Join(message);
|
||||
case IrcMessageType.PART:
|
||||
return new Part(message);
|
||||
case IrcMessageType.RPL_NAMREPLY:
|
||||
return new NamReply(message);
|
||||
default:
|
||||
return message;
|
||||
}
|
||||
IrcMessageType.CLEARCHAT => new ClearChat(message),
|
||||
IrcMessageType.CLEARMSG => new ClearMsg(message),
|
||||
IrcMessageType.JOIN => new Join(message),
|
||||
IrcMessageType.GLOBALUSERSTATE => new GlobalUserState(message),
|
||||
IrcMessageType.HOSTTARGET => new HostTarget(message),
|
||||
IrcMessageType.NOTICE => new Notice(message),
|
||||
IrcMessageType.PART => new Part(message),
|
||||
IrcMessageType.PRIVMSG => new Privmsg(message),
|
||||
IrcMessageType.ROOMSTATE => new Roomstate(message),
|
||||
IrcMessageType.RPL_NAMREPLY => new NamReply(message),
|
||||
IrcMessageType.USERNOTICE => new UserNotice(message),
|
||||
IrcMessageType.USERSTATE => new UserState(message),
|
||||
IrcMessageType.WHISPER => new Whisper(message),
|
||||
_ => message,
|
||||
};
|
||||
}
|
||||
/// <summary>
|
||||
/// Tries to get the value of the tag.
|
||||
|
||||
100
TwitchIrcClient/IRC/Messages/Roomstate.cs
Normal file
100
TwitchIrcClient/IRC/Messages/Roomstate.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using TwitchIrcClient.IRC;
|
||||
using TwitchIrcClient.IRC.Messages;
|
||||
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class Roomstate : ReceivedMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// A Boolean value that determines whether the chat room allows only messages with emotes.
|
||||
/// </summary>
|
||||
public bool EmoteOnly
|
||||
{ get
|
||||
{
|
||||
var value = TryGetTag("emote-only");
|
||||
if (value == "1")
|
||||
return true;
|
||||
if (value == "0")
|
||||
return false;
|
||||
throw new InvalidDataException($"tag \"emote-only\" does not have a proper value: {value}");
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// An integer value that determines whether only followers can post messages in the chat room.
|
||||
/// The value indicates how long, in minutes, the user must have followed the broadcaster before
|
||||
/// posting chat messages. If the value is -1, the chat room is not restricted to followers only.
|
||||
/// </summary>
|
||||
public int FollowersOnly
|
||||
{ get
|
||||
{
|
||||
var value = TryGetTag("followers-only");
|
||||
if (!int.TryParse(value, out int result))
|
||||
throw new InvalidDataException();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// A Boolean value that determines whether a user’s messages must be unique.
|
||||
/// Applies only to messages with more than 9 characters.
|
||||
/// </summary>
|
||||
public bool UniqueMode
|
||||
{ get
|
||||
{
|
||||
var value = TryGetTag("r9k");
|
||||
if (value == "1")
|
||||
return true;
|
||||
if (value == "0")
|
||||
return false;
|
||||
throw new InvalidDataException($"tag \"r9k\" does not have a proper value: {value}");
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// An ID that identifies the chat room (channel).
|
||||
/// </summary>
|
||||
public string RoomId => TryGetTag("room-id");
|
||||
/// <summary>
|
||||
/// An integer value that determines how long, in seconds, users must wait between sending messages.
|
||||
/// </summary>
|
||||
public int Slow
|
||||
{ get
|
||||
{
|
||||
string value = TryGetTag("slow");
|
||||
if (!int.TryParse(value, out int result))
|
||||
throw new InvalidDataException($"tag \"slow\" does not have a proper value: {value}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// A Boolean value that determines whether only subscribers and moderators can chat in the chat room.
|
||||
/// </summary>
|
||||
public bool SubsOnly
|
||||
{ get
|
||||
{
|
||||
var value = TryGetTag("subs-only");
|
||||
if (value == "1")
|
||||
return true;
|
||||
if (value == "0")
|
||||
return false;
|
||||
throw new InvalidDataException($"tag \"subs-only\" does not have a proper value: {value}");
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string ChannelName => Parameters.Last().TrimStart('#');
|
||||
public Roomstate(ReceivedMessage other) : base(other)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.ROOMSTATE,
|
||||
$"{nameof(Roomstate)} must have type {IrcMessageType.ROOMSTATE}" +
|
||||
$" but has {MessageType}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.WebSockets;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Linq;
|
||||
using static System.Net.Mime.MediaTypeNames;
|
||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||
|
||||
namespace TwitchLogger.IRC.Messages
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class UserNotice : ReceivedMessage
|
||||
{
|
||||
@@ -16,7 +23,7 @@ namespace TwitchLogger.IRC.Messages
|
||||
/// List of chat badges. Most badges have only 1 version, but some badges like
|
||||
/// subscriber badges offer different versions of the badge depending on how
|
||||
/// long the user has subscribed. To get the badge, use the Get Global Chat
|
||||
/// Badges and Get Channel Chat Badges APIs.Match the badge to the set-id field’s
|
||||
/// Badges and Get Channel Chat Badges APIs. Match the badge to the set-id field’s
|
||||
/// value in the response.Then, match the version to the id field in the list of versions.
|
||||
/// </summary>
|
||||
public List<Badge> Badges
|
||||
@@ -52,6 +59,8 @@ namespace TwitchLogger.IRC.Messages
|
||||
return System.Drawing.Color.FromArgb(r, g, b);
|
||||
}
|
||||
}
|
||||
public string Channel => Parameters.FirstOrDefault("").TrimStart('#');
|
||||
public string Message => Parameters.ElementAtOrDefault(1) ?? "";
|
||||
/// <summary>
|
||||
/// The user’s display name. This tag may be empty if it is never set.
|
||||
/// </summary>
|
||||
@@ -63,12 +72,20 @@ namespace TwitchLogger.IRC.Messages
|
||||
return value ?? "";
|
||||
}
|
||||
}
|
||||
public IEnumerable<Emote> Emotes
|
||||
{ get
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// An ID that uniquely identifies the message.
|
||||
/// </summary>
|
||||
public string Id => TryGetTag("id");
|
||||
public UserNoticeType? UserNoticeType => Enum.TryParse(TryGetTag("msg-id"), out UserNoticeType type)
|
||||
? type : null;
|
||||
public string Login => TryGetTag("login");
|
||||
/// <summary>
|
||||
/// Whether the user is a moderator in this channel
|
||||
/// </summary>
|
||||
@@ -118,24 +135,160 @@ namespace TwitchLogger.IRC.Messages
|
||||
return value ?? "";
|
||||
}
|
||||
}
|
||||
public UserType UserType
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string SystemMessage => TryGetTag("system-msg");
|
||||
/// <summary>
|
||||
/// When the Twitch IRC server received the message
|
||||
/// </summary>
|
||||
public DateTime Timestamp
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("user-type", out string? value))
|
||||
return UserType.Normal;
|
||||
switch (value)
|
||||
if (double.TryParse(TryGetTag("tmi-sent-ts"), out double value))
|
||||
return DateTime.UnixEpoch.AddMilliseconds(value);
|
||||
throw new InvalidDataException();
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.raid"/> notices.
|
||||
/// The display name of the broadcaster raiding this channel.
|
||||
/// </summary>
|
||||
public string RaidingChannelDisplayName => TryGetTag("msg-param-displayName");
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.raid"/> notices.
|
||||
/// The login name of the broadcaster raiding this channel.
|
||||
/// </summary>
|
||||
public string RaidingChannelLogin => TryGetTag("msg-param-login");
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.anongiftpaidupgrade"/> and <see cref="UserNoticeType.giftpaidupgrade"/> notices.
|
||||
/// The subscriptions promo, if any, that is ongoing (for example, Subtember 2018).
|
||||
/// </summary>
|
||||
public string SubscriptionPromoName => TryGetTag("msg-param-promo-name");
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.anongiftpaidupgrade"/> and
|
||||
/// <see cref="UserNoticeType.giftpaidupgrade"/> notices.
|
||||
/// The number of gifts the gifter has given during the promo indicated by <see cref="SubscriptionPromoName"/>.
|
||||
/// </summary>
|
||||
public int SubscriptionPromoCount => int.TryParse(TryGetTag("msg-param-promo-gift-total"),
|
||||
out int value) ? value : 0;
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.subgift"/> notices.
|
||||
/// The display name of the subscription gift recipient.
|
||||
/// </summary>
|
||||
public string RecipientDisplayName => TryGetTag("msg-param-recipient-display-name");
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.subgift"/> notices.
|
||||
/// The user ID of the subscription gift recipient.
|
||||
/// </summary>
|
||||
public string RecipientId => TryGetTag("msg-param-recipient-id");
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.subgift"/> notices.
|
||||
/// The user name of the subscription gift recipient.
|
||||
/// </summary>
|
||||
public string RecipientUsername => TryGetTag("msg-param-recipient-user-name");
|
||||
/// <summary>
|
||||
/// Only Included in <see cref="UserNoticeType.sub"/>, <see cref="UserNoticeType.resub"/>,
|
||||
/// and <see cref="UserNoticeType.subgift"/>.
|
||||
/// Either "msg-param-cumulative-months" or "msg-param-months" depending
|
||||
/// on the notice type.
|
||||
/// </summary>
|
||||
public int TotalMonths
|
||||
{ get
|
||||
{
|
||||
var s1 = TryGetTag("msg-param-cumulative-months");
|
||||
var s2 = TryGetTag("msg-param-months");
|
||||
if (int.TryParse(s1, out int value1))
|
||||
return value1;
|
||||
if (int.TryParse(s2, out int value2))
|
||||
return value2;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.sub"/> and <see cref="UserNoticeType.resub"/> notices.
|
||||
/// A Boolean value that indicates whether the user wants their streaks shared.
|
||||
/// Is "false" for other message types.
|
||||
/// </summary>
|
||||
public bool ShouldShareStreak => TryGetTag("msg-param-should-share-streak")
|
||||
== "1" ? true : false;
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.sub"/> and <see cref="UserNoticeType.resub"/> notices.
|
||||
/// The number of consecutive months the user has subscribed.
|
||||
/// This is zero(0) if <see cref="ShouldShareStreak"/> is 0.
|
||||
/// </summary>
|
||||
public int StreakMonths => int.TryParse(TryGetTag("msg-param-streak-months"),
|
||||
out int value) ? value : 0;
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.sub"/>, <see cref="UserNoticeType.resub"/>
|
||||
/// and <see cref="UserNoticeType.subgift"/> notices.
|
||||
/// </summary>
|
||||
public SubType SubPlan
|
||||
{ get
|
||||
{
|
||||
switch (TryGetTag("msg-param-sub-plan").ToUpper())
|
||||
{
|
||||
case "admin":
|
||||
return UserType.Admin;
|
||||
case "global_mod":
|
||||
return UserType.GlobalMod;
|
||||
case "staff":
|
||||
return UserType.Staff;
|
||||
case "PRIME":
|
||||
return SubType.Prime;
|
||||
case "1000":
|
||||
return SubType.T1;
|
||||
case "2000":
|
||||
return SubType.T2;
|
||||
case "3000":
|
||||
return SubType.T3;
|
||||
default:
|
||||
return UserType.Normal;
|
||||
return SubType.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.sub"/>, <see cref="UserNoticeType.resub"/>,
|
||||
/// and <see cref="UserNoticeType.subgift"/> notices.
|
||||
/// The display name of the subscription plan. This may be a default name or one created
|
||||
/// by the channel owner.
|
||||
/// </summary>
|
||||
public string SubPlanName => TryGetTag("msg-param-sub-plan-name");
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.raid"/> notices.
|
||||
/// The number of viewers raiding this channel from the broadcaster’s channel.
|
||||
/// </summary>
|
||||
public int ViewerCount => int.TryParse(TryGetTag("msg-param-viewerCount"),
|
||||
out int value) ? value : 0;
|
||||
/// <summary>
|
||||
/// The type of user sending the whisper message.
|
||||
/// </summary>
|
||||
public UserType UserType
|
||||
{ get
|
||||
{
|
||||
var value = TryGetTag("user-type");
|
||||
return value.ToUpper() switch
|
||||
{
|
||||
"ADMIN" => UserType.Admin,
|
||||
"GLOBAL_MOD" => UserType.GlobalMod,
|
||||
"STAFF" => UserType.Staff,
|
||||
"" => UserType.Normal,
|
||||
_ => UserType.Normal,
|
||||
};
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.ritual"/> notices.
|
||||
/// The name of the ritual being celebrated.
|
||||
/// </summary>
|
||||
public RitualType RitualType => Enum.TryParse(TryGetTag("msg-param-ritual-name"),
|
||||
out RitualType rt) ? rt : RitualType.None;
|
||||
//TODO possibly deprecate and add an int version in the future if all tiers are numeric
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.bitsbadgetier"/> notices.
|
||||
/// The tier of the Bits badge the user just earned. For example, 100, 1000, or 10000.
|
||||
/// </summary>
|
||||
public string Threshold => TryGetTag("msg-param-threshold");
|
||||
/// <summary>
|
||||
/// Included only with <see cref="UserNoticeType.subgift"/> notices.
|
||||
/// The number of months gifted as part of a single, multi-month gift.
|
||||
/// </summary>
|
||||
public int GiftMonths => int.TryParse(TryGetTag("msg-param-gift-months"),
|
||||
out int value) ? value : 0;
|
||||
public UserNotice(ReceivedMessage message) : base(message)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.USERNOTICE,
|
||||
@@ -145,16 +298,31 @@ namespace TwitchLogger.IRC.Messages
|
||||
}
|
||||
public enum UserNoticeType
|
||||
{
|
||||
sub,
|
||||
resub,
|
||||
subgift,
|
||||
submysterygift,
|
||||
giftpaidupgrade,
|
||||
rewardgift,
|
||||
anongiftpaidupgrade,
|
||||
raid,
|
||||
unraid,
|
||||
ritual,
|
||||
bitsbadgetier,
|
||||
sub = 0,
|
||||
resub = 1,
|
||||
subgift = 2,
|
||||
submysterygift = 3,
|
||||
giftpaidupgrade = 4,
|
||||
rewardgift = 5,
|
||||
anongiftpaidupgrade = 6,
|
||||
raid = 7,
|
||||
unraid = 8,
|
||||
ritual = 9,
|
||||
bitsbadgetier = 10,
|
||||
}
|
||||
public enum RitualType
|
||||
{
|
||||
new_chatter = 0,
|
||||
|
||||
None = int.MinValue,
|
||||
}
|
||||
public enum SubType
|
||||
{
|
||||
Prime = 0,
|
||||
T1 = 1,
|
||||
T2 = 2,
|
||||
T3 = 3,
|
||||
|
||||
None = int.MinValue,
|
||||
}
|
||||
}
|
||||
|
||||
119
TwitchIrcClient/IRC/Messages/UserState.cs
Normal file
119
TwitchIrcClient/IRC/Messages/UserState.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class UserState : ReceivedMessage
|
||||
{
|
||||
public string Channel => Parameters.FirstOrDefault("").TrimStart('#');
|
||||
/// <summary>
|
||||
/// The color of the user’s name in the chat room. This is a hexadecimal
|
||||
/// RGB color code in the form, #<RGB>. This tag may be empty if it is never set.
|
||||
/// </summary>
|
||||
public Color? Color
|
||||
{ get
|
||||
{
|
||||
//should have format "#RRGGBB"
|
||||
if (!MessageTags.TryGetValue("color", out string? value))
|
||||
return null;
|
||||
if (value.Length < 7)
|
||||
return null;
|
||||
int r = Convert.ToInt32(value.Substring(1, 2), 16);
|
||||
int g = Convert.ToInt32(value.Substring(3, 2), 16);
|
||||
int b = Convert.ToInt32(value.Substring(5, 2), 16);
|
||||
return System.Drawing.Color.FromArgb(r, g, b);
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// The user’s display name, escaped as described in the IRCv3 spec.
|
||||
/// </summary>
|
||||
public string DisplayName => TryGetTag("display-name");
|
||||
/// <summary>
|
||||
/// A comma-delimited list of IDs that identify the emote sets that the user has
|
||||
/// access to. Is always set to at least zero (0). To access the emotes in the set,
|
||||
/// use the Get Emote Sets API.
|
||||
/// </summary>
|
||||
/// <see href="https://dev.twitch.tv/docs/api/reference#get-emote-sets"/>
|
||||
public IEnumerable<int> EmoteSets
|
||||
{ get
|
||||
{
|
||||
var value = TryGetTag("emote-sets");
|
||||
foreach (var s in value.Split(','))
|
||||
{
|
||||
if (int.TryParse(s, out int num))
|
||||
yield return num;
|
||||
else
|
||||
throw new InvalidDataException();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// If a privmsg was sent, an ID that uniquely identifies the message.
|
||||
/// </summary>
|
||||
public string Id => TryGetTag("id");
|
||||
/// <summary>
|
||||
/// A Boolean value that determines whether the user is a moderator.
|
||||
/// </summary>
|
||||
public bool Moderator
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("mod", out string? value))
|
||||
return false;
|
||||
return value == "1";
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Whether the user is subscribed to the channel
|
||||
/// </summary>
|
||||
public bool Subscriber
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("subscriber", out string? value))
|
||||
return false;
|
||||
return value == "1";
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// A Boolean value that indicates whether the user has site-wide commercial
|
||||
/// free mode enabled
|
||||
/// </summary>
|
||||
public bool Turbo
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("turbo", out string? value))
|
||||
return false;
|
||||
return value == "1";
|
||||
}
|
||||
}
|
||||
public UserType UserType
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("user-type", out string? value))
|
||||
return UserType.Normal;
|
||||
switch (value)
|
||||
{
|
||||
case "admin":
|
||||
return UserType.Admin;
|
||||
case "global_mod":
|
||||
return UserType.GlobalMod;
|
||||
case "staff":
|
||||
return UserType.Staff;
|
||||
default:
|
||||
return UserType.Normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public UserState(ReceivedMessage other) : base(other)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.USERSTATE,
|
||||
$"{nameof(UserState)} must have type {IrcMessageType.USERSTATE}" +
|
||||
$" but has {MessageType}");
|
||||
}
|
||||
}
|
||||
}
|
||||
140
TwitchIrcClient/IRC/Messages/Whisper.cs
Normal file
140
TwitchIrcClient/IRC/Messages/Whisper.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchIrcClient.IRC.Messages
|
||||
{
|
||||
public class Whisper : ReceivedMessage
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// List of chat badges. Most badges have only 1 version, but some badges like
|
||||
/// subscriber badges offer different versions of the badge depending on how
|
||||
/// long the user has subscribed. To get the badge, use the Get Global Chat
|
||||
/// Badges and Get Channel Chat Badges APIs. Match the badge to the set-id field’s
|
||||
/// value in the response.Then, match the version to the id field in the list of versions.
|
||||
/// </summary>
|
||||
public List<Badge> Badges
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("badges", out string? value))
|
||||
return [];
|
||||
if (value == null)
|
||||
return [];
|
||||
List<Badge> badges = [];
|
||||
foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var spl = item.Split('/', 2);
|
||||
badges.Add(new Badge(spl[0], spl[1]));
|
||||
}
|
||||
return badges;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// The color of the user’s name in the chat room. This is a hexadecimal
|
||||
/// RGB color code in the form, #<RGB>. This tag may be empty if it is never set.
|
||||
/// </summary>
|
||||
public Color? Color
|
||||
{ get
|
||||
{
|
||||
//should have format "#RRGGBB"
|
||||
if (!MessageTags.TryGetValue("color", out string? value))
|
||||
return null;
|
||||
if (value.Length < 7)
|
||||
return null;
|
||||
int r = Convert.ToInt32(value.Substring(1, 2), 16);
|
||||
int g = Convert.ToInt32(value.Substring(3, 2), 16);
|
||||
int b = Convert.ToInt32(value.Substring(5, 2), 16);
|
||||
return System.Drawing.Color.FromArgb(r, g, b);
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// The user’s display name. This tag may be empty if it is never set.
|
||||
/// </summary>
|
||||
public string DisplayName
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("display-name", out string? value))
|
||||
return "";
|
||||
return value ?? "";
|
||||
}
|
||||
}
|
||||
public IEnumerable<Emote> Emotes
|
||||
{ get
|
||||
{
|
||||
var tag = TryGetTag("emotes");
|
||||
foreach (var emote in tag.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var split = emote.Split(':', 2);
|
||||
Debug.Assert(split.Length == 2);
|
||||
var name = split[0];
|
||||
foreach (var indeces in split[1].Split(','))
|
||||
{
|
||||
var split2 = indeces.Split('-');
|
||||
if (!int.TryParse(split2[0], out int start) ||
|
||||
!int.TryParse(split2[1], out int end))
|
||||
throw new InvalidDataException();
|
||||
yield return new Emote(name, start, end - start + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// An ID that uniquely identifies the whisper message.
|
||||
/// </summary>
|
||||
public string MessageId => TryGetTag("message-id");
|
||||
/// <summary>
|
||||
/// An ID that uniquely identifies the whisper thread.
|
||||
/// The ID is in the form, <smaller-value-user-id>_<larger-value-user-id>.
|
||||
/// </summary>
|
||||
public string ThreadId => TryGetTag("thread-id");
|
||||
/// <summary>
|
||||
/// A Boolean value that indicates whether the user has site-wide commercial
|
||||
/// free mode enabled
|
||||
/// </summary>
|
||||
public bool Turbo
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("turbo", out string? value))
|
||||
return false;
|
||||
return value == "1";
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// The ID of the user sending the whisper message.
|
||||
/// </summary>
|
||||
public string UserId => TryGetTag("user-id");
|
||||
public string Message => Parameters.LastOrDefault("");
|
||||
/// <summary>
|
||||
/// The type of the user. Assumes a normal user if this is not provided or is invalid.
|
||||
/// </summary>
|
||||
public UserType UserType
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("user-type", out string? value))
|
||||
return UserType.Normal;
|
||||
switch (value)
|
||||
{
|
||||
case "admin":
|
||||
return UserType.Admin;
|
||||
case "global_mod":
|
||||
return UserType.GlobalMod;
|
||||
case "staff":
|
||||
return UserType.Staff;
|
||||
default:
|
||||
return UserType.Normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
public Whisper(ReceivedMessage other) : base(other)
|
||||
{
|
||||
Debug.Assert(MessageType == IrcMessageType.WHISPER,
|
||||
$"{nameof(Whisper)} must have type {IrcMessageType.WHISPER}" +
|
||||
$" but has {MessageType}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC
|
||||
namespace TwitchIrcClient.IRC
|
||||
{
|
||||
/// <summary>
|
||||
/// Prevents sending too many messages in a time period. A single rate limiter can
|
||||
@@ -29,28 +29,35 @@ namespace TwitchLogger.IRC
|
||||
Timer.Start();
|
||||
}
|
||||
|
||||
public void WaitForAvailable(CancellationToken? token = null)
|
||||
public bool WaitForAvailable(CancellationToken? token = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (token is CancellationToken actualToken)
|
||||
Semaphore.Wait(actualToken);
|
||||
else
|
||||
Semaphore.Wait();
|
||||
lock (Semaphore)
|
||||
{
|
||||
if (token is CancellationToken actualToken)
|
||||
Semaphore.Wait(actualToken);
|
||||
else
|
||||
Semaphore.Wait();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
//caller is responsible for checking whether connection is cancelled before trying to send
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public bool WaitForAvailable(TimeSpan timeout, CancellationToken? token = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (token is CancellationToken actualToken)
|
||||
return Semaphore.Wait(timeout, actualToken);
|
||||
else
|
||||
return Semaphore.Wait(timeout);
|
||||
lock (Semaphore)
|
||||
{
|
||||
if (token is CancellationToken actualToken)
|
||||
return Semaphore.Wait(timeout, actualToken);
|
||||
else
|
||||
return Semaphore.Wait(timeout);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -61,10 +68,13 @@ namespace TwitchLogger.IRC
|
||||
{
|
||||
try
|
||||
{
|
||||
if (token is CancellationToken actualToken)
|
||||
return Semaphore.Wait(millis, actualToken);
|
||||
else
|
||||
return Semaphore.Wait(millis);
|
||||
lock (Semaphore)
|
||||
{
|
||||
if (token is CancellationToken actualToken)
|
||||
return Semaphore.Wait(millis, actualToken);
|
||||
else
|
||||
return Semaphore.Wait(millis);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -76,30 +86,29 @@ namespace TwitchLogger.IRC
|
||||
{
|
||||
try
|
||||
{
|
||||
Semaphore.Release(MessageLimit);
|
||||
}
|
||||
catch (SemaphoreFullException)
|
||||
{
|
||||
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
|
||||
lock (Semaphore)
|
||||
{
|
||||
var count = MessageLimit - Semaphore.CurrentCount;
|
||||
if (count > 0)
|
||||
Semaphore.Release(count);
|
||||
}
|
||||
}
|
||||
catch (SemaphoreFullException) { }
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
|
||||
#region RateLimiter Dispose
|
||||
private bool disposedValue;
|
||||
//https://stackoverflow.com/questions/8927878/what-is-the-correct-way-of-adding-thread-safety-to-an-idisposable-object
|
||||
private int _disposedCount;
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!disposedValue)
|
||||
if (Interlocked.Increment(ref _disposedCount) == 1)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
Semaphore?.Dispose();
|
||||
Timer?.Dispose();
|
||||
}
|
||||
disposedValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TwitchLogger.IRC
|
||||
namespace TwitchIrcClient.IRC
|
||||
{
|
||||
public enum UserType
|
||||
{
|
||||
|
||||
@@ -1 +1,60 @@
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection.Metadata;
|
||||
using System.Security.AccessControl;
|
||||
using System.Threading.Channels;
|
||||
using TwitchIrcClient.IRC;
|
||||
using TwitchIrcClient.IRC.Messages;
|
||||
|
||||
RateLimiter limiter = new(20, 30);
|
||||
bool ssl = true;
|
||||
async Task<IrcConnection> CreateConnection(string channel)
|
||||
{
|
||||
IrcConnection connection = ssl
|
||||
? connection = new IrcConnection("irc.chat.twitch.tv", 6697, limiter, true, true)
|
||||
: connection = new IrcConnection("irc.chat.twitch.tv", 6667, limiter, true, false);
|
||||
connection.AddCallback(new MessageCallbackItem(
|
||||
(o, m) =>
|
||||
{
|
||||
if (m is Privmsg priv)
|
||||
{
|
||||
if (priv.Bits > 0)
|
||||
lock (Console.Out)
|
||||
Console.WriteLine($"{priv.DisplayName}: {priv.Bits}{Environment.NewLine}");
|
||||
}
|
||||
else
|
||||
throw new ArgumentException("Received an unrequested message type", nameof(m));
|
||||
}, [IrcMessageType.PRIVMSG]));
|
||||
connection.onUserChange += (object? o, UserChangeEventArgs args) =>
|
||||
{
|
||||
lock (Console.Out)
|
||||
{
|
||||
var resetColor = Console.BackgroundColor;
|
||||
Console.BackgroundColor = ConsoleColor.DarkGreen;
|
||||
Console.WriteLine(string.Join(", ", args.Joined.Order()));
|
||||
Console.BackgroundColor = ConsoleColor.DarkRed;
|
||||
Console.WriteLine(string.Join(", ", args.Left.Order()));
|
||||
Console.BackgroundColor = resetColor;
|
||||
Console.WriteLine();
|
||||
}
|
||||
};
|
||||
if (!await connection.ConnectAsync())
|
||||
{
|
||||
Console.WriteLine("failed to connect");
|
||||
Environment.Exit(-1);
|
||||
}
|
||||
connection.Authenticate(null, null);
|
||||
connection.SendLine("CAP REQ :twitch.tv/commands twitch.tv/membership twitch.tv/tags");
|
||||
connection.JoinChannel(channel);
|
||||
return connection;
|
||||
}
|
||||
Console.Write("Channel: ");
|
||||
var channelName = Console.ReadLine();
|
||||
ArgumentNullException.ThrowIfNullOrWhiteSpace(channelName, nameof(channelName));
|
||||
var connection = await CreateConnection(channelName);
|
||||
while (true)
|
||||
{
|
||||
//all the work happens in other threads
|
||||
//specifically the threadpool used by Task.Run for
|
||||
//the tasks owned by the IrcConnection
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
544
TwitchIrcClientTests/IrcParserTest.cs
Normal file
544
TwitchIrcClientTests/IrcParserTest.cs
Normal file
@@ -0,0 +1,544 @@
|
||||
using System.Drawing;
|
||||
using System;
|
||||
using TwitchIrcClient.IRC;
|
||||
using TwitchIrcClient.IRC.Messages;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace TwitchIrcClientTests
|
||||
{
|
||||
[TestClass]
|
||||
public class IrcParserTest
|
||||
{
|
||||
[TestMethod]
|
||||
public void TestRoomstate()
|
||||
{
|
||||
var ROOMSTATE = "@emote-only=0;followers-only=-1;r9k=0;room-id=321654987;slow=0;subs-only=0 :tmi.twitch.tv ROOMSTATE #channelname";
|
||||
var _roomstate = ReceivedMessage.Parse(ROOMSTATE);
|
||||
Assert.AreEqual(IrcMessageType.ROOMSTATE, _roomstate.MessageType);
|
||||
if (_roomstate is Roomstate roomstate)
|
||||
{
|
||||
Assert.AreEqual("channelname", roomstate.ChannelName);
|
||||
Assert.IsTrue(roomstate.MessageTags.TryGetValue("emote-only", out string emoteOnly));
|
||||
Assert.AreEqual("0", emoteOnly);
|
||||
Assert.IsFalse(roomstate.EmoteOnly);
|
||||
Assert.IsTrue(roomstate.MessageTags.TryGetValue("followers-only", out string followersOnly));
|
||||
Assert.AreEqual("-1", followersOnly);
|
||||
Assert.AreEqual(-1, roomstate.FollowersOnly);
|
||||
Assert.IsTrue(roomstate.MessageTags.TryGetValue("r9k", out string r9k));
|
||||
Assert.AreEqual("0", r9k);
|
||||
Assert.IsFalse(roomstate.UniqueMode);
|
||||
Assert.IsTrue(roomstate.MessageTags.TryGetValue("room-id", out string roomId));
|
||||
Assert.AreEqual("321654987", roomId);
|
||||
Assert.AreEqual("321654987", roomstate.RoomId);
|
||||
Assert.IsTrue(roomstate.MessageTags.TryGetValue("slow", out string slow));
|
||||
Assert.AreEqual("0", slow);
|
||||
Assert.AreEqual(0, roomstate.Slow);
|
||||
Assert.IsTrue(roomstate.MessageTags.TryGetValue("subs-only", out string subsOnly));
|
||||
Assert.AreEqual("0", subsOnly);
|
||||
Assert.AreEqual(false, roomstate.SubsOnly);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
[TestMethod]
|
||||
public void TestNamreply()
|
||||
{
|
||||
var NAMREPLY = ":justinfan7550.tmi.twitch.tv 353 justinfan7550 = #channelname :user1 user2 user3 user4 user5";
|
||||
var _namReply = ReceivedMessage.Parse(NAMREPLY);
|
||||
Assert.AreEqual(IrcMessageType.RPL_NAMREPLY, _namReply.MessageType);
|
||||
if (_namReply is NamReply namReply)
|
||||
{
|
||||
Assert.AreEqual("channelname", namReply.ChannelName);
|
||||
Assert.IsTrue("user1 user2 user3 user4 user5".Split().Order()
|
||||
.SequenceEqual(namReply.Users.Order()));
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
[TestMethod]
|
||||
public void TestJoin()
|
||||
{
|
||||
var JOIN = ":newuser!newuser@newuser.tmi.twitch.tv JOIN #channelname";
|
||||
var _join = ReceivedMessage.Parse(JOIN);
|
||||
Assert.AreEqual(IrcMessageType.JOIN, _join.MessageType);
|
||||
if (_join is Join join)
|
||||
{
|
||||
Assert.AreEqual("channelname", join.ChannelName);
|
||||
Assert.AreEqual("newuser", join.Username);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
[TestMethod]
|
||||
public void TestPart()
|
||||
{
|
||||
var PART = ":leavinguser!leavinguser@leavinguser.tmi.twitch.tv PART #channelname";
|
||||
var _part = ReceivedMessage.Parse(PART);
|
||||
Assert.AreEqual(IrcMessageType.PART, _part.MessageType);
|
||||
if (_part is Part part)
|
||||
{
|
||||
Assert.AreEqual("channelname", part.ChannelName);
|
||||
Assert.AreEqual("leavinguser", part.Username);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
[TestMethod]
|
||||
public void TestPrivmsg()
|
||||
{
|
||||
var PRIVMSG = "@badge-info=subscriber/1;badges=subscriber/0;client-nonce=202e32113a3768963eded865e051fc5b;color=#AAAAFF;" +
|
||||
"display-name=ChattingUser;emotes=;first-msg=0;flags=;id=24fe75a1-06a5-4078-a31f-cf615107b2a2;mod=0;returning-chatter=0;" +
|
||||
"room-id=321654987;subscriber=1;tmi-sent-ts=1710920497332;turbo=0;user-id=01234567;user-type= " +
|
||||
":chattinguser!chattinguser@chattinguser.tmi.twitch.tv PRIVMSG #channelname :This is a test chat message";
|
||||
var CHEER = "@badge-info=subscriber/9;badges=subscriber/9,twitch-recap-2023/1;bits=100;color=#FF0000;display-name=CheeringUser;" +
|
||||
//I haven't fixed this emote tag after rewriting the message
|
||||
"emotes=emotesv2_44a39d65e08f43adac871a80e9b96d85:17-24;first-msg=1;flags=;id=5eab1319-5d46-4c55-be29-33c2f834e42e;mod=0;" +
|
||||
"returning-chatter=0;room-id=321654987;subscriber=0;tmi-sent-ts=1710920826069;turbo=1;user-id=012345678;user-type=;vip " +
|
||||
":cheeringuser!cheeringuser@cheeringuser.tmi.twitch.tv PRIVMSG #channelname :This includes a cheer Cheer100";
|
||||
var ESCAPE = @"@escaped=\:\s\\\r\n\a\b\c PRIVMSG #channelname :message";
|
||||
var EMOTES = @"@badge-info=subscriber/4;badges=subscriber/3;client-nonce=2cc8bb73f5d946b22ec2905c8ccdee7a;color=#1E90FF;" +
|
||||
@"display-name=Ikatono;emote-only=1;emotes=emotesv2_4f3ee26e385b46aa88d5f45307489939:0-12,14-26/emotesv2_9046ad54f76f42389edb4cc828b1b057" +
|
||||
@":28-35,37-44;first-msg=0;flags=;id=08424675-217f-44bc-b9c0-24e2e2dd5f33;mod=0;returning-chatter=0;room-id=230151386;" +
|
||||
@"subscriber=1;tmi-sent-ts=1711136008625;turbo=0;user-id=24866530;user-type= :ikatono!ikatono@ikatono.tmi.twitch.tv " +
|
||||
@"PRIVMSG #bajiru_en :bajiBUFFERING bajiBUFFERING bajiBONK bajiBONK";
|
||||
|
||||
var _priv = ReceivedMessage.Parse(PRIVMSG);
|
||||
Assert.AreEqual(IrcMessageType.PRIVMSG, _priv.MessageType);
|
||||
if (_priv is Privmsg priv)
|
||||
{
|
||||
Assert.AreEqual("This is a test chat message", priv.ChatMessage);
|
||||
Assert.AreEqual(0, priv.Bits);
|
||||
Assert.AreEqual("ChattingUser", priv.DisplayName);
|
||||
Assert.AreEqual(Color.FromArgb(170, 170, 255), priv.Color);
|
||||
Assert.AreEqual("24fe75a1-06a5-4078-a31f-cf615107b2a2", priv.Id);
|
||||
Assert.IsFalse(priv.FirstMessage);
|
||||
Assert.IsFalse(priv.Moderator);
|
||||
Assert.AreEqual("321654987", priv.RoomId);
|
||||
Assert.IsTrue(priv.Subscriber);
|
||||
Assert.IsFalse(priv.Turbo);
|
||||
Assert.AreEqual("01234567", priv.UserId);
|
||||
Assert.AreEqual(UserType.Normal, priv.UserType);
|
||||
Assert.IsFalse(priv.Vip);
|
||||
Assert.AreEqual(new DateTime(2024, 3, 20, 7, 41, 37, 332, DateTimeKind.Utc), priv.Timestamp);
|
||||
Assert.IsTrue(priv.Badges.SequenceEqual([new Badge("subscriber", "0")]));
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
|
||||
var _cheer = ReceivedMessage.Parse(CHEER);
|
||||
Assert.AreEqual(IrcMessageType.PRIVMSG, _cheer.MessageType);
|
||||
if (_cheer is Privmsg cheer)
|
||||
{
|
||||
Assert.AreEqual("This includes a cheer Cheer100", cheer.ChatMessage);
|
||||
Assert.AreEqual(100, cheer.Bits);
|
||||
Assert.AreEqual("CheeringUser", cheer.DisplayName);
|
||||
Assert.AreEqual(Color.FromArgb(255, 0, 0), cheer.Color);
|
||||
Assert.AreEqual("5eab1319-5d46-4c55-be29-33c2f834e42e", cheer.Id);
|
||||
Assert.IsTrue(cheer.FirstMessage);
|
||||
Assert.IsFalse(cheer.Moderator);
|
||||
Assert.AreEqual("321654987", cheer.RoomId);
|
||||
Assert.IsFalse(cheer.Subscriber);
|
||||
Assert.IsTrue(cheer.Turbo);
|
||||
Assert.AreEqual("012345678", cheer.UserId);
|
||||
Assert.AreEqual(UserType.Normal, cheer.UserType);
|
||||
Assert.IsTrue(cheer.Vip);
|
||||
Assert.AreEqual(new DateTime(2024, 3, 20, 7, 47, 6, 069, DateTimeKind.Utc), cheer.Timestamp);
|
||||
Assert.IsTrue(cheer.Badges.SequenceEqual([
|
||||
new Badge("subscriber", "9"),
|
||||
new Badge("twitch-recap-2023", "1"),
|
||||
]));
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
|
||||
var _escape = ReceivedMessage.Parse(ESCAPE);
|
||||
Assert.AreEqual(IrcMessageType.PRIVMSG, _escape.MessageType);
|
||||
if (_escape is Privmsg escape)
|
||||
{
|
||||
Assert.AreEqual("; \\\r\nabc", escape.MessageTags["escaped"]);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
|
||||
var _emotes = ReceivedMessage.Parse(EMOTES);
|
||||
Assert.AreEqual(IrcMessageType.PRIVMSG, _emotes.MessageType);
|
||||
if (_emotes is Privmsg emotes)
|
||||
{
|
||||
Assert.IsTrue(emotes.Emotes.SequenceEqual([
|
||||
new Emote("emotesv2_4f3ee26e385b46aa88d5f45307489939", 0, 12-0+1),
|
||||
new Emote("emotesv2_4f3ee26e385b46aa88d5f45307489939", 14, 26-14+1),
|
||||
new Emote("emotesv2_9046ad54f76f42389edb4cc828b1b057", 28, 35-28+1),
|
||||
new Emote("emotesv2_9046ad54f76f42389edb4cc828b1b057", 37, 44-37+1),
|
||||
]));
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
[TestMethod]
|
||||
public void TestUserNotice()
|
||||
{
|
||||
//these 4 are examples given from Twitch's USERNOTICE tags page
|
||||
var RESUB = @"@badge-info=;badges=staff/1,broadcaster/1,turbo/1;color=#008000;display-name=ronni;emotes=;" +
|
||||
@"id=db25007f-7a18-43eb-9379-80131e44d633;login=ronni;mod=0;msg-id=resub;msg-param-cumulative-months=6;msg-param-streak-months=2;" +
|
||||
@"msg-param-should-share-streak=1;msg-param-sub-plan=Prime;msg-param-sub-plan-name=Prime;room-id=12345678;subscriber=1;" +
|
||||
@"system-msg=ronni\shas\ssubscribed\sfor\s6\smonths!;tmi-sent-ts=1507246572675;turbo=1;user-id=87654321;user-type=staff" +
|
||||
@" :tmi.twitch.tv USERNOTICE #dallas :Great stream -- keep it up!";
|
||||
var GIFTED = @"@badge-info=;badges=staff/1,premium/1;color=#0000FF;display-name=TWW2;emotes=;" +
|
||||
@"id=e9176cd8-5e22-4684-ad40-ce53c2561c5e;login=tww2;mod=0;msg-id=subgift;msg-param-months=1;" +
|
||||
@"msg-param-recipient-display-name=Mr_Woodchuck;msg-param-recipient-id=55554444;msg-param-recipient-name=mr_woodchuck;" +
|
||||
@"msg-param-sub-plan-name=House\sof\sNyoro~n;msg-param-sub-plan=1000;room-id=19571752;subscriber=0;" +
|
||||
@"system-msg=TWW2\sgifted\sa\sTier\s1\ssub\sto\sMr_Woodchuck!;tmi-sent-ts=1521159445153;turbo=0;user-id=87654321;user-type=staff" +
|
||||
@" :tmi.twitch.tv USERNOTICE #forstycup";
|
||||
var RAID = @"@badge-info=;badges=turbo/1;color=#9ACD32;display-name=TestChannel;emotes=;id=3d830f12-795c-447d-af3c-ea05e40fbddb;" +
|
||||
@"login=testchannel;mod=0;msg-id=raid;msg-param-displayName=TestChannel;msg-param-login=testchannel;msg-param-viewerCount=15;" +
|
||||
@"room-id=33332222;subscriber=0;system-msg=15\sraiders\sfrom\sTestChannel\shave\sjoined\n!;tmi-sent-ts=1507246572675;turbo=1;" +
|
||||
@"user-id=123456;user-type= :tmi.twitch.tv USERNOTICE #othertestchannel";
|
||||
var NEWCHATTER = @"@badge-info=;badges=;color=;display-name=SevenTest1;emotes=30259:0-6;id=37feed0f-b9c7-4c3a-b475-21c6c6d21c3d;" +
|
||||
@"login=seventest1;mod=0;msg-id=ritual;msg-param-ritual-name=new_chatter;room-id=87654321;subscriber=0;" +
|
||||
@"system-msg=Seventoes\sis\snew\shere!;tmi-sent-ts=1508363903826;turbo=0;user-id=77776666;user-type=" +
|
||||
@" :tmi.twitch.tv USERNOTICE #seventoes :HeyGuys";
|
||||
|
||||
var _resub = ReceivedMessage.Parse(RESUB);
|
||||
Assert.AreEqual(IrcMessageType.USERNOTICE, _resub.MessageType);
|
||||
if (_resub is UserNotice resub)
|
||||
{
|
||||
Assert.AreEqual(Color.FromArgb(0, 128, 0), resub.Color);
|
||||
Assert.AreEqual("ronni", resub.DisplayName);
|
||||
Assert.AreEqual("db25007f-7a18-43eb-9379-80131e44d633", resub.Id);
|
||||
Assert.AreEqual("ronni", resub.Login);
|
||||
Assert.IsFalse(resub.Moderator);
|
||||
Assert.AreEqual(RitualType.None, resub.RitualType);
|
||||
Assert.AreEqual(UserNoticeType.resub, resub.UserNoticeType);
|
||||
Assert.AreEqual(6, resub.TotalMonths);
|
||||
Assert.AreEqual(2, resub.StreakMonths);
|
||||
Assert.IsTrue(resub.ShouldShareStreak);
|
||||
Assert.AreEqual(SubType.Prime, resub.SubPlan);
|
||||
Assert.AreEqual("Prime", resub.SubPlanName);
|
||||
Assert.AreEqual("12345678", resub.RoomId);
|
||||
Assert.IsTrue(resub.Subscriber);
|
||||
Assert.AreEqual("ronni has subscribed for 6 months!", resub.SystemMessage);
|
||||
Assert.AreEqual(new DateTime(2017, 10, 5, 23, 36, 12, 675, DateTimeKind.Utc),
|
||||
resub.Timestamp);
|
||||
Assert.IsTrue(resub.Turbo);
|
||||
Assert.AreEqual("87654321", resub.UserId);
|
||||
Assert.AreEqual(UserType.Staff, resub.UserType);
|
||||
Assert.AreEqual("dallas", resub.Channel);
|
||||
Assert.AreEqual("Great stream -- keep it up!", resub.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
|
||||
var _gifted = ReceivedMessage.Parse(GIFTED);
|
||||
Assert.AreEqual(IrcMessageType.USERNOTICE, _gifted.MessageType);
|
||||
if (_gifted is UserNotice gifted)
|
||||
{
|
||||
Assert.AreEqual(Color.FromArgb(0, 0, 255), gifted.Color);
|
||||
Assert.AreEqual("TWW2", gifted.DisplayName);
|
||||
Assert.AreEqual("e9176cd8-5e22-4684-ad40-ce53c2561c5e", gifted.Id);
|
||||
Assert.AreEqual("tww2", gifted.Login);
|
||||
Assert.IsFalse(gifted.Moderator);
|
||||
Assert.AreEqual(RitualType.None, gifted.RitualType);
|
||||
Assert.AreEqual(UserNoticeType.subgift, gifted.UserNoticeType);
|
||||
Assert.AreEqual(1, gifted.TotalMonths);
|
||||
Assert.AreEqual("Mr_Woodchuck", gifted.RecipientDisplayName);
|
||||
//Twitch's example uses "msg-param-recipient-name" which doesn't appear anywhere
|
||||
//else in the documentation. I believe this was inteded to be "msg-param-recipient-user-name"
|
||||
//Assert.AreEqual("mr_woodchuck", gifted.RecipientUsername);
|
||||
Assert.AreEqual("55554444", gifted.RecipientId);
|
||||
Assert.AreEqual("House of Nyoro~n", gifted.SubPlanName);
|
||||
Assert.AreEqual(SubType.T1, gifted.SubPlan);
|
||||
Assert.AreEqual("19571752", gifted.RoomId);
|
||||
Assert.IsFalse(gifted.Subscriber);
|
||||
Assert.AreEqual("TWW2 gifted a Tier 1 sub to Mr_Woodchuck!", gifted.SystemMessage);
|
||||
Assert.AreEqual(new DateTime(2018, 3, 16, 0, 17, 25, 153, DateTimeKind.Utc),
|
||||
gifted.Timestamp);
|
||||
Assert.IsFalse(gifted.Turbo);
|
||||
Assert.AreEqual("87654321", gifted.UserId);
|
||||
Assert.AreEqual(UserType.Staff, gifted.UserType);
|
||||
Assert.AreEqual("forstycup", gifted.Channel);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
|
||||
var _raid = ReceivedMessage.Parse(RAID);
|
||||
Assert.AreEqual(IrcMessageType.USERNOTICE, _raid.MessageType);
|
||||
if (_raid is UserNotice raid)
|
||||
{
|
||||
Assert.AreEqual(Color.FromArgb(154, 205, 50), raid.Color);
|
||||
Assert.AreEqual("TestChannel", raid.DisplayName);
|
||||
Assert.AreEqual("3d830f12-795c-447d-af3c-ea05e40fbddb", raid.Id);
|
||||
Assert.AreEqual("testchannel", raid.Login);
|
||||
Assert.IsFalse(raid.Moderator);
|
||||
Assert.AreEqual(RitualType.None, raid.RitualType);
|
||||
Assert.AreEqual(UserNoticeType.raid, raid.UserNoticeType);
|
||||
Assert.AreEqual("TestChannel", raid.RaidingChannelDisplayName);
|
||||
Assert.AreEqual("testchannel", raid.RaidingChannelLogin);
|
||||
Assert.AreEqual(15, raid.ViewerCount);
|
||||
Assert.AreEqual("33332222", raid.RoomId);
|
||||
Assert.IsFalse(raid.Subscriber);
|
||||
Assert.AreEqual("15 raiders from TestChannel have joined\n!", raid.SystemMessage);
|
||||
Assert.AreEqual(new DateTime(2017, 10, 5, 23, 36, 12, 675, DateTimeKind.Utc),
|
||||
raid.Timestamp);
|
||||
Assert.IsTrue(raid.Turbo);
|
||||
Assert.AreEqual("123456", raid.UserId);
|
||||
Assert.AreEqual(UserType.Normal, raid.UserType);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
|
||||
var _newchatter = ReceivedMessage.Parse(NEWCHATTER);
|
||||
Assert.AreEqual(IrcMessageType.USERNOTICE, _newchatter.MessageType);
|
||||
if (_newchatter is UserNotice newchatter)
|
||||
{
|
||||
Assert.AreEqual(null, newchatter.Color);
|
||||
Assert.AreEqual("SevenTest1", newchatter.DisplayName);
|
||||
Assert.AreEqual("37feed0f-b9c7-4c3a-b475-21c6c6d21c3d", newchatter.Id);
|
||||
Assert.AreEqual("seventest1", newchatter.Login);
|
||||
Assert.IsFalse(newchatter.Moderator);
|
||||
Assert.AreEqual(RitualType.new_chatter, newchatter.RitualType);
|
||||
Assert.AreEqual("87654321", newchatter.RoomId);
|
||||
Assert.IsFalse(newchatter.Subscriber);
|
||||
Assert.AreEqual("Seventoes is new here!", newchatter.SystemMessage);
|
||||
Assert.AreEqual(new DateTime(2017, 10, 18, 21, 58, 23, 826, DateTimeKind.Utc),
|
||||
newchatter.Timestamp);
|
||||
Assert.IsFalse(newchatter.Turbo);
|
||||
Assert.AreEqual("77776666", newchatter.UserId);
|
||||
Assert.AreEqual(UserType.Normal, newchatter.UserType);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
[TestMethod]
|
||||
public void TestUserstate()
|
||||
{
|
||||
var USERSTATE = @"@badge-info=;badges=staff/1;color=#0D4200;display-name=ronni;" +
|
||||
@"emote-sets=0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239;mod=1;subscriber=1;" +
|
||||
@"turbo=1;user-type=staff :tmi.twitch.tv USERSTATE #dallas";
|
||||
|
||||
var _userstate = ReceivedMessage.Parse(USERSTATE);
|
||||
Assert.AreEqual(IrcMessageType.USERSTATE, _userstate.MessageType);
|
||||
if (_userstate is UserState userstate)
|
||||
{
|
||||
Assert.AreEqual("dallas", userstate.Channel);
|
||||
Assert.AreEqual(Color.FromArgb(13, 66, 0), userstate.Color);
|
||||
Assert.AreEqual("ronni", userstate.DisplayName);
|
||||
Assert.IsTrue(userstate.EmoteSets.SequenceEqual([0, 33, 50, 237, 793, 2126, 3517, 4578, 5569, 9400, 10337, 12239]));
|
||||
Assert.IsTrue(userstate.Moderator);
|
||||
Assert.IsTrue(userstate.Subscriber);
|
||||
Assert.IsTrue(userstate.Turbo);
|
||||
Assert.AreEqual(UserType.Staff, userstate.UserType);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
[TestMethod]
|
||||
public void TestWhisper()
|
||||
{
|
||||
//Taken from a Twitch documentation example
|
||||
//https://dev.twitch.tv/docs/irc/tags/#whisper-tags
|
||||
var WHISPER = @"@badges=staff/1,bits-charity/1;color=#8A2BE2;display-name=PetsgomOO;emotes=;message-id=306;" +
|
||||
@"thread-id=12345678_87654321;turbo=0;user-id=87654321;user-type=staff" +
|
||||
@" :petsgomoo!petsgomoo@petsgomoo.tmi.twitch.tv WHISPER foo :hello";
|
||||
|
||||
var _whisper = ReceivedMessage.Parse(WHISPER);
|
||||
Assert.AreEqual(IrcMessageType.WHISPER, _whisper.MessageType);
|
||||
if (_whisper is Whisper whisper)
|
||||
{
|
||||
Assert.IsTrue(whisper.Badges.SequenceEqual([
|
||||
new Badge("staff", "1"),
|
||||
new Badge("bits-charity", "1"),
|
||||
]));
|
||||
Assert.AreEqual(Color.FromArgb(138, 43, 226), whisper.Color);
|
||||
Assert.AreEqual("PetsgomOO", whisper.DisplayName);
|
||||
Assert.IsTrue(whisper.Emotes.SequenceEqual([]));
|
||||
Assert.AreEqual("306", whisper.MessageId);
|
||||
Assert.AreEqual("12345678_87654321", whisper.ThreadId);
|
||||
Assert.IsFalse(whisper.Turbo);
|
||||
Assert.AreEqual("87654321", whisper.UserId);
|
||||
Assert.AreEqual(UserType.Staff, whisper.UserType);
|
||||
Assert.AreEqual("hello", whisper.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
[TestMethod]
|
||||
public void TestGlobalUserState()
|
||||
{
|
||||
var GLOBAL = @"@badge-info=subscriber/8;badges=subscriber/6;color=#0D4200;display-name=dallas;" +
|
||||
@"emote-sets=0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239;turbo=0;user-id=12345678;" +
|
||||
@"user-type=admin :tmi.twitch.tv GLOBALUSERSTATE";
|
||||
|
||||
var _global = ReceivedMessage.Parse(GLOBAL);
|
||||
Assert.AreEqual(IrcMessageType.GLOBALUSERSTATE, _global.MessageType);
|
||||
if (_global is GlobalUserState global)
|
||||
{
|
||||
Assert.IsTrue(global.BadgeInfo.SequenceEqual(["subscriber/8"]));
|
||||
Assert.IsTrue(global.Badges.SequenceEqual([new Badge("subscriber", "6")]));
|
||||
Assert.AreEqual(Color.FromArgb(13, 66, 0), global.Color);
|
||||
Assert.AreEqual("dallas", global.DisplayName);
|
||||
Assert.IsTrue(global.EmoteSets.SequenceEqual([
|
||||
0, 33, 50, 237, 793, 2126, 3517, 4578, 5569, 9400, 10337,
|
||||
12239]));
|
||||
Assert.IsFalse(global.Turbo);
|
||||
Assert.AreEqual("12345678", global.UserId);
|
||||
Assert.AreEqual(UserType.Admin, global.UserType);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
[TestMethod]
|
||||
public void TestClearMsg()
|
||||
{
|
||||
var CLEARMSG = @"@login=ronni;room-id=;target-msg-id=abc-123-def;tmi-sent-ts=1642720582342" +
|
||||
@" :tmi.twitch.tv CLEARMSG #dallas :HeyGuys";
|
||||
|
||||
var _clearmsg = ReceivedMessage.Parse(CLEARMSG);
|
||||
Assert.AreEqual(IrcMessageType.CLEARMSG, _clearmsg.MessageType);
|
||||
if (_clearmsg is ClearMsg clearmsg)
|
||||
{
|
||||
Assert.AreEqual("ronni", clearmsg.Login);
|
||||
Assert.AreEqual("", clearmsg.RoomId);
|
||||
Assert.AreEqual("abc-123-def", clearmsg.TargetMessageId);
|
||||
Assert.AreEqual(new DateTime(2022, 1, 20, 23, 16, 22, 342, DateTimeKind.Utc),
|
||||
clearmsg.Timestamp);
|
||||
Assert.AreEqual("dallas", clearmsg.Channel);
|
||||
Assert.AreEqual("HeyGuys", clearmsg.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
[TestMethod]
|
||||
public void TestClearChat()
|
||||
{
|
||||
var PERMA = @"@room-id=12345678;target-user-id=87654321;tmi-sent-ts=1642715756806" +
|
||||
@" :tmi.twitch.tv CLEARCHAT #dallas :ronni";
|
||||
var CLEARCHAT = @"@room-id=12345678;tmi-sent-ts=1642715695392 :tmi.twitch.tv CLEARCHAT #dallas";
|
||||
var TIMEOUT = @"@ban-duration=350;room-id=12345678;target-user-id=87654321;tmi-sent-ts=1642719320727" +
|
||||
@" :tmi.twitch.tv CLEARCHAT #dallas :ronni";
|
||||
|
||||
var _perma = ReceivedMessage.Parse(PERMA);
|
||||
Assert.AreEqual(IrcMessageType.CLEARCHAT, _perma.MessageType);
|
||||
if (_perma is ClearChat perma)
|
||||
{
|
||||
Assert.AreEqual("12345678", perma.RoomId);
|
||||
Assert.AreEqual("87654321", perma.TargetUserId);
|
||||
Assert.AreEqual(new DateTime(2022, 1, 20, 21, 55, 56, 806, DateTimeKind.Utc),
|
||||
perma.Timestamp);
|
||||
Assert.AreEqual("dallas", perma.Channel);
|
||||
Assert.AreEqual("ronni", perma.User);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
|
||||
var _clearchat = ReceivedMessage.Parse(CLEARCHAT);
|
||||
Assert.AreEqual(IrcMessageType.CLEARCHAT, _clearchat.MessageType);
|
||||
if (_clearchat is ClearChat clearchat)
|
||||
{
|
||||
Assert.AreEqual("12345678", clearchat.RoomId);
|
||||
Assert.AreEqual(new DateTime(2022, 1, 20, 21, 54, 55, 392),
|
||||
clearchat.Timestamp);
|
||||
Assert.AreEqual("dallas", clearchat.Channel);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
|
||||
var _timeout = ReceivedMessage.Parse(TIMEOUT);
|
||||
Assert.AreEqual(IrcMessageType.CLEARCHAT, _timeout.MessageType);
|
||||
if (_timeout is ClearChat timeout)
|
||||
{
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
[TestMethod]
|
||||
public void TestHostTarget()
|
||||
{
|
||||
var START = @":tmi.twitch.tv HOSTTARGET #abc :xyz 10";
|
||||
var END = @":tmi.twitch.tv HOSTTARGET #abc :- 10";
|
||||
//this should be valid based on the Twitch documentation but there
|
||||
//doesn't seem to be a real use case
|
||||
var NOCHAN = @":tmi.twitch.tv HOSTTARGET #abc : 10";
|
||||
|
||||
var _start = ReceivedMessage.Parse(START);
|
||||
Assert.AreEqual(IrcMessageType.HOSTTARGET, _start.MessageType);
|
||||
if (_start is HostTarget start)
|
||||
{
|
||||
Assert.AreEqual("abc", start.HostingChannel);
|
||||
Assert.AreEqual("xyz", start.ChannelBeingHosted);
|
||||
Assert.AreEqual(10, start.NumberOfViewers);
|
||||
Assert.IsTrue(start.NowHosting);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
|
||||
var _end = ReceivedMessage.Parse(END);
|
||||
Assert.AreEqual(IrcMessageType.HOSTTARGET, _end.MessageType);
|
||||
if (_end is HostTarget end)
|
||||
{
|
||||
Assert.AreEqual("abc", end.HostingChannel);
|
||||
Assert.AreEqual("", end.ChannelBeingHosted);
|
||||
Assert.IsFalse(end.NowHosting);
|
||||
Assert.AreEqual(10, end.NumberOfViewers);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
|
||||
var _nochan = ReceivedMessage.Parse(NOCHAN);
|
||||
Assert.AreEqual(IrcMessageType.HOSTTARGET, _nochan.MessageType);
|
||||
if (_nochan is HostTarget nochan)
|
||||
{
|
||||
Assert.AreEqual("abc", nochan.HostingChannel);
|
||||
Assert.AreEqual("", nochan.ChannelBeingHosted);
|
||||
Assert.IsTrue(nochan.NowHosting);
|
||||
Assert.AreEqual(10, nochan.NumberOfViewers);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Fail();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
TwitchIrcClientTests/TwitchIrcClientTests.csproj
Normal file
45
TwitchIrcClientTests/TwitchIrcClientTests.csproj
Normal file
@@ -0,0 +1,45 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="3.1.1" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TwitchIrcClient\TwitchIrcClient.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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