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